@warlock.js/ai-panoptic 4.15.0 → 4.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable changes to `@warlock.js/ai-panoptic` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
+ ## 4.16.0 - 2026-08-18
8
+
9
+ ### Security
10
+
11
+ - **Dashboard stored XSS via `javascript:` markdown links — fixed.** The dashboard's markdown link renderer (`mdInline` in `ui.html.ts`) rewrote `[text](url)` into a live `<a href>` without validating the URL scheme, so a `javascript:` URL embedded in captured trace content (`span.input` / `span.output` under `captureContent` — i.e. prompt-injected model output or attacker-controlled tool results) became a stored XSS that fired when an operator clicked the link; the page's CSP (`script-src 'unsafe-inline'`) permits `javascript:` URI execution and does not restrict top-level navigation, putting the in-page bearer token in reach. Link URLs are now checked against a scheme allowlist (`http:`, `https:`, `mailto:`, plus relative/anchor URLs) after normalizing the URL the way a browser will — attribute-entity decode, strip of ignored control chars/whitespace (`java\tscript:`), lowercase — and scheme-relative `//host` links are rejected too; a rejected URL renders its label as plain text with no `href` at all. Regression-tested by executing the actual inlined client renderer against `javascript:`/`data:`/`vbscript:` payloads and their case/whitespace/entity obfuscations (`ui.html.md-links.spec.ts`)
12
+ - **Dashboard token-handling hardening (3 residual findings from the same audit as the XSS above).** None of these are exploitable on their own today, but each widened the blast radius of a future bug:
13
+ - *Bearer token no longer a page-global.* `ui.html.ts`'s client script kept `TOKEN` as a `var` shared across its whole ~1000-line closure. It's now sealed inside its own inner IIFE that exposes only the `fetchAuthed` helper — code elsewhere in that closure (present or future) can no longer read the raw token by name.
14
+ - *Constant-time token comparison.* `serve.ts`'s `isAuthorized` compared the header/query token with plain `===`, which short-circuits on the first differing byte and is a textbook timing side-channel — the one scenario the package's own docs call out as realistic (`authToken` is required specifically when bound off-loopback, i.e. reachable over a network). Both the header and query-string checks now go through `crypto.timingSafeEqual` on length-checked, equal-length buffers.
15
+ - *Token-in-URL narrowed to the one route that needs it.* `?token=` is no longer accepted on the JSON API routes, only on the HTML page route. The page's own polling already re-sends the token as an `Authorization` header (fixed in 4.8.2), so the only request that structurally *can't* carry a header is the initial browser navigation that loads the HTML shell — that's the sole remaining query-string exposure, narrowing the token's footprint in access/proxy logs and browser history from every poll to one request.
16
+
7
17
  ## 4.12.0
8
18
 
9
19
  ### Changed
package/cjs/index.cjs CHANGED
@@ -3,6 +3,7 @@ let _warlock_js_ai = require("@warlock.js/ai");
3
3
  let node_fs_promises = require("node:fs/promises");
4
4
  let node_path = require("node:path");
5
5
  let node_http = require("node:http");
6
+ let node_crypto = require("node:crypto");
6
7
  let _warlock_js_logger = require("@warlock.js/logger");
7
8
 
8
9
  //#region ../ai-panoptic/src/exporters/utils/walk-spans.ts
