diff --git a/public/region-filter.js b/public/region-filter.js
index 69f88fc7d..fe2d5557e 100644
--- a/public/region-filter.js
+++ b/public/region-filter.js
@@ -86,12 +86,16 @@
_listeners.forEach(function (fn) { fn(getSelected()); });
}
- /** Build summary label for dropdown trigger */
+ // #333: region codes include observer IATA values from MQTT, so every code
+ // and label is escaped for its attribute or text slot. dataset.region hands
+ // toggleRegion() the decoded code, so selection still matches _regions.
+
+ /** Build summary label for dropdown trigger (HTML-escaped) */
function dropdownLabel(codes) {
if (!_selected) return 'All Regions';
var sel = Array.from(_selected);
if (sel.length === 0) return 'All Regions';
- if (sel.length <= 2) return sel.join(', ');
+ if (sel.length <= 2) return escapeHtml(sel.join(', '));
return sel.length + ' Regions';
}
@@ -106,7 +110,7 @@
var label = _regions[code] || code;
var active = allSelected || (_selected && _selected.has(code));
html += '';
+ '" data-region="' + escapeHtml(code) + '" role="checkbox" aria-checked="' + !!active + '">' + escapeHtml(label) + '';
});
html += '';
container.innerHTML = html;
@@ -132,8 +136,8 @@
var cityName = configLabel || (window.IATA_CITIES && window.IATA_CITIES[code]);
var label = cityName ? (code + ' - ' + cityName) : code;
var active = allSelected || (_selected && _selected.has(code));
- html += '';
+ html += '';
});
html += '';
container.innerHTML = html;
diff --git a/scripts/check-xss-sinks.sh b/scripts/check-xss-sinks.sh
index fbd9148e9..7211e4894 100755
--- a/scripts/check-xss-sinks.sh
+++ b/scripts/check-xss-sinks.sh
@@ -13,6 +13,26 @@
# We accept these as residual risk in exchange for a regex-only gate that
# runs in <5s on every PR and surfaces actionable file:line evidence.
#
+# #351 N4 / #333: the gate flags an unescaped node-controlled field inside
+# a QUOTED ATTRIBUTE on a sink line, in both the template form
+# (`el.innerHTML = \`\``) and the concat
+# form (`el.innerHTML = ''`), because strings,
+# templates and // comments are parsed in one left-to-right pass (see
+# strip() below).
+#
+# KNOWN LIMITATION — same-line sinks only. The gate audits the line that
+# holds the sink (innerHTML = …, insertAdjacentHTML(…), bindPopup(…), …).
+# HTML built on OTHER lines and assigned later is not seen, e.g.
+# function row(o) { return '
\$\{groupRegion \? `\$\{[^}]*groupRegion[^}]*\}<\/span>` : '—'\}<\/td>)/;
+
+// --- Start a static server on a free port >= 13900 -----------------------
+function startServer() {
+ return new Promise((resolve, reject) => {
+ const server = http.createServer((req, res) => {
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end('');
+ });
+ let port = 13911;
+ const tryListen = () => {
+ server.once('error', (err) => {
+ if (err.code === 'EADDRINUSE' && port < 13999) { port++; tryListen(); }
+ else reject(err);
+ });
+ server.listen(port, '127.0.0.1', () => resolve({ server, port }));
+ };
+ tryListen();
+ });
+}
+
+// Render `renderExpr` (a JS expression yielding an HTML string) in-browser,
+// inject it into a real DOM subtree, and report what the parser produced.
+// `escName`/`escSrc` override the escaper in scope (e.g. customize-v2.js's
+// own esc), so a sink is rendered with the helper its file really calls.
+async function renderAndInspect(page, { renderExpr, argNames, args, wrap, escName, escSrc }) {
+ return page.evaluate(({ appEscSrc, renderExpr, argNames, args, wrap, escName, escSrc }) => {
+ delete window.__xss333;
+ const truncate = (s, n) => (s == null ? '' : String(s).slice(0, n));
+ const escapeHtml = (new Function(appEscSrc + '\nreturn escapeHtml;'))();
+ const localEsc = escSrc ? (new Function(escSrc + '\nreturn ' + escName + ';'))() : escapeHtml;
+ const fn = new Function(...argNames, 'escapeHtml', 'truncate', escName || '__unusedEsc',
+ 'return (' + renderExpr + ');');
+ const html = fn(...args, escapeHtml, truncate, localEsc);
+ const host = document.getElementById('host');
+ // A standalone
is foster-parented (dropped) unless it sits inside a
+ // table, so table-cell sinks must be re-homed before the parser runs.
+ host.innerHTML = wrap === 'table'
+ ? '
`)/);
+ const r = await renderAndInspect(page, {
+ renderExpr: tpl, argNames: ['route'], args: ['#/' + BENIGN],
+ });
+ assertInert(r, 'unknown route benign');
+ if (!r.text.includes(BENIGN))
+ throw new Error('unknown route: benign name mangled: ' + r.text);
+ });
+
+ // 2. Observer region — iata in the observers-table region cell (attr +
+ // badge text). Also confirms the data-value sort key survives.
+ await test('observer region: hostile iata renders inert, sort key preserved', async () => {
+ const tpl = liftTemplate('observers.js',
+ OBS_REGION_CELL);
+ const r = await renderAndInspect(page, {
+ renderExpr: '`' + tpl + '`', argNames: ['o'], args: [{ iata: PAYLOAD }], wrap: 'table',
+ });
+ assertInert(r, 'observer region');
+ if (r.dataValue !== PAYLOAD)
+ throw new Error('observer region: data-value sort key not preserved: ' + r.dataValue);
+ if (!r.hasRegionBadge)
+ throw new Error('observer region: badge-region span missing (display regressed)');
+ });
+
+ await test('observer region: benign iata displays verbatim in the badge', async () => {
+ const tpl = liftTemplate('observers.js',
+ OBS_REGION_CELL);
+ const r = await renderAndInspect(page, {
+ renderExpr: '`' + tpl + '`', argNames: ['o'], args: [{ iata: 'KÖLN & Co' }], wrap: 'table',
+ });
+ assertInert(r, 'observer region benign');
+ if (!r.text.includes('KÖLN & Co'))
+ throw new Error('observer region: benign iata mangled: ' + r.text);
+ });
+
+ // 3. Packet region badge — groupRegion from the observer map iata.
+ await test('packet region badge: hostile region renders inert', async () => {
+ const tpl = liftTemplate('packets.js',
+ PKT_REGION_CELL);
+ const r = await renderAndInspect(page, {
+ renderExpr: '`' + tpl + '`', argNames: ['groupRegion'], args: [PAYLOAD], wrap: 'table',
+ });
+ assertInert(r, 'packet region badge');
+ if (!r.hasRegionBadge)
+ throw new Error('packet region badge: badge-region span missing');
+ });
+
+ // 3b. Geo-filter prune preview (customize-v2.js) — rendered with the
+ // file's own textContent-based esc(), serialized by the real parser.
+ await test('geo-filter prune preview: hostile node name renders inert with customize-v2 esc()', async () => {
+ const src = fs.readFileSync(path.join(__dirname, 'public', 'customize-v2.js'), 'utf8');
+ const escFn = src.match(/\n\s*(function esc\(s\) \{[^\n]*\})/);
+ if (!escFn) throw new Error('could not lift esc() from public/customize-v2.js');
+ const expr = liftTemplate('customize-v2.js', /return '
'\s*\+\s*([\s\S]*?)\s*\+\s*coords\s*\+\s*'<\/div>';/);
+ for (const name of [PAYLOAD, BENIGN]) {
+ const r = await renderAndInspect(page, {
+ renderExpr: "'
' + (" + expr + ") + '
'", argNames: ['n'],
+ args: [{ name, pubkey: 'abcdef012345' }], escName: 'esc', escSrc: escFn[1],
+ });
+ assertInert(r, 'geo-filter prune preview');
+ if (r.text !== name)
+ throw new Error('geo-filter prune preview: name not shown verbatim: ' + JSON.stringify(r.text));
+ }
+ });
+
+ // 4. Favorites dropdown — node name in the fav-dd-name span.
+ await test('favorites dropdown: hostile node name renders inert', async () => {
+ const expr = liftTemplate('app.js',
+ /'\s*\+\s*([\s\S]*?)\s*\+\s*'<\/span>'/);
+ const renderExpr = "'' + (" + expr + ") + ''";
+ const r = await renderAndInspect(page, {
+ renderExpr, argNames: ['h', 'pk'],
+ args: [{ node: { name: PAYLOAD } }, 'abcdef0123456789'],
+ });
+ assertInert(r, 'favorites dropdown');
+ if (!r.text.includes(PAYLOAD))
+ throw new Error('favorites dropdown: payload not shown as literal text: ' + r.text);
+ });
+
+ await test('favorites dropdown: benign node name displays verbatim', async () => {
+ const expr = liftTemplate('app.js',
+ /'\s*\+\s*([\s\S]*?)\s*\+\s*'<\/span>'/);
+ const renderExpr = "'' + (" + expr + ") + ''";
+ const r = await renderAndInspect(page, {
+ renderExpr, argNames: ['h', 'pk'],
+ args: [{ node: { name: BENIGN } }, 'abcdef0123456789'],
+ });
+ assertInert(r, 'favorites dropdown benign');
+ if (r.text !== BENIGN)
+ throw new Error('favorites dropdown: benign name mangled: ' + JSON.stringify(r.text));
+ });
+
+ await context.close();
+ await browser.close();
+ await new Promise((resolve) => server.close(resolve));
+
+ console.log('\n' + passed + ' passed, ' + failed + ' failed');
+ process.exit(failed ? 1 : 0);
+})().catch((e) => { console.error(e); process.exit(1); });
diff --git a/test-xss-333-pages-e2e.js b/test-xss-333-pages-e2e.js
new file mode 100644
index 000000000..85b561d6d
--- /dev/null
+++ b/test-xss-333-pages-e2e.js
@@ -0,0 +1,323 @@
+// test-xss-333-pages-e2e.js
+//
+// Full-page XSS regression test for issue #333 round 2 — the adjacent sinks
+// found in review of PR #373:
+// F1 packets region badge in the flat view and in expanded group children
+// F2 the shared region filter (pills, dropdown items, dropdown trigger)
+// F3 observers-row navigation with an observer id that contains a quote
+// N1 (round 3) the nodes-page detail-panel "Regions:" summary line
+//
+// Unlike test-xss-333-e2e.js (production sink fragments rendered on a blank
+// page), this drives the REAL pages against a running Go server. The hostile
+// values are injected by rewriting two API responses in the browser
+// (/api/observers, /api/config/regions) with page.route — the fixture DB is
+// untouched and no payload leaves the machine. Every injected element would
+// run `window.__xss333++`, so the check is "did any handler fire" plus "is
+// there any in the DOM", not a string match on the source.
+//
+// Requires a server with the CI-prepared e2e fixture (see deploy.yml): SJC
+// observers exist, and the #1486 seed adds hash fae0c9e6d357a814 with three
+// observations (observers OAK, SJC, OAK).
+//
+// Usage: BASE_URL=http://localhost:13581 node test-xss-333-pages-e2e.js
+'use strict';
+const { chromium } = require('playwright');
+
+const BASE = process.env.BASE_URL || 'http://localhost:13581';
+const SEED_HASH = 'fae0c9e6d357a814';
+
+// Breaks out of a double-quoted attribute and carries a tag for the text
+// context; the trailing quote probes single-quoted contexts.
+const HOSTILE = '">\'';
+// Observer id that breaks out of a single-quoted JS string (F3). Only
+// characters encodeURIComponent leaves alone (' ( ) - letters digits), so
+// URL-encoding cannot neutralise it; a run shows up as an alert dialog.
+const QUOTE_ID = "xss333'-alert(333)-'";
+const BENIGN_REGION = 'KØB & Æ';
+
+let passed = 0, failed = 0;
+async function step(name, fn) {
+ try { await fn(); passed++; console.log(' ✓ ' + name); }
+ catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
+}
+function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
+
+// Rewrite /api/observers (SJC → HOSTILE, optional extra observers) and
+// /api/config/regions (whatever `regions` returns for the real response).
+async function hostileContext(browser, { regions, extraObservers = [], iataMap = { SJC: HOSTILE } }) {
+ const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
+ await ctx.route((url) => url.pathname === '/api/observers', async (route) => {
+ const res = await route.fetch();
+ const body = await res.json();
+ for (const o of body.observers || []) if (o.iata in iataMap) o.iata = iataMap[o.iata];
+ body.observers = (body.observers || []).concat(extraObservers);
+ await route.fulfill({ response: res, json: body });
+ });
+ await ctx.route((url) => url.pathname === '/api/config/regions', async (route) => {
+ const res = await route.fetch();
+ await route.fulfill({ response: res, json: regions(await res.json()) });
+ });
+ const page = await ctx.newPage();
+ page.setDefaultTimeout(15000);
+ page.on('pageerror', (e) => console.error(' [pageerror]', e.message));
+ page._xssDialogs = 0;
+ page.on('dialog', (d) => { page._xssDialogs++; d.dismiss().catch(() => {}); });
+ return { ctx, page };
+}
+
+// Real regions with SJC replaced by the hostile code (4 codes → pill mode).
+const hostileRegions = (real) => {
+ const out = {};
+ for (const k of Object.keys(real)) out[k === 'SJC' ? HOSTILE : k] = k === 'SJC' ? HOSTILE : real[k];
+ return out;
+};
+
+// Count live injections anywhere in the document.
+async function injections(page) {
+ const r = await page.evaluate(() => ({
+ fired: window.__xss333 || 0,
+ imgs: document.querySelectorAll('img[src="x"]').length,
+ }));
+ r.dialogs = page._xssDialogs;
+ return r;
+}
+function assertInert(r, label) {
+ assert(r.fired === 0 && r.imgs === 0 && r.dialogs === 0,
+ `${label}: ${r.imgs} injected , ${r.fired} handler call(s), ${r.dialogs} dialog(s)`);
+}
+
+(async () => {
+ const browser = await chromium.launch({
+ headless: true,
+ executablePath: process.env.CHROMIUM_PATH || undefined,
+ args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
+ });
+ console.log(`\n=== #333 round 2 full-page XSS E2E against ${BASE} ===`);
+
+ // ---- F1: packets flat view --------------------------------------------
+ await step('F1 packets flat view: hostile observer IATA renders as badge text', async () => {
+ const { ctx, page } = await hostileContext(browser, { regions: (r) => r });
+ try {
+ // 24 h window: the fixture is freshened to ~now before the run, and a
+ // 15-minute default would empty the list on a slow runner (cf. #180).
+ await page.goto(BASE + '/#/packets?timeWindow=1440', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#fGroup');
+ await page.waitForSelector('#pktBody tr[data-hash]');
+ await page.click('#fGroup'); // Group by Hash off → flat rows
+ await page.waitForFunction(() => !document.getElementById('fGroup').classList.contains('active'));
+ // Rows from SJC observers carry the hostile region; wait until the flat
+ // rows have rendered region cells, then judge the DOM.
+ await page.waitForFunction(() => document.querySelectorAll(
+ '#pktBody tr[data-action="select-hash"] td.col-region').length > 0);
+ await page.waitForTimeout(500);
+ assertInert(await injections(page), 'flat view');
+ const r = await page.evaluate((h) => {
+ const rows = Array.from(document.querySelectorAll('#pktBody tr[data-action="select-hash"]'));
+ const badges = Array.from(document.querySelectorAll('#pktBody .badge-region')).map((b) => b.textContent);
+ return { rows: rows.length, hostile: badges.filter((t) => t === h).length,
+ groupHeaders: document.querySelectorAll('#pktBody tr.group-header').length };
+ }, HOSTILE);
+ assert(r.groupHeaders === 0, 'expected the flat view, found group headers');
+ assert(r.hostile > 0, 'no flat row shows the hostile region as text');
+ } finally { await ctx.close(); }
+ });
+
+ // ---- F1: packets group child rows ---------------------------------------
+ await step('F1 packets expanded group: child-row region badges render as text', async () => {
+ const { ctx, page } = await hostileContext(browser, { regions: (r) => r });
+ try {
+ await page.goto(BASE + '/#/packets?hash=' + SEED_HASH + '&timeWindow=0', { waitUntil: 'domcontentloaded' });
+ const sel = `#pktBody tr[data-hash="${SEED_HASH}"][data-action="toggle-select"]`;
+ await page.waitForSelector(sel);
+ await page.click(sel + ' td.col-expand');
+ await page.waitForFunction(() => document.querySelectorAll('#pktBody tr.group-child').length === 3);
+ const badges = await page.$$eval('#pktBody tr.group-child .badge-region', (els) => els.map((e) => e.textContent));
+ assert(JSON.stringify(badges.slice().sort()) === JSON.stringify([HOSTILE, 'OAK', 'OAK'].sort()),
+ 'child badges must be OAK, SJC→hostile, OAK as text, got ' + JSON.stringify(badges));
+ assertInert(await injections(page), 'group children');
+ } finally { await ctx.close(); }
+ });
+
+ // ---- F2: region filter pills (Observers) + filtering still works --------
+ await step('F2 region filter pills: hostile code inert, selecting it filters the observers table', async () => {
+ const { ctx, page } = await hostileContext(browser, { regions: hostileRegions });
+ try {
+ await page.goto(BASE + '/#/observers', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#obsRegionFilter .region-pill');
+ await page.waitForSelector('#obsTable tbody tr');
+ const pills = await page.$$eval('#obsRegionFilter .region-pill',
+ (els) => els.map((e) => ({ region: e.dataset.region, text: e.textContent })));
+ const hostilePill = pills.find((p) => p.region === HOSTILE);
+ assert(hostilePill, 'no pill carries the hostile code in data-region: ' + JSON.stringify(pills));
+ assert(hostilePill.text === HOSTILE, 'hostile pill label is not the literal code: ' + hostilePill.text);
+ assertInert(await injections(page), 'region pills');
+
+ const total = await page.$$eval('#obsTable tbody tr[data-action="navigate"]', (els) => els.length);
+ await page.locator('#obsRegionFilter .region-pill').filter({ hasText: 'onerror' }).click();
+ await page.waitForFunction((n) => document.querySelectorAll('#obsTable tbody tr[data-action="navigate"]').length < n, total);
+ const r = await page.evaluate(() => ({
+ stored: localStorage.getItem('meshcore-region-filter'),
+ regions: Array.from(document.querySelectorAll('#obsTable tbody tr[data-action="navigate"] .badge-region'))
+ .map((b) => b.textContent),
+ active: Array.from(document.querySelectorAll('#obsRegionFilter .region-pill-active')).map((e) => e.dataset.region),
+ }));
+ assert(r.stored === JSON.stringify([HOSTILE]), 'persisted selection is not the exact code: ' + r.stored);
+ assert(r.regions.length > 0 && r.regions.every((t) => t === HOSTILE),
+ 'filtered table must only show the hostile region: ' + JSON.stringify(r.regions));
+ assert(JSON.stringify(r.active) === JSON.stringify([HOSTILE]), 'active pill: ' + JSON.stringify(r.active));
+ assertInert(await injections(page), 'region pills after selection');
+ } finally { await ctx.close(); }
+ });
+
+ // ---- F2: region filter dropdown (Observers, 5 regions) -----------------
+ await step('F2 region filter dropdown: items + trigger label inert, selection filters', async () => {
+ const { ctx, page } = await hostileContext(browser, {
+ regions: (real) => Object.assign(hostileRegions(real), { [BENIGN_REGION]: BENIGN_REGION }),
+ });
+ try {
+ await page.goto(BASE + '/#/observers', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#obsRegionFilter .region-dropdown-trigger');
+ await page.waitForSelector('#obsTable tbody tr');
+ await page.click('#obsRegionFilter .region-dropdown-trigger');
+ const items = await page.$$eval('#obsRegionFilter .region-dropdown-item',
+ (els) => els.map((e) => ({ region: e.querySelector('input').dataset.region, text: e.textContent.trim() })));
+ const h = items.find((i) => i.region === HOSTILE);
+ assert(h && h.text === HOSTILE + ' - ' + HOSTILE, 'hostile dropdown item: ' + JSON.stringify(items));
+ const b = items.find((i) => i.region === BENIGN_REGION);
+ assert(b && b.text === BENIGN_REGION + ' - ' + BENIGN_REGION, 'benign item mangled: ' + JSON.stringify(b));
+ assertInert(await injections(page), 'region dropdown');
+
+ await page.evaluate((code) => {
+ const input = Array.from(document.querySelectorAll('#obsRegionFilter input[data-region]'))
+ .find((i) => i.dataset.region === code);
+ input.click();
+ }, HOSTILE);
+ await page.waitForFunction(() => document.querySelector('#obsRegionFilter .region-dropdown-trigger')
+ .textContent !== 'All Regions ▾');
+ const label = await page.$eval('#obsRegionFilter .region-dropdown-trigger', (e) => e.textContent);
+ assert(label === HOSTILE + ' ▾', 'trigger label is not the literal code: ' + JSON.stringify(label));
+ const regions = await page.$$eval('#obsTable tbody tr[data-action="navigate"] .badge-region', (els) => els.map((e) => e.textContent));
+ assert(regions.length > 0 && regions.every((t) => t === HOSTILE),
+ 'filtered table must only show the hostile region: ' + JSON.stringify(regions));
+ assertInert(await injections(page), 'region dropdown trigger label');
+ } finally { await ctx.close(); }
+ });
+
+ // ---- F2: the filter on the other pages that render it ------------------
+ for (const [hash, sel] of [['#/packets', '#packetsRegionFilter'], ['#/nodes', '#nodesRegionFilter'], ['#/analytics', '#analyticsRegionFilter']]) {
+ await step(`F2 region filter on ${hash}: hostile code inert`, async () => {
+ const { ctx, page } = await hostileContext(browser, { regions: hostileRegions });
+ try {
+ await page.goto(BASE + '/' + hash, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction((s) => {
+ const el = document.querySelector(s);
+ return el && Array.from(el.querySelectorAll('[data-region]')).length >= 4;
+ }, sel);
+ const codes = await page.$$eval(sel + ' [data-region]', (els) => els.map((e) => e.dataset.region));
+ assert(codes.includes(HOSTILE), hash + ': hostile code lost from data-region: ' + JSON.stringify(codes));
+ assertInert(await injections(page), hash + ' region filter');
+ } finally { await ctx.close(); }
+ });
+ }
+
+ // ---- F3: observers row with a quote in the id --------------------------
+ await step('F3 observers row: id with a quote navigates without running code', async () => {
+ const { ctx, page } = await hostileContext(browser, {
+ regions: (r) => r,
+ extraObservers: [{ id: QUOTE_ID, name: 'xss333 quote', iata: 'SJC', last_seen: new Date().toISOString() }],
+ });
+ try {
+ await page.goto(BASE + '/#/observers', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#obsTable tbody tr');
+ const idx = await page.$$eval('#obsTable tbody tr[data-action="navigate"]',
+ (els, id) => els.findIndex((e) => e.dataset.observerId === id), QUOTE_ID);
+ assert(idx >= 0, 'synthetic observer row not rendered');
+ await page.locator('#obsTable tbody tr[data-action="navigate"]').nth(idx).locator('[data-testid="obs-cell-name"]').click();
+ await page.waitForFunction((want) => location.hash === want, '#/observers/' + encodeURIComponent(QUOTE_ID));
+ assertInert(await injections(page), 'observer row click');
+ } finally { await ctx.close(); }
+ });
+
+ await step('F3 observers row: plain id still navigates to the detail page', async () => {
+ const { ctx, page } = await hostileContext(browser, { regions: (r) => r });
+ try {
+ await page.goto(BASE + '/#/observers', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#obsTable tbody tr[data-action="navigate"]');
+ const id = await page.$eval('#obsTable tbody tr[data-action="navigate"]', (e) => e.dataset.observerId);
+ await page.locator('#obsTable tbody tr[data-action="navigate"]').first().locator('[data-testid="obs-cell-name"]').click();
+ await page.waitForFunction((want) => location.hash === want, '#/observers/' + encodeURIComponent(id));
+ } finally { await ctx.close(); }
+ });
+
+ // The inline onclick also navigated on narrow viewports, where the
+ // delegated handler is meant to open the slide-over INSTEAD (#1056 AC#4).
+ await step('F3 observers row on a narrow viewport: opens the slide-over, list stays', async () => {
+ const ctx = await browser.newContext({ viewport: { width: 500, height: 900 } });
+ try {
+ const page = await ctx.newPage();
+ await page.goto(BASE + '/#/observers', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#obsTable tbody tr[data-action="navigate"]');
+ await page.locator('#obsTable tbody tr[data-action="navigate"]').first().locator('[data-testid="obs-cell-name"]').click();
+ await page.waitForFunction(() => window.SlideOver && window.SlideOver.isOpen());
+ await page.waitForTimeout(500); // a stray navigation would land by now
+ const hash = await page.evaluate(() => location.hash);
+ assert(hash === '#/observers', 'narrow row click navigated away: ' + hash);
+ } finally { await ctx.close(); }
+ });
+
+ // ---- N1 (round 3): nodes-page detail panel "Regions:" summary ----------
+ // The detail panel (renderDetail → #nodesRight) joins the observer IATAs
+ // from /api/nodes/{pk}/health into a "Regions:" line, one line above the
+ // already-escaped per-observer badge. Select a node, inject the hostile
+ // IATA into its health response, and judge the DOM.
+ await step('N1 nodes detail panel: Regions summary renders the hostile IATA as text', async () => {
+ const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
+ let dialogs = 0;
+ try {
+ await ctx.route((url) => /\/api\/nodes\/[^/]+\/health$/.test(url.pathname), async (route) => {
+ const res = await route.fetch();
+ const body = await res.json();
+ // Guarantee at least one observer so the Regions section renders,
+ // whichever node the first row happens to be.
+ body.observers = (body.observers && body.observers.length)
+ ? body.observers
+ : [{ observer_id: 'OBS-H', observer_name: 'h', packetCount: 1 }];
+ for (const o of body.observers) o.iata = HOSTILE;
+ await route.fulfill({ response: res, json: body });
+ });
+ const page = await ctx.newPage();
+ page.setDefaultTimeout(15000);
+ page.on('dialog', (d) => { dialogs++; d.dismiss().catch(() => {}); });
+ await page.goto(BASE + '/#/nodes', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#nodesBody tr[data-action="select"]');
+ await page.click('#nodesBody tr[data-action="select"]');
+ await page.waitForFunction(() => {
+ const r = document.getElementById('nodesRight');
+ return Array.from(r ? r.querySelectorAll('div') : [])
+ .some((d) => d.innerHTML.trim().startsWith('Regions:'));
+ });
+ await page.waitForTimeout(400);
+ const r = await page.evaluate((h) => {
+ const right = document.getElementById('nodesRight');
+ // The exact summary div, not an ancestor (whose innerHTML could carry
+ // an unrelated such as a node avatar).
+ const line = Array.from(right.querySelectorAll('div'))
+ .find((d) => d.innerHTML.trim().startsWith('Regions:'));
+ return {
+ fired: window.__xss333 || 0,
+ imgs: document.querySelectorAll('img[src="x"]').length,
+ showsText: !!line && line.textContent.includes(h),
+ hasRawImg: !!line && / survived in the Regions summary');
+ } finally { await ctx.close(); }
+ });
+
+ await browser.close();
+ console.log('\n' + passed + ' passed, ' + failed + ' failed');
+ process.exit(failed ? 1 : 0);
+})().catch((e) => { console.error(e); process.exit(1); });
diff --git a/test-xss-escape-sinks.js b/test-xss-escape-sinks.js
index 8cdf8939b..836781b8a 100644
--- a/test-xss-escape-sinks.js
+++ b/test-xss-escape-sinks.js
@@ -64,6 +64,55 @@ function loadEscapeHtml() {
}
const escapeHtml = loadEscapeHtml();
+// #333 F5: sinks that call a file-local escaper must be tested with THAT
+// escaper, not with app.js's escapeHtml, or a weakened local escaper goes
+// unnoticed. Lift the one-line `function esc(s) { … }` from the file.
+function loadLocalEsc(file, ctxExtra) {
+ const src = fs.readFileSync(file, 'utf8');
+ // #333 N4: the regex below lifts the *first* one-line `function esc(s)`.
+ // If a second file-local esc were added above the real sink's escaper, it
+ // would silently retarget this contract test. Pin the assumption: there
+ // must be exactly one esc() definition in the file, else fail loudly.
+ const defs = src.match(/function esc\s*\(/g) || [];
+ if (defs.length !== 1)
+ throw new Error(`expected exactly one function esc() in ${file}, found ${defs.length}; `
+ + 'loadLocalEsc would silently pick the first — add a line anchor to disambiguate');
+ const m = src.match(/\n\s*(function esc\(s\) \{[^\n]*\})/);
+ if (!m) throw new Error('could not locate function esc(s) in ' + file);
+ const ctx = Object.assign({ esc: null }, ctxExtra || {});
+ vm.createContext(ctx);
+ vm.runInContext(m[1] + '\nthis.esc = esc;', ctx);
+ return ctx.esc;
+}
+const analyticsEsc = loadLocalEsc('public/analytics.js');
+// customize-v2.js's esc() serializes a text node through a
+// (textContent → innerHTML). Node has no DOM, so model the serializer as the
+// HTML spec defines it for text nodes: & < > and U+00A0 are escaped, quotes
+// are not. The function under test is still the production esc().
+const customizeEsc = loadLocalEsc('public/customize-v2.js', {
+ document: {
+ createElement: () => {
+ let text = '';
+ return {
+ set textContent(v) { text = String(v); },
+ get innerHTML() {
+ return text.replace(/&/g, '&').replace(//g, '>').replace(/\u00a0/g, ' ');
+ },
+ };
+ },
+ },
+});
+
+// Text-context check for escapers that legitimately leave quotes alone
+// (customize-v2.js esc): no live tag, and the tag is shown escaped.
+function assertNoTagInText(html, label) {
+ if (/ {
+ const src = fs.readFileSync('public/app.js', 'utf8');
+ const m = src.match(/'\s*\+\s*([\s\S]*?)\s*\+\s*'<\/span>'/);
+ assert.ok(m, '#333 favorites: fav-dd-name concat segment not found');
+ const fn = new Function('h', 'pk', 'escapeHtml', 'truncate',
+ 'return `` + (' + m[1] + ') + ``;');
+ const html = fn(
+ { node: { name: TAG_PAYLOAD + ATTR_PAYLOAD } }, 'abcdef0123456789',
+ escapeHtml, (s, n) => (s == null ? '' : String(s).slice(0, n)));
+ assertNoXss(html, '#333 favorites dropdown name');
+});
+
+// 2. Geo-filter prune preview (customize-v2.js) — node name in list item.
+test('#333 geo-filter prune preview escapes node name', () => {
+ const src = fs.readFileSync('public/customize-v2.js', 'utf8');
+ const m = src.match(/return '
'\s*\+\s*([\s\S]*?)\s*\+\s*coords\s*\+\s*'<\/div>';/);
+ assert.ok(m, '#333 geo-prune: prune list item concat not found');
+ // #333 F5: run customize-v2.js's own esc() (text context, quotes are
+ // legitimately left alone there), not app.js's escapeHtml.
+ const fn = new Function('n', 'esc', 'coords',
+ 'return `
` + (' + m[1] + ') + coords + `
`;');
+ const html = fn({ name: TAG_PAYLOAD + ATTR_PAYLOAD, pubkey: 'abcdef012345' }, customizeEsc, '');
+ assertNoTagInText(html, '#333 geo-filter prune preview name');
+});
+
+// 3a. Observer regions — observers table badge (template-literal form).
+test('#333 observers table region badge escapes o.iata', () => {
+ const src = fs.readFileSync('public/observers.js', 'utf8');
+ const m = src.match(/\$\{[^}]*o\.iata[^}]*\}<\/span>/);
+ assert.ok(m, '#333 observers table badge-region sink not found');
+ const fn = new Function('o', 'escapeHtml', 'return `' + m[0] + '`;');
+ const html = fn({ iata: TAG_PAYLOAD + ATTR_PAYLOAD }, escapeHtml);
+ assertNoXss(html, '#333 observers table region badge');
+});
+
+// 3b. Observer regions — slide-over region badge (concat form).
+test('#333 observers slide-over region badge escapes o.iata', () => {
+ const src = fs.readFileSync('public/observers.js', 'utf8');
+ const m = src.match(/''\s*\+\s*([\s\S]*?)\s*\+\s*'<\/span>'/);
+ assert.ok(m, '#333 observers slide-over badge-region concat not found');
+ const fn = new Function('o', 'escapeHtml', 'return `` + (' + m[1] + ') + ``;');
+ const html = fn({ iata: TAG_PAYLOAD + ATTR_PAYLOAD }, escapeHtml);
+ assertNoXss(html, '#333 observers slide-over region badge');
+});
+
+// 4. Packet region badge (packets.js) — groupRegion in badge-region span.
+test('#333 packets region badge escapes groupRegion', () => {
+ const src = fs.readFileSync('public/packets.js', 'utf8');
+ const m = src.match(/\$\{[^}]*groupRegion[^}]*\}<\/span>/);
+ assert.ok(m, '#333 packets badge-region sink not found');
+ const fn = new Function('groupRegion', 'escapeHtml', 'return `' + m[0] + '`;');
+ const html = fn(TAG_PAYLOAD + ATTR_PAYLOAD, escapeHtml);
+ assertNoXss(html, '#333 packets region badge');
+});
+
+// 5. Clock-skew observer/node names — clock-health fleet table (regression
+// pin: already escaped in our fork; the mutant is removing esc()).
+test('#333 clock-health table escapes node name (regression pin)', () => {
+ const src = fs.readFileSync('public/analytics.js', 'utf8');
+ // Escaped or not (`esc(n.nodeName …)` / `(n.nodeName …)`), so a mutant fails
+ // on behaviour, not on "cell not found" (#333 F6).
+ const m = src.match(/
'\s*\+\s*([^\n]*?n\.nodeName[^\n]*?)\s*\+\s*'<\/strong><\/td>/);
+ assert.ok(m, '#333 clock-health name cell not found');
+ const fn = new Function('n', 'esc',
+ 'return `
sink not found');
+ const fn = new Function('route', 'escapeHtml', 'return `' + m[0] + '`;');
+ const html = fn('#/' + TAG_PAYLOAD + ATTR_PAYLOAD, escapeHtml);
+ assertNoXss(html, '#333 unknown-route heading');
+});
+
+// 7b. Unknown-route template must not hardcode a hex colour (CSS var).
+test('#333 unknown-route template uses a CSS var, not a hardcoded hex colour', () => {
+ const src = fs.readFileSync('public/app.js', 'utf8');
+ const m = src.match(/`
' + ATTR_PAYLOAD;
+const BENIGN_REGION = 'KØB & Æ';
+
+function decodeEntities(s) {
+ return s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
+ .replace(/'/g, "'").replace(/&/g, '&');
+}
+
+// Real public/packets.js in a sandbox (same shape as
+// test-issue-165-hop-resolution-per-observer.js). `observers` is what
+// /api/observers returns; resolveHops() seeds the page's observerMap from it.
+async function loadPacketsWithObservers(observers) {
+ const store = {};
+ const ctx = {
+ window: { addEventListener() {}, dispatchEvent() {}, innerWidth: 1280 },
+ document: {
+ readyState: 'complete',
+ createElement: () => ({ style: {}, dataset: {}, classList: { add() {}, remove() {} }, appendChild() {}, setAttribute() {} }),
+ head: { appendChild() {} }, body: { appendChild() {}, removeChild() {} },
+ getElementById: () => null, querySelector: () => null, querySelectorAll: () => [],
+ addEventListener() {}, removeEventListener() {},
+ },
+ localStorage: { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } },
+ console, Math, Object, Array, Number, Date, Map, Set, JSON, String, Boolean, RegExp, Error, TypeError, Promise,
+ parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent, URLSearchParams,
+ setTimeout, clearTimeout, setInterval: () => 0, clearInterval() {},
+ requestAnimationFrame: () => 0, cancelAnimationFrame() {},
+ performance: { now: () => Date.now() },
+ location: { hash: '' },
+ fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }),
+ registerPage() {}, onWS() {}, offWS() {}, debouncedOnWS: fn => fn,
+ CLIENT_TTL: {},
+ fetchAllNodes: () => Promise.resolve({ nodes: [] }),
+ api: (p) => Promise.resolve(p.startsWith('/observers') ? { observers } : p.startsWith('/iata-coords') ? { coords: {} } : {}),
+ invalidateApiCache() {},
+ };
+ ctx.window.localStorage = ctx.localStorage;
+ vm.createContext(ctx);
+ const load = (f) => {
+ vm.runInContext(fs.readFileSync('public/' + f, 'utf8'), ctx, { filename: f });
+ for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
+ };
+ // roles.js + app.js supply the page globals the row builders call
+ // (payloadTypeName, …); app.js's own escapeHtml becomes the global.
+ for (const f of ['payload-labels.js', 'roles.js', 'app.js']) load(f);
+ ctx.api = (p) => Promise.resolve(p.startsWith('/observers') ? { observers } : p.startsWith('/iata-coords') ? { coords: {} } : {});
+ ctx.fetchAllNodes = () => Promise.resolve({ nodes: [] });
+ for (const f of ['packet-helpers.js', 'hop-resolver.js', 'hop-display.js', 'packets.js']) load(f);
+ const T = ctx.window._packetsTestAPI;
+ await T.resolveHops(['ef'], observers[0].id); // seeds observerMap via ensureHopResolver
+ return T;
+}
+
+// Every .badge-region in a rendered row, decoded the way the browser would.
+function regionBadges(html) {
+ return Array.from(html.matchAll(/([^<]*)<\/span>/g), m => decodeEntities(m[1]));
+}
+
+const RAW_HEX = '15000102030405060708090a0b0c0d0e0f';
+function pkt(extra) {
+ return Object.assign({ hash: 'fae0c9e6d357a814', raw_hex: RAW_HEX, payload_type: 5, route_type: 1,
+ decoded_json: '{}', path_json: '[]', timestamp: '', latest: '' }, extra);
+}
+
+asyncTest('#333 F1 packets flat view: region badge escapes the observer IATA', async () => {
+ const T = await loadPacketsWithObservers([{ id: 'OBS-H', name: 'h', iata: REGION_PAYLOAD }]);
+ const html = T.buildFlatRowHtml(pkt({ id: 7, observer_id: 'OBS-H' }));
+ assertNoXss(html, '#333 F1 flat view region');
+ assert.deepStrictEqual(regionBadges(html), [REGION_PAYLOAD], 'flat view badge must show the code as text');
+});
+
+asyncTest('#333 F1 packets group child rows: region badge escapes the observer IATA', async () => {
+ const T = await loadPacketsWithObservers([
+ { id: 'OBS-B', name: 'b', iata: 'SJC' },
+ { id: 'OBS-H', name: 'h', iata: REGION_PAYLOAD },
+ ]);
+ const p = pkt({ observer_id: 'OBS-B', count: 2, observation_count: 2, observer_count: 2,
+ _children: [pkt({ id: 1, observer_id: 'OBS-B' }), pkt({ id: 2, observer_id: 'OBS-H' })] });
+ T._setExpanded(p.hash, true);
+ const html = T.buildGroupRowHtml(p);
+ assert.ok(html.includes('class="group-child"'), 'expanded group must render child rows');
+ assertNoXss(html, '#333 F1 group child region');
+ assert.deepStrictEqual(regionBadges(html), ['SJC', 'SJC', REGION_PAYLOAD],
+ 'header + both child badges must show their codes as text');
+});
+
+asyncTest('#333 F1 packets group header: region badge escapes the observer IATA', async () => {
+ const T = await loadPacketsWithObservers([{ id: 'OBS-H', name: 'h', iata: REGION_PAYLOAD }]);
+ const html = T.buildGroupRowHtml(pkt({ observer_id: 'OBS-H', count: 1, observation_count: 1 }));
+ assertNoXss(html, '#333 F1 group header region');
+ assert.deepStrictEqual(regionBadges(html), [REGION_PAYLOAD]);
+});
+
+asyncTest('#333 F1 packets region badges show a benign code verbatim (no double escape)', async () => {
+ const T = await loadPacketsWithObservers([{ id: 'OBS-K', name: 'k', iata: BENIGN_REGION }]);
+ const flat = T.buildFlatRowHtml(pkt({ id: 3, observer_id: 'OBS-K' }));
+ assert.ok(flat.includes('KØB & Æ'), flat);
+ assert.deepStrictEqual(regionBadges(flat), [BENIGN_REGION]);
+});
+
+// Real public/region-filter.js in a sandbox. `regions` is what
+// /api/config/regions returns; `stored` pre-seeds the persisted selection.
+async function loadRegionFilter(regions, opts) {
+ const store = {};
+ if (opts && opts.stored) store['meshcore-region-filter'] = JSON.stringify(opts.stored);
+ const el = () => ({ hidden: true, onclick: null, setAttribute() {} });
+ const container = { innerHTML: '', style: {}, onclick: null, querySelector: el };
+ const ctx = {
+ window: {},
+ document: { addEventListener() {}, removeEventListener() {} },
+ localStorage: { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } },
+ fetch: async () => ({ json: async () => regions }),
+ console, setTimeout, clearTimeout,
+ escapeHtml, // app.js's real global escaper
+ };
+ ctx.window = ctx;
+ vm.createContext(ctx);
+ vm.runInContext(fs.readFileSync('public/region-filter.js', 'utf8'), ctx, { filename: 'region-filter.js' });
+ await ctx.RegionFilter.init(container, opts && opts.dropdown ? { dropdown: true } : undefined);
+ return { RF: ctx.RegionFilter, container, store };
+}
+
+// The value the browser would hand to toggleRegion via el.dataset.region.
+function dataRegions(html) {
+ return Array.from(html.matchAll(/data-region="([^"]*)"/g), m => decodeEntities(m[1]));
+}
+
+asyncTest('#333 F2 region filter pills: hostile code escaped in data-region and label', async () => {
+ const { container } = await loadRegionFilter({ SJC: 'San Jose', [REGION_PAYLOAD]: REGION_PAYLOAD });
+ const html = container.innerHTML;
+ assert.ok(html.includes('region-pill'), 'expected pill mode for 2 regions: ' + html);
+ assertNoXss(html, '#333 F2 region pills');
+ assert.deepStrictEqual(dataRegions(html), ['__all__', 'SJC', REGION_PAYLOAD],
+ 'each pill data-region must round-trip to its code');
+ assert.ok(decodeEntities(html).includes('>' + REGION_PAYLOAD + ''), 'pill label must show the code as text');
+});
+
+asyncTest('#333 F2 region filter dropdown: hostile code + label escaped, trigger label escaped', async () => {
+ const { container } = await loadRegionFilter(
+ { SJC: 'San Jose', [REGION_PAYLOAD]: REGION_PAYLOAD + ' city' },
+ { dropdown: true, stored: [REGION_PAYLOAD] });
+ const html = container.innerHTML;
+ assert.ok(html.includes('region-dropdown-trigger'), 'expected dropdown mode: ' + html);
+ assertNoXss(html, '#333 F2 region dropdown');
+ assert.deepStrictEqual(dataRegions(html), ['__all__', 'SJC', REGION_PAYLOAD]);
+ const trigger = html.match(/'), container.innerHTML);
+ assert.deepStrictEqual(dataRegions(container.innerHTML), ['__all__', 'SJC', BENIGN_REGION]);
+});
+
+// F3: observers row — an observer id containing ' must not reach JS code.
+test('#333 F3 observers row: no inline handler interpolates the observer id', () => {
+ const src = fs.readFileSync('public/observers.js', 'utf8');
+ const m = src.match(/return `(
]*data-action="navigate"[^>]*>)/);
+ assert.ok(m, '#333 F3 observers navigate row not found');
+ const fn = new Function('o', 'escapeHtml', 'return `' + m[1] + '`;');
+ const id = "Q'-console.error(1)-'";
+ const tag = fn({ id }, escapeHtml);
+ const handlers = Array.from(tag.matchAll(/\son[a-z]+="([^"]*)"/gi), x => x[1]);
+ for (const h of handlers) {
+ assert.ok(!h.includes(id) && !h.includes(encodeURIComponent(id)),
+ '#333 F3: observer id reaches an inline JS handler: ' + tag);
+ }
+ // Navigation target stays on the row for the delegated handler.
+ const dv = tag.match(/data-value="([^"]*)"/);
+ assert.ok(dv && decodeEntities(dv[1]) === '#/observers/' + encodeURIComponent(id),
+ '#333 F3: data-value navigation target lost: ' + tag);
+});
+
+// =========================================================================
+// K. Issue #333 round 3 — the two remaining adjacent region sinks the round-2
+// review flagged (N1, N2) plus the analytics repeater-row inline handler
+// (N3). N1 and N2 both render the SAME value — observer IATA from
+// /api/nodes/{pk}/health, via observers.map(o => o.iata) — raw into a
+// summary line one or two lines above an already-escaped sibling badge.
+// Each test lifts the REAL production expression by regex and renders it
+// with a hostile IATA, so dropping the escaper (the mutant) turns it red.
+// =========================================================================
+
+// N1: public/nodes.js detail panel (renderDetail → panel.innerHTML). The
+// "Regions:" summary line joins observers.map(o => o.iata) into text. The
+// sibling full-page view at nodes.js:970 and the "Heard By" rows below both
+// escape the IATA; only this summary line is at issue.
+test('#333 N1 nodes detail panel: Regions summary escapes the observer IATA', () => {
+ const src = fs.readFileSync('public/nodes.js', 'utf8');
+ const m = src.match(/\(\(\) => \{ const regions = \[\.\.\.new Set\(observers\.map\(o => o\.iata\)\.filter\(Boolean\)\)\]; return regions\.length \? `
Regions:<\/strong> [^`]*<\/div>` : ''; \}\)\(\)/);
+ assert.ok(m, '#333 N1 nodes detail Regions summary IIFE not found');
+ const render = new Function('observers', 'escapeHtml', 'return ' + m[0] + ';');
+ const html = render([{ iata: 'SJC' }, { iata: REGION_PAYLOAD }], escapeHtml);
+ assertNoXss(html, '#333 N1 nodes detail Regions summary');
+ assert.ok(decodeEntities(html).includes('SJC, ' + REGION_PAYLOAD),
+ '#333 N1: region codes must render as comma-joined text: ' + html);
+});
+
+// N2: public/live.js showNodeDetail (content.innerHTML). The "Heard By"
+// heading appends ' — Regions: ' + the same observers.map(o => o.iata) join.
+// The per-observer rows one line below already escape o.iata.
+test('#333 N2 live node detail: Heard By heading escapes the observer IATA', () => {
+ const src = fs.readFileSync('public/live.js', 'utf8');
+ // The heading template continues past into the observer list, so lift
+ // just the real join expression that builds the " — Regions: …" text.
+ const m = src.match(/Heard By\$\{regions\.length \? ' — Regions: ' \+ ([^}]*?) : ''\}<\/h4>/);
+ assert.ok(m, '#333 N2 live Heard By heading not found');
+ const render = new Function('regions', 'escapeHtml', "return 'Heard By — Regions: ' + (" + m[1] + ');');
+ const regions = [...new Set([{ iata: 'SJC' }, { iata: REGION_PAYLOAD }].map(o => o.iata).filter(Boolean))];
+ const html = render(regions, escapeHtml);
+ assertNoXss(html, '#333 N2 live Heard By heading');
+ assert.ok(decodeEntities(html).includes('Regions: SJC, ' + REGION_PAYLOAD),
+ '#333 N2: region codes must render as text in the heading: ' + html);
+});
+
+// N3: public/analytics.js renderRepeaterTable. The row carried
+// onclick="location.hash='#/nodes/${encodeURIComponent(r.pubkey)}'".
+// encodeURIComponent leaves ' ( ) alone, so a pubkey quote breaks out of the
+// JS string — the exact pattern F3 removed from observers.js. The fix drops
+// the inline handler and navigates via data-action="navigate"/data-value.
+test('#333 N3 analytics repeater row: no inline handler carries the node pubkey', () => {
+ const src = fs.readFileSync('public/analytics.js', 'utf8');
+ const m = src.match(/html \+= `
String(s), 50, encodeURIComponent);
+ const handlers = Array.from(html.matchAll(/\son[a-z]+\s*=\s*"([^"]*)"/gi), x => x[1]);
+ for (const h of handlers) {
+ assert.ok(!h.includes(pubkey) && !h.includes(encodeURIComponent(pubkey)),
+ '#333 N3: node pubkey reaches an inline JS handler: ' + html);
+ }
+ const dv = html.match(/data-value="([^"]*)"/);
+ assert.ok(dv && decodeEntities(dv[1]) === '#/nodes/' + encodeURIComponent(pubkey),
+ '#333 N3: data-value navigation target lost: ' + html);
+});
+
// =========================================================================
// SUMMARY
// =========================================================================
-console.log('\n' + '═'.repeat(48));
-console.log(` XSS escape sinks: ${passed} passed, ${failed} failed`);
-console.log('═'.repeat(48));
-if (failed > 0) process.exit(1);
+(async () => {
+ for (const t of asyncTests) {
+ try { await t.fn(); passed++; console.log(` ✅ ${t.name}`); }
+ catch (e) { failed++; console.log(` ❌ ${t.name}: ${e.message}`); }
+ }
+ console.log('\n' + '═'.repeat(48));
+ console.log(` XSS escape sinks: ${passed} passed, ${failed} failed`);
+ console.log('═'.repeat(48));
+ if (failed > 0) process.exit(1);
+})();
diff --git a/testdata/preflight-xss/bad-13-attr-interp.js b/testdata/preflight-xss/bad-13-attr-interp.js
new file mode 100644
index 000000000..50246e4b0
--- /dev/null
+++ b/testdata/preflight-xss/bad-13-attr-interp.js
@@ -0,0 +1,9 @@
+// bad-13-attr-interp.js — XSS fixture for check-xss-sinks (#333 / #351 N4).
+// Unescaped node-controlled ${observer_name} interpolated into a *quoted
+// attribute* inside a template literal on a sink line. The quote-stripper
+// used to erase the interpolation before the audit, so this passed the gate.
+// EXPECTED: flagged by check-xss-sinks.
+/* eslint-disable */
+function render(el, o) {
+ el.innerHTML = `x`;
+}
diff --git a/testdata/preflight-xss/bad-14-attr-interp-singlequote.js b/testdata/preflight-xss/bad-14-attr-interp-singlequote.js
new file mode 100644
index 000000000..49e2cc73d
--- /dev/null
+++ b/testdata/preflight-xss/bad-14-attr-interp-singlequote.js
@@ -0,0 +1,7 @@
+// bad-14-attr-interp-singlequote.js — single-quoted-attribute variant of the
+// #351 N4 gap. Same class as bad-13 but with a single-quoted attribute.
+// EXPECTED: flagged by check-xss-sinks.
+/* eslint-disable */
+function render(el, o) {
+ el.innerHTML = `x`;
+}
diff --git a/testdata/preflight-xss/bad-15-attr-concat.js b/testdata/preflight-xss/bad-15-attr-concat.js
new file mode 100644
index 000000000..5ba37ea38
--- /dev/null
+++ b/testdata/preflight-xss/bad-15-attr-concat.js
@@ -0,0 +1,10 @@
+// bad-15-attr-concat.js — XSS fixture for check-xss-sinks (#333 F4).
+// The concatenation form of N4: a node-controlled field concatenated into a
+// double-quoted attribute built from single-quoted string pieces. The old
+// quote-stripper ran its "…" pass first, so `"' + o.name + '"` looked like
+// one double-quoted string and the field was erased before the audit.
+// EXPECTED: flagged by check-xss-sinks.
+/* eslint-disable */
+function render(el, o) {
+ el.innerHTML = 'x';
+}
diff --git a/testdata/preflight-xss/bad-16-attr-concat-doublequote-pieces.js b/testdata/preflight-xss/bad-16-attr-concat-doublequote-pieces.js
new file mode 100644
index 000000000..bc9eb69b2
--- /dev/null
+++ b/testdata/preflight-xss/bad-16-attr-concat-doublequote-pieces.js
@@ -0,0 +1,8 @@
+// bad-16-attr-concat-doublequote-pieces.js — XSS fixture (#333 F4).
+// Mirror of bad-15: double-quoted string pieces around a single-quoted
+// attribute. The '…' pass used to swallow `' + o.observer_name + "'`.
+// EXPECTED: flagged by check-xss-sinks.
+/* eslint-disable */
+function render(el, o) {
+ el.innerHTML = "x";
+}
diff --git a/testdata/preflight-xss/bad-17-url-in-string.js b/testdata/preflight-xss/bad-17-url-in-string.js
new file mode 100644
index 000000000..6281ee98a
--- /dev/null
+++ b/testdata/preflight-xss/bad-17-url-in-string.js
@@ -0,0 +1,9 @@
+// bad-17-url-in-string.js — XSS fixture (#333 F4).
+// A `//` inside a string literal (a URL) is not a comment. The old stripper
+// removed `//…` before it parsed strings, cutting the line at `https:` and
+// dropping the unescaped node field that followed.
+// EXPECTED: flagged by check-xss-sinks.
+/* eslint-disable */
+function render(el, o) {
+ el.innerHTML = '' + o.name + '';
+}
diff --git a/testdata/preflight-xss/good-5-attr-interp-escaped.js b/testdata/preflight-xss/good-5-attr-interp-escaped.js
new file mode 100644
index 000000000..c22d8d0e6
--- /dev/null
+++ b/testdata/preflight-xss/good-5-attr-interp-escaped.js
@@ -0,0 +1,11 @@
+// good-5-attr-interp-escaped.js — the escaped counterpart of bad-13. The
+// node-controlled field inside the quoted attribute is wrapped in escapeHtml,
+// so the N4 fix must NOT false-positive here.
+/* eslint-disable */
+function escapeHtml(s) {
+ if (s == null) return '';
+ return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
+}
+function render(el, o) {
+ el.innerHTML = `x`;
+}
diff --git a/testdata/preflight-xss/good-6-attr-concat-escaped.js b/testdata/preflight-xss/good-6-attr-concat-escaped.js
new file mode 100644
index 000000000..487a8fbe5
--- /dev/null
+++ b/testdata/preflight-xss/good-6-attr-concat-escaped.js
@@ -0,0 +1,12 @@
+// good-6-attr-concat-escaped.js — the escaped counterpart of bad-15/bad-16.
+// The left-to-right string parse must NOT false-positive on these.
+/* eslint-disable */
+function escapeHtml(s) {
+ if (s == null) return '';
+ return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
+}
+function render(el, o) {
+ el.innerHTML = 'x';
+ el.innerHTML = "x";
+ el.innerHTML = '' + escapeHtml(o.name) + '';
+}
diff --git a/testdata/preflight-xss/good-7-literal-dollar-brace.js b/testdata/preflight-xss/good-7-literal-dollar-brace.js
new file mode 100644
index 000000000..3d8d1009e
--- /dev/null
+++ b/testdata/preflight-xss/good-7-literal-dollar-brace.js
@@ -0,0 +1,7 @@
+// good-7-literal-dollar-brace.js — a plain quoted string whose TEXT contains
+// "${name}" is not an interpolation. The round-1 N4 workaround preserved such
+// literals and could flag them; the left-to-right parse does not.
+/* eslint-disable */
+function render(el) {
+ el.innerHTML = '${name} is a template placeholder';
+}