@warlock.js/ai-panoptic 4.14.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 +10 -0
- package/cjs/index.cjs +78 -16
- package/cjs/index.cjs.map +1 -1
- package/esm/dashboard/serve.mjs +37 -5
- package/esm/dashboard/serve.mjs.map +1 -1
- package/esm/dashboard/ui.html.mjs +41 -11
- package/esm/dashboard/ui.html.mjs.map +1 -1
- package/llms-full.txt +8 -2
- package/llms.txt +1 -1
- package/package.json +21 -7
- package/skills/use-local-dashboard/SKILL.md +8 -2
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
|
|
2339
|
-
// 401 forever once authToken is configured
|
|
2340
|
-
// the only request the URL's query string
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
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,
|
|
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
|
-
/**
|
|
3513
|
-
|
|
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
|
|
3516
|
-
|
|
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
|
-
|
|
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
|
}
|