@@ -2335,16 +2336,23 @@ function dashboardHtml(basePath, title, evaluateEnabled = false, evaluateDefault
2335
2336
  var EVALUATE_DEFAULT_INSTRUCTIONS = ${encodeForInlineScript(evaluateDefaultInstructions)};
2336
2337
 
2337
2338
  // Carry the ?token= the page itself was loaded with onto every
2338
- // subsequent poll — otherwise the API calls below inherit no auth and
2339
- // 401 forever once authToken is configured (the initial page load is
2340
- // the only request the URL's query string naturally reaches).
2341
- var TOKEN = new URLSearchParams(window.location.search).get("token");
2342
- function fetchAuthed(url, options) {
2343
- var opts = options || {};
2344
- var headers = opts.headers || {};
2345
- if (TOKEN) headers = Object.assign({}, headers, { Authorization: "Bearer " + TOKEN });
2346
- return fetch(url, Object.assign({}, opts, { headers: headers }));
2347
- }
2339
+ // subsequent poll as an Authorization header — otherwise the API calls
2340
+ // below inherit no auth and 401 forever once authToken is configured
2341
+ // (the initial page load is the only request the URL's query string
2342
+ // naturally reaches; the server only honors ?token= on that one route,
2343
+ // see serve.ts). The token itself is kept in its own nested closure,
2344
+ // not a plain var sitting alongside the rest of this file's top-level
2345
+ // state, so it isn't trivially reachable from other code sharing this
2346
+ // script's outer scope; only the fetchAuthed function it returns is.
2347
+ var fetchAuthed = (function () {
2348
+ var TOKEN = new URLSearchParams(window.location.search).get("token");
2349
+ return function fetchAuthed(url, options) {
2350
+ var opts = options || {};
2351
+ var headers = opts.headers || {};
2352
+ if (TOKEN) headers = Object.assign({}, headers, { Authorization: "Bearer " + TOKEN });
2353
+ return fetch(url, Object.assign({}, opts, { headers: headers }));
2354
+ };
2355
+ })();
2348
2356
 
2349
2357
  var state = {
2350
2358
  traces: [], selectedId: null, selectedSpanId: null, collapsed: {}, sig: null,
@@ -2652,11 +2660,34 @@ function dashboardHtml(basePath, title, evaluateEnabled = false, evaluateDefault
2652
2660
  return out;
2653
2661
  }
2654
2662
 
2663
+ // (S5) Markdown link URLs come from captured prompt/tool text — attacker-
2664
+ // influenced content — so only http:, https:, mailto: and relative/anchor
2665
+ // URLs may become an href; javascript:, data:, vbscript:, etc. must not.
2666
+ // The check runs on the URL as the browser will act on it: undo the
2667
+ // entities esc() introduced (the HTML parser decodes them exactly once in
2668
+ // the attribute), strip the control chars / whitespace browsers ignore
2669
+ // when parsing a scheme (java\\tscript:), and lowercase. Returns the
2670
+ // original (still-escaped) URL when safe, or null to drop the link.
2671
+ function sanitizeHref(url) {
2672
+ var probe = url.replace(/&(amp|lt|gt|quot|#39);/g, function (m, name) {
2673
+ return { amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'" }[name];
2674
+ });
2675
+ probe = probe.replace(/[\\u0000-\\u0020\\u007f]+/g, "").toLowerCase();
2676
+ if (/^[a-z][a-z0-9+.-]*:/.test(probe)) {
2677
+ return /^(https?|mailto):/.test(probe) ? url : null;
2678
+ }
2679
+ if (/^[\\/\\\\]{2}/.test(probe)) return null; // scheme-relative smuggles a foreign host
2680
+ return url;
2681
+ }
2655
2682
  function mdInline(s) {
2656
2683
  s = s.replace(/\\*\\*([^*]+)\\*\\*/g, "<strong>$1</strong>");
2657
2684
  var codeRe = new RegExp(BT + "([^" + BT + "]+)" + BT, "g");
2658
2685
  s = s.replace(codeRe, "<code>$1</code>");
2659
- s = s.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
2686
+ s = s.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, function (m, label, url) {
2687
+ var href = sanitizeHref(url);
2688
+ if (href === null) return label; // unsafe scheme: label as plain text, no <a>
2689
+ return '<a href="' + href + '" target="_blank" rel="noopener">' + label + "</a>";
2690
+ });
2660
2691
  return s;
2661
2692
  }
2662
2693
  function mdToHtml(raw) {
@@ -3509,11 +3540,41 @@ function hostHeaderName(hostHeader) {
3509
3540
  const colon = hostHeader.indexOf(":");
3510
3541
  return colon === -1 ? hostHeader : hostHeader.slice(0, colon);
3511
3542
  }
3512
- /** Constant-ish bearer-token check from `Authorization` header or `?token=`. */
3513
- function isAuthorized(req, url, token) {
3543
+ /**
3544
+ * Constant-time string equality (S4) — guards against a timing
3545
+ * side-channel that could otherwise let a network-adjacent attacker
3546
+ * recover the token byte-by-byte via repeated timed guesses. Plain `===`
3547
+ * short-circuits on the first differing byte, so response latency leaks
3548
+ * how many leading bytes matched; `timingSafeEqual` does not. Lengths are
3549
+ * compared first (a length mismatch is not secret and `timingSafeEqual`
3550
+ * requires equal-length buffers anyway).
3551
+ */
3552
+ function constantTimeEqual(a, b) {
3553
+ const bufA = Buffer.from(a);
3554
+ const bufB = Buffer.from(b);
3555
+ if (bufA.length !== bufB.length) return false;
3556
+ return (0, node_crypto.timingSafeEqual)(bufA, bufB);
3557
+ }
3558
+ /**
3559
+ * Bearer-token check (S4): `Authorization: Bearer <token>` header, or
3560
+ * (only when `allowQueryToken`) a `?token=` query param.
3561
+ *
3562
+ * The query-string form only exists for the one request that structurally
3563
+ * cannot carry a custom header — the initial browser navigation that
3564
+ * loads the HTML shell (a typed/clicked/bookmarked URL). Every other
3565
+ * request the served page makes is same-origin `fetch()`, which can and
3566
+ * does set `Authorization` (see `ui.html.ts`'s `fetchAuthed`), so the API
3567
+ * routes never need to accept a query-string token. Restricting the
3568
+ * fallback to just the page route minimizes the token's exposure in
3569
+ * server access logs, proxy logs, and browser history to a single route
3570
+ * instead of every poll.
3571
+ */
3572
+ function isAuthorized(req, url, token, allowQueryToken) {
3514
3573
  const header = req.headers.authorization;
3515
- if (header && header === `Bearer ${token}`) return true;
3516
- return url.searchParams.get("token") === token;
3574
+ if (header && constantTimeEqual(header, `Bearer ${token}`)) return true;
3575
+ if (!allowQueryToken) return false;
3576
+ const queryToken = url.searchParams.get("token");
3577
+ return queryToken !== null && constantTimeEqual(queryToken, token);
3517
3578
  }
3518
3579
  /**
3519
3580
  * Build the `node:http` request handler for the dashboard over a given
@@ -3553,7 +3614,8 @@ function createRequestHandler(store, config) {
3553
3614
  sendJson(res, 403, { error: "host_not_allowed" });
3554
3615
  return;
3555
3616
  }
3556
- if (config.authToken && !isAuthorized(req, url, config.authToken)) {
3617
+ const isPageRoute = pathname === base || pathname === base.replace(/\/$/, "");
3618
+ if (config.authToken && !isAuthorized(req, url, config.authToken, isPageRoute)) {
3557
3619
  sendJson(res, 401, { error: "unauthorized" });
3558
3620
  return;
3559
3621
  }