// S5-P2: the dashboard's inline-JS-string sinks used the wrong escaper. // // onclick="App.f('${value}')" is TWO nested contexts: the HTML attribute is // decoded first, then the JS string is parsed. esc() — or the sketch's old // escAttr(), which called esc() first and left its '.replace(/'/g, "\\'")' // with no target — emits ', which the attribute decode turns back into a // live single quote: `a'); App.deleteAccount('victim` ran. escJsAttr() emits // JS escapes (\', \x3c, \x26 …) so the attribute value carries no raw ' " < > // & at all. // // This file pins the cluster end to end: // * index-sketch.html gained escJsAttr (same contract as index.html's) and // every one of its quoted handler interpolations routes through it or a // proven-safe local; // * index.html's OAuth fail-panel email + loadLsBinary sinks are fixed; // * index.html has no leftover call to the never-defined escAttr(); // * auth.js updateAccountLabel stops a label from carrying control chars or // unbounded length into account.email in the first place. import { describe, it, after } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { addAccountByKey, removeAccount, updateAccountLabel, sanitizeLabelText } from '../src/auth.js'; const SKETCH = readFileSync(new URL('../src/dashboard/index-sketch.html', import.meta.url), 'utf8'); const DASHBOARD = readFileSync(new URL('../src/dashboard/index.html', import.meta.url), 'utf8'); // Same shipped-source extraction as dashboard-escape-behaviour.test.js — // reimplementing the helper here would pass while production degraded. function extractMethod(html, name) { const at = html.indexOf(`\n ${name}(s)`); assert.notEqual(at, -1, `${name}(s) must exist as a single-argument App method`); const open = html.indexOf('{', at); let depth = 0; let end = -1; for (let i = open; i < html.length; i++) { if (html[i] === '{') depth++; else if (html[i] === '}' && --depth === 0) { end = i + 1; break; } } assert.notEqual(end, -1, `could not delimit ${name}`); return new Function('s', `return (function ${name}(s) ${html.slice(open, end)}).call(null, s);`); } describe('index-sketch.html: escJsAttr replaces the dead escAttr composition', () => { it('the helper exists and neutralises JS-string breakouts', () => { const escJsAttr = extractMethod(SKETCH, 'escJsAttr'); const cases = [ ['\\', '\\\\'], ["'", "\\'"], ['"', '\\"'], ['<', '\\x3c'], ['>', '\\x3e'], ['&', '\\x26'], ['\r', '\\r'], ['\n', '\\n'], ['\u2028', '\\u2028'], ['\u2029', '\\u2029'], ]; for (const [raw, encoded] of cases) { assert.equal(escJsAttr(raw), encoded, `escJsAttr(${JSON.stringify(raw)})`); } // The exact payload that owned the old escAttr path. const out = escJsAttr("a'); App.deleteAccount('victim"); assert.doesNotMatch(out, /(? { // escAttr( catches both a helper definition (`escAttr(s) {`) and call // sites (`this.escAttr(...)`); prose mentioning the retired name is fine. assert.doesNotMatch(SKETCH, /\bescAttr\s*\(/, 'the broken helper must not linger as a copy-paste footgun'); }); it('every single-quoted interpolation inside an inline handler is safe', () => { // Sweep, not a point pin: '${x}' inside generated markup is only sound when // x is escJsAttr()'d or a local built from one (inlineId is random hex, // emailAttr/keyArg are escJsAttr outputs). const SAFE = /^(?:this\.escJsAttr\(|inlineId|emailAttr|keyArg|jid)/; const offenders = []; for (const m of SKETCH.matchAll(/'\$\{([^}]*)\}/g)) { if (!SAFE.test(m[1])) offenders.push(m[1].trim().slice(0, 80)); } assert.deepEqual(offenders, [], `quoted JS-string interpolations bypassing escJsAttr: ${offenders.join(' | ')}`); }); it('the OAuth success line escapes the server-supplied email', () => { assert.match(SKETCH, /logged in as \$\{this\.esc\(r\.email \|\| label\)\}/); assert.doesNotMatch(SKETCH, /logged in as \$\{r\.email \|\| label\}/); }); it('the fail-panel email goes through escJsAttr, not esc', () => { assert.match(SKETCH, /const emailAttr = this\.escJsAttr\(email \|\| r\.email \|\| ''\)/); assert.match(SKETCH, /addAccountFromInlineToken\('\$\{inlineId\}','\$\{emailAttr\}'\)/); }); }); describe('index.html: the same two sinks', () => { it('the OAuth fail-panel email goes through escJsAttr', () => { assert.match(DASHBOARD, /const emailAttr = this\.escJsAttr\(r\.email \|\| ''\)/); assert.match(DASHBOARD, /addAccountFromInlineToken\('\$\{inlineId\}','\$\{emailAttr\}'\)/); }); it('loadLsBinary escapes the server error text and the path', () => { assert.match(DASHBOARD, /\$\{this\.esc\(msg\)\}<\/span> · ]*>\$\{this\.esc\(r\.path \|\| ''\)\}<\/code>/); }); it('no call to the never-defined escAttr() remains', () => { // index.html never defined escAttr — the one call site at provider-swatch // was a latent TypeError waiting on the render path. Keep it at zero. assert.doesNotMatch(DASHBOARD, /\bescAttr\s*\(/); }); }); describe('updateAccountLabel: the label cannot carry hostile text into account.email', () => { const created = new Set(); after(() => { for (const id of created) removeAccount(id); }); function account(label) { const a = addAccountByKey(`devin-session-token$lbl-${randomUUID().slice(0, 8)}`, label); created.add(a.id); return a; } it('rejects control characters, oversized and non-string labels', () => { const a = account('ok@example.test'); const bad = [ 'a\nb', 'a\u001b[31m', 'a\rb', 'a\u0000b', 'a\u0085b', 'x'.repeat(129), '', ' ', 42, null, undefined, { toString: () => 'x' }, ]; for (const label of bad) { assert.throws(() => updateAccountLabel(a.id, label), (e) => e.code === 'ERR_ACCOUNT_LABEL_INVALID' && e.statusCode === 400, `label ${JSON.stringify(label)?.slice(0, 40)} must be refused`); } assert.equal(a.email, 'ok@example.test', 'a refused label must not mutate the account'); }); it('accepts printable (incl. non-ASCII) labels and stores them trimmed', () => { const a = account('before@example.test'); assert.equal(updateAccountLabel(a.id, ' renamed@example.test '), true); assert.equal(a.email, 'renamed@example.test'); assert.equal(updateAccountLabel(a.id, '张三 '), true); assert.equal(a.email, '张三 '); assert.equal(updateAccountLabel(a.id, 'x'.repeat(128)), true); }); it('addAccountByKey applies the same boundary at write time (S5)', () => { // The PATCH path got the validator first; add/import paths wrote `label` // straight into `email`. A hostile add must be a coded 400, not a stored // ANSI/forged-log label that only render-time sanitization would catch. for (const label of ['a\nb', 'a\u001b[31m', 'x'.repeat(129), 42]) { assert.throws( () => addAccountByKey(`devin-session-token$bad-${randomUUID().slice(0, 8)}`, label), (e) => e.code === 'ERR_ACCOUNT_LABEL_INVALID' && e.statusCode === 400, `add label ${JSON.stringify(label)?.slice(0, 40)} must be refused`); } // An absent/blank label is not an error — it falls back to the key prefix. const blank = addAccountByKey(`devin-session-token$blk-${randomUUID().slice(0, 8)}`, ' '); created.add(blank.id); assert.match(blank.email, /^key-/); // Non-string labels passed as undefined/null keep the fallback too. const none = addAccountByKey(`devin-session-token$non-${randomUUID().slice(0, 8)}`); created.add(none.id); assert.match(none.email, /^key-/); }); it('rejects invisible/bidi format characters (label spoofing, L5 probe)', () => { const a = account('ok@example.test'); // L5 measured these ALLOWED pre-fix: RLO can visually reverse a label on // the dashboard; ZWSP/BOM/SHY/isolates hide in plain sight. const spoof = [ 'x‮y', // RLO — bidi override 'x​y', // ZWSP 'xy', // BOM / ZWNBSP 'x­y', // soft hyphen 'x⁦y', // LRI isolate 'x⁧y', // RLI isolate 'x⁠y', // word joiner 'x᠎y', // Mongolian vowel separator (Cf) 'x͏y', // combining grapheme joiner 'x
y', // LINE SEPARATOR — renders a line break, forges a log line (L1-F3) 'x
y', // PARAGRAPH SEPARATOR — same forged-record class ]; for (const label of spoof) { assert.throws(() => updateAccountLabel(a.id, label), (e) => e.code === 'ERR_ACCOUNT_LABEL_INVALID', `label with ${[...label].map(c => 'U+' + c.codePointAt(0).toString(16)).join(' ')} must be refused`); } // Legitimate invisible-adjacent characters stay allowed: ZWJ (emoji // sequences) and ZWNJ/LRM/RLM (real RTL names). assert.equal(updateAccountLabel(a.id, 'family👨‍👩‍👧 dev'), true); assert.equal(updateAccountLabel(a.id, 'نام\u200cکاربر'), true); }); it('sanitizeLabelText strips the same characters for upstream-sourced writes', () => { // Upstream (login result / GetUserStatus) text is sanitized, not asserted: // a hostile response degrades to a clean label instead of throwing. assert.equal(sanitizeLabelText('a\u202Eb\u200bc'), 'abc'); assert.equal(sanitizeLabelText('a\u2028b\u2029c'), 'abc'); assert.equal(sanitizeLabelText(' spaced@example.test '), 'spaced@example.test'); assert.equal(sanitizeLabelText('a\nb\rc'), 'abc'); assert.equal(sanitizeLabelText(null), ''); assert.equal(sanitizeLabelText(42), '42'); }); });