// The escape helpers were tested; their CALL SITES were not. // // dashboard-escape-behaviour.test.js proves esc() and escJsAttr() neutralise every // breakout character. It says nothing about whether the template that renders an // account label actually calls them — remove the esc() from a sink and both existing // guards stay green. There are 200+ escape call sites against 1000+ interpolations, so // "the helpers are correct" is a much weaker statement than it looks. // // This guard closes the gap for the fields that carry account-holder, upstream or // caller data AND reach innerHTML. // // HISTORY, because it is the whole reason this file was rewritten (2026-09-13 audit): // the first version policed ['lastError','apiKey_masked','keyPrefix','planName'] and // matched ZERO of them as unescaped sinks. Its own "guard the guard" test passed, // because every one of those fields DID have an escaped sink elsewhere in the file — // so the guard reported success while enforcing nothing at all. The enforced list is // now id/status/name, which matched 39 live sites on the day it was switched. // // Two consequences, both encoded below: // 1. A guard whose candidate list is EMPTY must fail, not pass. Counting offenders // is not enough — the list must be shown to match real interpolations. // 2. Every tolerated site is an explicit, reviewed entry with a reason. There is no // blanket "numeric-looking" or "inside a URL" escape hatch: a silent skip is how // this guard died the first time. import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; const DASHBOARD = new URL('../src/dashboard/index.html', import.meta.url); /** * Fields whose values cross a trust boundary and reach innerHTML: * id/status — account records echoed by /dashboard/api/accounts (the id is a * server-generated UUID fragment, but it is still response data, and the * panel renders it into , class attributes and title attributes). * name — a field descriptor supplied by CALLER code (App.prompt) and read back * out of the DOM via [data-name] -> values[el.dataset.name]. */ const ENFORCED_FIELDS = ['id', 'status', 'name']; /** * Interpolations that reach the DOM via textContent are safe without escaping. * * This is a CONTEXT check, and it is the one blanket rule in the file: an expression * on the same line as a textContent/innerText assignment cannot inject markup no * matter what it contains. Everything else must be escaped or allowlisted. The check * is deliberately line-scoped: a wider window crosses statement boundaries and makes * the candidate set depend on CRLF vs LF, which is how this guard once skipped a live * fetch-path interpolation. */ const TEXT_CONTENT_SINKS = /textContent|innerText|\.title\s*=|toast\(/; /** * Sites the enforced fields match that are deliberately left WITHOUT esc(), each with * the reason it cannot break out of its context. Keyed `line|field`. * * Line numbers move. That is the point: a stale entry fails the entry-exists check * below, forcing a re-review, rather than silently permitting whatever has since moved * into that line. The expression is carried in the entry so a reviewer can see what was * tolerated without re-reading the file. * * Reviewed 2026-09-13 by reading each site in the shipped dashboard. Four classes: * A. not-in-DOM — the interpolation builds a JS string (a cache signature) or * a non-HTML value; it is never assigned to innerHTML. * B. url-or-selector — it lands in a fetch URL, a percent-encoded path, or a * querySelector selector, not in markup. * C. numeric-or-http — an HTTP status code, a count, or a hardcoded literal. * D. already-escaped — the interpolation is fed an escaped local variable * (`const name = this.esc(...)`), so wrapping it again would * double-encode the visible text. */ const REVIEWED_NOT_SINKS = [ { line: 6011, field: 'status', expr: 'hr.status}', why: 'C numeric-or-http: HTTP status code, always a Number' }, { line: 6223, field: 'id', expr: 'a.id}', why: 'A not-in-DOM: value feeds a JS string/cache signature, not innerHTML' }, { line: 6223, field: 'status', expr: 'a.status}', why: 'C numeric-or-http: HTTP status code, always a Number' }, { line: 6320, field: 'name', expr: 'name}', why: 'A not-in-DOM: value feeds a JS string/cache signature, not innerHTML' }, { line: 9020, field: 'status', expr: '!ok ? (() => { const errCode = String(h.status || \'\').replace(skipped ? \'skip', why: 'C numeric-or-http: HTTP status code, always a Number' }, { line: 9209, field: 'id', expr: 'encodeURIComponent(id)}', why: 'B url-or-selector: encoded into a fetch path or selector, not markup' }, { line: 9821, field: 'id', expr: 'CSS.escape(String(ev.id))}', why: 'B url-or-selector: encoded into a fetch path or selector, not markup' }, { line: 10020, field: 'id', expr: 'id}', why: 'B url-or-selector: re-auth probe path, not markup' }, { line: 10607, field: 'id', expr: 'encodeURIComponent(id)}', why: 'B url-or-selector: encoded into a fetch path or selector, not markup' }, { line: 10647, field: 'id', expr: 'encodeURIComponent(id)}', why: 'B url-or-selector: encoded into a fetch path or selector, not markup' }, { line: 10666, field: 'id', expr: 'encodeURIComponent(id)}', why: 'B url-or-selector: capacity-preflight POST path, not markup' }, { line: 10678, field: 'id', expr: 'id}', why: 'B url-or-selector: spend-policy PATCH path, not markup' }, { line: 10686, field: 'id', expr: 'id}', why: 'B url-or-selector: spend-reserve PATCH path, not markup' }, { line: 10772, field: 'id', expr: 'models.map(m => `