From b2f22999a27a1f71edded1df49276d4bee1c6ea6 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 13 Oct 2026 11:40:38 +0000 Subject: [PATCH 1/2] test(ui): reproduce unchecked URL schemes in Home footer and branding sinks (#429) Unit test drives the real roles.js helpers in a vm sandbox and pins, at source level, that home.js footer links and the customize-v2.js branding logo/favicon setters use the shared check. The browser test injects hostile customization via /api/config/theme and cs-theme-overrides against a local server and asserts on the protocols and attributes the browser computed. Both are red on this commit: no window.isSafeLinkUrl/safeUrlAttr exist yet, footer links and the logo/favicon accept javascript:/data:/vbscript:, an entity-encoded javascript: footer URL resolves to javascript:, and a legacy .brand-icon receives an unescaped that runs a handler. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 1 + test-all.sh | 1 + test-issue-429-config-url-schemes.js | 274 ++++++++++++++++++ test-xss-429-home-footer-e2e.js | 411 +++++++++++++++++++++++++++ 4 files changed, 687 insertions(+) create mode 100644 test-issue-429-config-url-schemes.js create mode 100644 test-xss-429-home-footer-e2e.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7b41c36d6..9b1e5b5f9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -431,6 +431,7 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-xss-333-pages-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-xss-392-perf-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-xss-368-home-links-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-xss-429-home-footer-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1639-observers-sort-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-405-observers-listener-leak-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1758-ng-filter-rerenders-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/test-all.sh b/test-all.sh index ee9efaf9e..fea6fde36 100755 --- a/test-all.sh +++ b/test-all.sh @@ -200,6 +200,7 @@ run test-issue-1107-live-layout.js run test-issue-1619-feed-detail-card-draggable.js run test-xss-escape-sinks.js run test-issue-368-minimarkdown-links.js +run test-issue-429-config-url-schemes.js run test-issue-393-slowpath-doc-bound.js run test-preflight-xss-gate.js run test-issue-1648-m4-emoji-scan.js diff --git a/test-issue-429-config-url-schemes.js b/test-issue-429-config-url-schemes.js new file mode 100644 index 000000000..e6ad2c0a2 --- /dev/null +++ b/test-issue-429-config-url-schemes.js @@ -0,0 +1,274 @@ +/** + * test-issue-429-config-url-schemes.js + * + * Issue #429 — #420 (#368) gave miniMarkdown() link destinations a scheme + * allowlist, but the same Home customization source still had an href sink + * with no scheme check: footerLinks[].url in public/home.js, rendered as + * href="${escapeAttr(l.url)}". escapeAttr only covers the two quote + * characters, so `javascript:` went straight through, and so did an + * entity-encoded scheme (`javascript:`) that the browser decodes back + * into `javascript:` when it parses the attribute. + * + * The other URL sinks fed by importable customization are the branding + * logo / favicon setters in public/customize-v2.js. They now go through the + * same check. + * + * These tests drive the REAL helpers from public/roles.js in a vm sandbox + * and pin, at source level, that the sinks use them instead of growing a + * second allowlist. The browser-level counterpart (what the Home page and + * the navbar actually render and execute) is test-xss-429-home-footer-e2e.js. + * + * Mutants this file must catch (verified by hand, see the PR): + * N1 safeUrlAttr() stops escaping '&' -> B2 red + * N2 isSafeLinkUrl() drops the control/whitespace strip -> B1 red + * N3 isSafeLinkUrl() accepts every scheme -> B1, B3 red + * N4 home.js footer goes back to escapeAttr(l.url) -> B4 red + * N5 a .brand-icon innerHTML logo sink comes back -> B5 red + * N9 isSafeImageUrl() accepts any data: URL -> B6 red + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +// --- Load public/roles.js the way index.html does ----------------------- +function loadRoles() { + const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {} }, + document: { + readyState: 'complete', + createElement: () => ({ id: '', textContent: '', innerHTML: '', style: {} }), + head: { appendChild: () => {} }, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + }, + console, Date, Math, JSON, RegExp, Error, TypeError, + parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout: () => {}, clearTimeout: () => {}, + localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, + location: { hash: '' }, + navigator: {}, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + }; + vm.createContext(ctx); + vm.runInContext(fs.readFileSync('public/roles.js', 'utf8'), ctx); + return ctx.window; +} +const W = loadRoles(); +const isSafe = W.isSafeLinkUrl; +const safeAttr = W.safeUrlAttr; + +// Hostile URLs, raw (as they would sit in a customization JSON). Every one +// carries a scheme that is not http/https once case and the ASCII +// controls/whitespace a browser ignores are taken out. +const HOSTILE = [ + ['plain javascript:', 'javascript:alert(1)'], + ['mixed case', 'JaVaScRiPt:alert(1)'], + ['upper case', 'JAVASCRIPT:alert(1)'], + ['leading spaces', ' javascript:alert(1)'], + ['leading TAB/LF', '\t\njavascript:alert(1)'], + ['leading C0 control', '\u0001javascript:alert(1)'], + ['embedded TAB', 'java\tscript:alert(1)'], + ['embedded LF', 'java\nscript:alert(1)'], + ['embedded CR', 'java\rscript:alert(1)'], + ['embedded NUL', 'java\u0000script:alert(1)'], + ['embedded DEL', 'java\u007fscript:alert(1)'], + ['space before colon', 'javascript :alert(1)'], + ['data: html', 'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg=='], + ['data: svg image', 'data:image/svg+xml,'], + ['vbscript:', 'vbscript:msgbox(1)'], + ['file:', 'file:///etc/passwd'], + ['mailto: (not on the allowlist, see #368)', 'mailto:someone@example.org'], +]; +const EMPTYISH = [ + ['empty', ''], + ['whitespace only', ' \t\n'], + ['null', null], + ['undefined', undefined], +]; +// Safe URLs and what safeUrlAttr() must turn them into (fully escaped for a +// double- or single-quoted attribute: & < > " '). +const SAFE = [ + ['https', 'https://meshcore.example/docs', 'https://meshcore.example/docs'], + ['http', 'http://meshcore.example/docs', 'http://meshcore.example/docs'], + ['mixed-case scheme', 'HTTPS://meshcore.example/', 'HTTPS://meshcore.example/'], + ['hash route', '#/packets', '#/packets'], + ['root-relative', '/nodes', '/nodes'], + ['relative', 'docs/x', 'docs/x'], + ['query only', '?a=1', '?a=1'], + ['colon after the first path segment', '/nodes/a:b', '/nodes/a:b'], + ['query with &', 'https://m.example/?a=1&b=2', 'https://m.example/?a=1&b=2'], +]; +// No-scheme strings that a browser would turn INTO a scheme if '&' were left +// alone in the attribute: the entity is decoded while the attribute is +// parsed. They stay (relative) links, but the '&' must come out as & so +// the browser sees the literal text. +const ENTITY_OBFUSCATED = [ + ['decimal entity', 'javascript:alert(1)', '&#106;avascript:alert(1)'], + ['hex entity', 'javascript:alert(1)', '&#x6A;avascript:alert(1)'], + ['named :', 'javascript:alert(1)', 'javascript&colon;alert(1)'], + ['entity TAB inside', 'jav ascript:alert(1)', 'jav&#x09;ascript:alert(1)'], +]; +const QUOTE_BREAKOUT = [ + ['double-quote breakout', '" onmouseover="alert(1)', '" onmouseover="alert(1)'], + ['single-quote breakout', "/a' onmouseover='alert(1)", '/a' onmouseover='alert(1)'], + ['tag breakout', '">', '"><img src=x onerror=alert(1)>'], +]; + +// ===== B1 — isSafeLinkUrl: the shared allowlist predicate =============== +console.log('\n=== #429 B1: window.isSafeLinkUrl (shared scheme check) ==='); +{ + test('roles.js exports window.isSafeLinkUrl', () => { + assert.strictEqual(typeof isSafe, 'function'); + }); + for (const [name, url] of HOSTILE) { + test('rejects ' + name, () => assert.strictEqual(isSafe(url), false, JSON.stringify(url))); + } + for (const [name, url] of EMPTYISH) { + test('rejects ' + name, () => assert.strictEqual(isSafe(url), false, JSON.stringify(url))); + } + for (const [name, url] of SAFE) { + test('accepts ' + name, () => assert.strictEqual(isSafe(url), true, JSON.stringify(url))); + } +} + +// ===== B2 — safeUrlAttr: raw URL -> attribute value, or '' ============== +console.log('\n=== #429 B2: window.safeUrlAttr (raw URL to attribute value) ==='); +{ + test('roles.js exports window.safeUrlAttr', () => { + assert.strictEqual(typeof safeAttr, 'function'); + }); + for (const [name, url] of HOSTILE.concat(EMPTYISH)) { + test('returns "" for ' + name, () => assert.strictEqual(safeAttr(url), '', JSON.stringify(url))); + } + for (const [name, url, want] of SAFE) { + test('keeps ' + name, () => assert.strictEqual(safeAttr(url), want)); + } + for (const [name, url, want] of ENTITY_OBFUSCATED) { + test('entity-obfuscated scheme stays literal: ' + name, () => { + assert.strictEqual(safeAttr(url), want); + }); + } + for (const [name, url, want] of QUOTE_BREAKOUT) { + test('cannot leave the attribute: ' + name, () => { + const out = safeAttr(url); + assert.strictEqual(out, want); + assert(!/["'<>]/.test(out), 'raw quote or bracket survived: ' + out); + }); + } + test('& is escaped exactly once', () => { + assert.strictEqual(safeAttr('/a?x=1&y=2'), '/a?x=1&amp;y=2'); + }); +} + +// ===== B3 — one allowlist: miniMarkdown and the raw-URL path agree ====== +console.log('\n=== #429 B3: miniMarkdown and safeUrlAttr share one allowlist ==='); +{ + // Parentheses dropped so miniMarkdown's [^)]+ captures the whole URL. + const MATRIX = HOSTILE.concat(SAFE).map(r => r[1].replace(/[()]/g, '')); + test('the agreement matrix covers every hostile and safe URL', () => { + assert.strictEqual(MATRIX.length, HOSTILE.length + SAFE.length); + }); + for (const url of MATRIX) { + test('same verdict for ' + JSON.stringify(url), () => { + const linked = / { + const src = fs.readFileSync('public/roles.js', 'utf8'); + assert.strictEqual((src.match(/SAFE_LINK_SCHEMES\s*=/g) || []).length, 1); + }); +} + +// ===== B6 — isSafeImageUrl: the image-sink form of the same check ======= +console.log('\n=== #429 B6: window.isSafeImageUrl (logo / favicon sinks) ==='); +{ + const isImg = W.isSafeImageUrl; + test('roles.js exports window.isSafeImageUrl', () => assert.strictEqual(typeof isImg, 'function')); + // Everything the link check accepts, the image check accepts too. + for (const [name, url] of SAFE) { + test('accepts ' + name, () => assert.strictEqual(isImg(url), true, JSON.stringify(url))); + } + // Inline images stay possible (the customizer has always taken them), + // after the same normalisation. + for (const url of ['data:image/png;base64,iVBORw0KGgo=', 'data:image/svg+xml;base64,PHN2Zy8+', ' DATA:Image/png;base64,AA', 'da\tta:image/gif;base64,R0lG']) { + test('accepts inline image ' + JSON.stringify(url), () => assert.strictEqual(isImg(url), true)); + } + // Every other hostile URL is still rejected, data:text/html included. + for (const [name, url] of HOSTILE.concat(EMPTYISH)) { + if (/^data:image\//.test(url || '')) continue; + test('rejects ' + name, () => assert.strictEqual(isImg(url), false, JSON.stringify(url))); + } + test('rejects a data: URL that only mentions image/', () => { + assert.strictEqual(isImg('data:text/html,image/'), false); + assert.strictEqual(isImg('data:,image/png'), false); + }); +} + +// ===== B4 — the Home footer sink uses the shared helper ================= +console.log('\n=== #429 B4: home.js footerLinks go through safeUrlAttr ==='); +{ + const home = fs.readFileSync('public/home.js', 'utf8'); + test('no footer href is built from escapeAttr(l.url) any more', () => { + assert(!/href="\$\{escapeAttr\(l\.url\)\}"/.test(home), 'raw escapeAttr(l.url) href is back'); + }); + test('footer links are rendered through window.safeUrlAttr', () => { + const fn = /function footerLinkHtml\(l\) \{([\s\S]*?)\n \}/.exec(home); + assert(fn, 'footerLinkHtml(l) not found in public/home.js'); + assert(/safeUrlAttr\(l\.url\)/.test(fn[1]), 'footerLinkHtml does not call safeUrlAttr(l.url)'); + assert(/homeCfg\.footerLinks\.map\(footerLinkHtml\)/.test(home), 'footerLinks are not mapped through footerLinkHtml'); + }); + test('home.js grows no scheme allowlist of its own', () => { + assert(!/javascript:|SAFE_LINK_SCHEMES|https\?:/i.test(home.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, '')), + 'public/home.js contains its own scheme check'); + }); +} + +// ===== B5 — branding URL sinks in customize-v2.js ======================= +console.log('\n=== #429 B5: customize-v2.js branding URL sinks ==='); +{ + const cv2 = fs.readFileSync('public/customize-v2.js', 'utf8'); + const code = cv2.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, ''); + test('no innerHTML is built from a logo URL', () => { + const hits = code.split('\n').filter(l => /innerHTML\s*=/.test(l) && /. escapeAttr only covered the quotes, so a +// javascript:/data:/vbscript: URL became a live link, and so did an +// entity-encoded scheme (javascript:) that the browser decodes while +// it parses the attribute. +// 2. Branding logo / favicon. public/customize-v2.js wrote branding.logoUrl +// into the navbar and branding.faviconUrl into +// .href with no scheme check, from the server theme, from +// the stored overrides (cs-theme-overrides, also what a theme import +// writes) and from the customizer input; it also wrote an unescaped +// '' into any .brand-icon element. +// +// Hostile customization is injected by rewriting /api/config/theme with +// page.route or by seeding localStorage with addInitScript — the fixture DB +// is untouched and no payload leaves the machine. The checks are DOM-level: +// did any injected handler run (window.__xss429) or dialog open, which +// protocol did the browser compute for each anchor/img/link, and which +// attributes did each footer anchor end up with. +// +// Usage: BASE_URL=http://localhost:13581 node test-xss-429-home-footer-e2e.js +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const SAFE_PROTOCOLS = ['http:', 'https:']; + +// Group 1 — unsafe schemes, plain and obfuscated with case and the ASCII +// controls/whitespace a browser ignores. Must not become links; the label +// survives as plain text. +const UNSAFE_URLS = [ + 'javascript:window.__xss429=1', + 'JaVaScRiPt:window.__xss429=1', + ' \t\njavascript:window.__xss429=1', + 'java\nscript:window.__xss429=1', + 'java\u0000script:window.__xss429=1', + '\u0001javascript:window.__xss429=1', + 'data:text/html;base64,PHNjcmlwdD53aW5kb3cuX194c3M0Mjk9MTwvc2NyaXB0Pg==', + 'vbscript:msgbox1', +]; +// Group 2 — no scheme in the raw text, but a scheme once an HTML entity is +// decoded. They may stay links, but only as inert relative URLs. +const ENTITY_URLS = [ + 'javascript:window.__xss429=1', + 'javascript:window.__xss429=1', + 'javascript:window.__xss429=1', +]; +// Group 3 — try to leave the attribute. Stay links with exactly the four +// attributes the footer emits. +const BREAKOUT_URLS = [ + '" onmouseover="window.__xss429=1', + '" autofocus onfocus="window.__xss429=1" x="', +]; +// Group 4 — safe links that must keep working exactly as before. +const SAFE_URLS = [ + 'https://meshcore.example/docs', + 'http://meshcore.example/docs', + '/nodes', + '#/map', +]; +const FOOTER = [] + .concat(UNSAFE_URLS.map((u, i) => ({ label: 'xss429-bad-' + i, url: u }))) + .concat(ENTITY_URLS.map((u, i) => ({ label: 'xss429-ent-' + i, url: u }))) + .concat(BREAKOUT_URLS.map((u, i) => ({ label: 'xss429-brk-' + i, url: u }))) + .concat(SAFE_URLS.map((u, i) => ({ label: 'xss429-ok-' + i, url: u }))) + // A rejected link with a hostile label: the label must come out as text. + .concat([{ label: 'xss429-lbl', url: 'javascript:void0' }]); + +const HOSTILE_LOGOS = [ + 'javascript:window.__xss429=1', + ' JaVa\tScRiPt:window.__xss429=1', + 'data:text/html;base64,PHNjcmlwdD53aW5kb3cuX194c3M0Mjk9MTwvc2NyaXB0Pg==', +]; +// Image sinks keep accepting an inline data:image/ logo (nothing in an +// runs script, and the customizer has always taken one). +const INLINE_LOGO = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4='; +const SAFE_LOGO = '/img/corescope-logo.svg?ok=429'; +const SAFE_FAVICON = '/favicon.ico?ok=429'; + +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'); } + +// Opens a fresh context. `theme` is merged into the /api/config/theme body; +// `overrides` seeds cs-theme-overrides; `brandIcon` adds the legacy +// a custom index.html may still carry. +async function openPage(browser, opts) { + const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + if (opts.theme) { + await ctx.route((url) => url.pathname === '/api/config/theme', async (route) => { + const res = await route.fetch(); + let body = {}; + try { body = await res.json(); } catch (e) { body = {}; } + Object.keys(opts.theme).forEach((k) => { + body[k] = Object.assign({}, body[k] || {}, opts.theme[k]); + }); + await route.fulfill({ response: res, json: body }); + }); + } + await ctx.addInitScript((o) => { + try { + localStorage.setItem('meshcore-user-level', 'new'); + if (o.overrides) localStorage.setItem('cs-theme-overrides', JSON.stringify(o.overrides)); + } catch (e) {} + if (o.brandIcon) { + document.addEventListener('readystatechange', () => { + const brand = document.querySelector('.nav-brand'); + if (brand && !brand.querySelector('.brand-icon')) { + const s = document.createElement('span'); + s.className = 'brand-icon'; + brand.appendChild(s); + } + }); + } + }, { overrides: opts.overrides || null, brandIcon: !!opts.brandIcon }); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + page.on('pageerror', (e) => console.error(' [pageerror]', e.message)); + page._dialogs = 0; + page.on('dialog', (d) => { page._dialogs++; d.dismiss().catch(() => {}); }); + return { ctx, page }; +} + +async function brandState(page) { + return page.evaluate(() => { + const logo = document.querySelector('.nav-brand .brand-logo'); + const fav = document.querySelector('link[rel="icon"]'); + const iconImgs = Array.from(document.querySelectorAll('.brand-icon img')); + return { + logoTag: logo ? logo.tagName.toLowerCase() : null, + logoSrc: logo && logo.tagName.toLowerCase() === 'img' ? logo.src : null, + favHref: fav ? fav.href : null, + iconImgs: iconImgs.map(i => ({ src: i.src, attrs: Array.from(i.attributes).map(a => a.name) })), + fired: window.__xss429 || 0, + }; + }); +} +function protocolOf(u) { + try { return new URL(u).protocol; } catch (e) { return 'unparseable:'; } +} + +(async () => { + let browser; + try { + browser = await chromium.launch(); + } catch (e) { + if (process.env.CHROMIUM_REQUIRE === '1') { + console.error('Chromium required but unavailable: ' + e.message); + process.exit(1); + } + console.log('⚠️ Chromium unavailable, skipping: ' + e.message); + process.exit(0); + } + + // ── 1. Home footer links ──────────────────────────────────────────── + { + const { ctx, page } = await openPage(browser, { + theme: { home: { heroTitle: 'CoreScope', heroSubtitle: 'issue #429 fixture', footerLinks: FOOTER } }, + }); + await page.goto(BASE + '/#/home', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => /xss429-ok-0/.test((document.querySelector('.home-footer-links') || {}).textContent || ''), + null, { timeout: 15000 }); + // Hover and focus every footer anchor so an injected mouseover/focus + // handler gets its chance to run before we count. + const n = await page.locator('.home-footer-links a').count(); + for (let i = 0; i < n; i++) { + const a = page.locator('.home-footer-links a').nth(i); + await a.hover().catch(() => {}); + await a.focus().catch(() => {}); + } + await page.waitForTimeout(300); + + console.log('\n=== #429 — hostile Home footer links ==='); + + const rows = await page.evaluate(() => { + const anchors = Array.from(document.querySelectorAll('.home-footer-links a')); + return { + text: (document.querySelector('.home-footer-links') || {}).textContent || '', + anchors: anchors.map(a => ({ + label: (a.textContent || '').trim(), + proto: a.protocol, + href: a.getAttribute('href'), + attrs: Array.from(a.attributes).map(x => x.name.toLowerCase()), + })), + imgs: document.querySelectorAll('.home-footer-links img').length, + fired: window.__xss429 || 0, + }; + }); + const byLabel = (l) => rows.anchors.find(a => a.label === l); + + await step('every footer entry reached the page', async () => { + const missing = FOOTER.map(f => f.label.replace(/^]*>/, '')) + .filter(l => !rows.text.includes(l)); + assert(missing.length === 0, 'missing: ' + missing.join(', ')); + }); + + await step('no injected handler ran and no dialog opened', async () => { + assert(rows.fired === 0, 'injected handler fired ' + rows.fired + ' time(s)'); + assert(page._dialogs === 0, page._dialogs + ' dialog(s) opened'); + }); + + await step('no footer anchor resolves to an unsafe scheme', async () => { + const bad = rows.anchors.filter(a => !['http:', 'https:'].includes(a.proto)) + .map(a => a.label + ' -> ' + a.proto + ' ' + JSON.stringify(a.href)); + assert(bad.length === 0, 'unsafe anchors: ' + JSON.stringify(bad)); + }); + + await step('every unsafe-scheme URL degraded to plain label text', async () => { + const anchored = UNSAFE_URLS.map((_, i) => 'xss429-bad-' + i).filter(l => byLabel(l)); + assert(anchored.length === 0, 'unsafe URLs became links: ' + anchored.join(', ')); + assert(!rows.anchors.some(a => a.label.includes('xss429-lbl')), 'the hostile-label entry became a link'); + }); + + await step('a rejected entry keeps its label as escaped text, not markup', async () => { + assert(rows.text.includes('xss429-lbl'), + 'label text was not rendered verbatim'); + assert(rows.imgs === 0, rows.imgs + ' element(s) inside the footer'); + }); + + await step('entity-obfuscated schemes stay literal, inert relative URLs', async () => { + ENTITY_URLS.forEach((u, i) => { + const a = byLabel('xss429-ent-' + i); + if (!a) return; // dropping it would be safe too + assert(SAFE_PROTOCOLS.includes(a.proto), a.label + ' resolves to ' + a.proto); + assert(a.href === u, a.label + ' href decoded to ' + JSON.stringify(a.href) + ', want the literal ' + JSON.stringify(u)); + }); + }); + + await step('quote-breakout URLs add no attribute to the anchor', async () => { + BREAKOUT_URLS.forEach((u, i) => { + const a = byLabel('xss429-brk-' + i); + assert(a, 'xss429-brk-' + i + ' did not render (a schemeless URL stays a link)'); + assert(JSON.stringify(a.attrs) === JSON.stringify(['href', 'class', 'target', 'rel']), + a.label + ' carries ' + a.attrs.join(',')); + assert(a.href === u, a.label + ' href is ' + JSON.stringify(a.href)); + }); + }); + + console.log('\n=== #429 — safe Home footer links still work ==='); + + await step('every safe URL still renders as a link with the given href', async () => { + SAFE_URLS.forEach((u, i) => { + const a = byLabel('xss429-ok-' + i); + assert(a, 'xss429-ok-' + i + ' did not render as a link'); + assert(a.href === u, a.label + ' href is ' + a.href + ', want ' + u); + assert(JSON.stringify(a.attrs) === JSON.stringify(['href', 'class', 'target', 'rel']), + a.label + ' carries ' + a.attrs.join(',')); + }); + }); + + await step('a safe hash footer link still navigates', async () => { + const label = 'xss429-ok-' + SAFE_URLS.indexOf('#/map'); + await page.evaluate((l) => { + const a = Array.from(document.querySelectorAll('.home-footer-links a')) + .find(x => (x.textContent || '').trim() === l); + a.removeAttribute('target'); // keep the navigation in this tab + a.click(); + }, label); + await page.waitForFunction(() => location.hash === '#/map', null, { timeout: 10000 }); + }); + + await ctx.close(); + } + + // ── 2. Branding logo / favicon ────────────────────────────────────── + console.log('\n=== #429 — hostile branding.logoUrl / faviconUrl ==='); + + for (let i = 0; i < HOSTILE_LOGOS.length; i++) { + const u = HOSTILE_LOGOS[i]; + await step('server theme: logo/favicon ' + JSON.stringify(u) + ' is not applied', async () => { + const { ctx, page } = await openPage(browser, { + theme: { branding: { logoUrl: u, faviconUrl: u } }, + }); + await page.goto(BASE + '/#/home', { waitUntil: 'load' }); + await page.waitForFunction(() => !!window.SITE_CONFIG, null, { timeout: 15000 }); + await page.waitForTimeout(300); + const s = await brandState(page); + await ctx.close(); + assert(s.fired === 0, 'injected handler fired'); + if (s.logoSrc) assert(SAFE_PROTOCOLS.includes(protocolOf(s.logoSrc)), 'logo img src is ' + s.logoSrc.slice(0, 60)); + assert(!s.favHref || SAFE_PROTOCOLS.includes(protocolOf(s.favHref)), 'favicon href is ' + String(s.favHref).slice(0, 60)); + }); + } + + await step('stored overrides (theme import): hostile logo/favicon are not applied', async () => { + const u = HOSTILE_LOGOS[0]; + const { ctx, page } = await openPage(browser, { + overrides: { branding: { logoUrl: u, faviconUrl: u } }, + }); + await page.goto(BASE + '/#/home', { waitUntil: 'load' }); + await page.waitForFunction(() => !!window.SITE_CONFIG, null, { timeout: 15000 }); + await page.waitForTimeout(300); + const s = await brandState(page); + await ctx.close(); + if (s.logoSrc) assert(SAFE_PROTOCOLS.includes(protocolOf(s.logoSrc)), 'logo img src is ' + s.logoSrc); + assert(!s.favHref || SAFE_PROTOCOLS.includes(protocolOf(s.favHref)), 'favicon href is ' + s.favHref); + }); + + await step('customizer: a hostile stored logo gets no preview ', async () => { + const { ctx, page } = await openPage(browser, { + overrides: { branding: { logoUrl: HOSTILE_LOGOS[0] } }, + }); + await page.goto(BASE + '/#/home', { waitUntil: 'load' }); + await page.click('#customizeToggle'); + const tab = await page.$('.cust-tab[data-tab="branding"]'); + if (tab) await tab.click(); + await page.waitForSelector('input[data-cv2-field="branding.logoUrl"]', { timeout: 5000 }); + const previews = await page.evaluate(() => + Array.from(document.querySelectorAll('.cust-preview-img')).map(i => i.src)); + await ctx.close(); + const bad = previews.filter(p => !SAFE_PROTOCOLS.includes(protocolOf(p))); + assert(bad.length === 0, 'unsafe preview src: ' + JSON.stringify(bad)); + }); + + await step('customizer: typing a javascript: logo URL does not reach the navbar', async () => { + const { ctx, page } = await openPage(browser, {}); + await page.goto(BASE + '/#/home', { waitUntil: 'load' }); + await page.click('#customizeToggle'); + const tab = await page.$('.cust-tab[data-tab="branding"]'); + if (tab) await tab.click(); + const inp = await page.waitForSelector('input[data-cv2-field="branding.logoUrl"]', { timeout: 5000 }); + await inp.fill(HOSTILE_LOGOS[0]); + await inp.dispatchEvent('input'); + const fav = await page.waitForSelector('input[data-cv2-field="branding.faviconUrl"]', { timeout: 5000 }); + await fav.fill(HOSTILE_LOGOS[0]); + await fav.dispatchEvent('input'); + await page.waitForTimeout(300); + const s = await brandState(page); + await ctx.close(); + if (s.logoSrc) assert(SAFE_PROTOCOLS.includes(protocolOf(s.logoSrc)), 'logo img src is ' + s.logoSrc); + assert(!s.favHref || SAFE_PROTOCOLS.includes(protocolOf(s.favHref)), 'favicon href is ' + s.favHref); + }); + + await step('a legacy .brand-icon never receives logo markup', async () => { + const { ctx, page } = await openPage(browser, { + brandIcon: true, + theme: { branding: { logoUrl: 'x" onerror="window.__xss429=1' } }, + overrides: { branding: { logoUrl: 'y" onerror="window.__xss429=1' } }, + }); + await page.goto(BASE + '/#/home', { waitUntil: 'load' }); + await page.waitForFunction(() => !!window.SITE_CONFIG, null, { timeout: 15000 }); + await page.waitForTimeout(500); + const s = await brandState(page); + const hasIcon = await page.evaluate(() => !!document.querySelector('.brand-icon')); + await ctx.close(); + assert(hasIcon, 'test setup: the .brand-icon element was not injected'); + assert(s.fired === 0, 'injected handler fired ' + s.fired + ' time(s)'); + const handlers = s.iconImgs.filter(i => i.attrs.some(a => /^on/i.test(a))); + assert(handlers.length === 0, '.brand-icon img with inline handler: ' + JSON.stringify(handlers)); + }); + + console.log('\n=== #429 — safe branding still applies ==='); + + await step('a safe logo and favicon are still applied', async () => { + const { ctx, page } = await openPage(browser, { + theme: { branding: { logoUrl: SAFE_LOGO, faviconUrl: SAFE_FAVICON } }, + }); + await page.goto(BASE + '/#/home', { waitUntil: 'load' }); + await page.waitForFunction(() => { + const l = document.querySelector('.nav-brand .brand-logo'); + return l && l.tagName.toLowerCase() === 'img'; + }, null, { timeout: 15000 }); + const s = await brandState(page); + await ctx.close(); + assert(s.logoSrc && s.logoSrc.endsWith(SAFE_LOGO), 'logo img src is ' + s.logoSrc); + assert(s.favHref && s.favHref.endsWith(SAFE_FAVICON), 'favicon href is ' + s.favHref); + }); + + await step('an inline data:image/ logo is still applied', async () => { + const { ctx, page } = await openPage(browser, { + theme: { branding: { logoUrl: INLINE_LOGO } }, + }); + await page.goto(BASE + '/#/home', { waitUntil: 'load' }); + await page.waitForFunction(() => { + const l = document.querySelector('.nav-brand .brand-logo'); + return l && l.tagName.toLowerCase() === 'img'; + }, null, { timeout: 15000 }); + const s = await brandState(page); + await ctx.close(); + assert(s.logoSrc === INLINE_LOGO, 'logo img src is ' + String(s.logoSrc).slice(0, 60)); + }); + + await step('the customizer shows a preview for a safe stored logo', async () => { + const { ctx, page } = await openPage(browser, { + overrides: { branding: { logoUrl: SAFE_LOGO } }, + }); + await page.goto(BASE + '/#/home', { waitUntil: 'load' }); + await page.click('#customizeToggle'); + const tab = await page.$('.cust-tab[data-tab="branding"]'); + if (tab) await tab.click(); + await page.waitForSelector('input[data-cv2-field="branding.logoUrl"]', { timeout: 5000 }); + const previews = await page.evaluate(() => + Array.from(document.querySelectorAll('.cust-preview-img')).map(i => i.src)); + await ctx.close(); + assert(previews.some(p => p.endsWith(SAFE_LOGO)), 'no preview for the safe logo: ' + JSON.stringify(previews)); + }); + + await browser.close(); + + console.log('\n' + passed + ' passed, ' + failed + ' failed'); + process.exit(failed ? 1 : 0); +})().catch((e) => { console.error(e); process.exit(1); }); From 4ca35bd00823ae24e2bc63c469a77f23e8c38903 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 13 Oct 2026 11:40:38 +0000 Subject: [PATCH 2/2] fix(ui): validate URL schemes for Home footer links and branding URLs (#429) roles.js: the scheme check #420 added for miniMarkdown is now one exported predicate, window.isSafeLinkUrl (same allowlist, same case and ASCII control/whitespace normalisation). window.safeUrlAttr wraps it for a raw URL headed for an HTML attribute and also escapes '&', so an entity-encoded scheme stays literal text. window.isSafeImageUrl is the image-sink form: the same check plus inline data:image/ URLs, which an or icon cannot run. home.js: footerLinks[].url goes through safeUrlAttr; a rejected URL renders as its escaped label text. customize-v2.js: the branding logo and favicon setters (server theme, stored overrides/theme import, customizer input) and the logo preview use the image-sink check; a rejected logo counts as cleared. The three dead .brand-icon innerHTML writes, which interpolated logoUrl unescaped into , are removed: the rebrand dropped that element from index.html. Co-Authored-By: Claude Opus 5.5 --- public/customize-v2.js | 41 +++++++++++++++++-------------------- public/home.js | 11 +++++++++- public/roles.js | 46 +++++++++++++++++++++++++++++++++--------- 3 files changed, 65 insertions(+), 33 deletions(-) diff --git a/public/customize-v2.js b/public/customize-v2.js index 45f044598..6bae00fc3 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -68,6 +68,8 @@ function _setBrandLogoUrl(url, alt) { var node = document.querySelector('.nav-brand .brand-logo'); if (!node) return; + // #429 — a URL the shared scheme check rejects is treated as cleared. + if (url && !_brandUrlOk(url)) url = ''; if (url) { if (node.tagName.toLowerCase() === 'img') { node.setAttribute('src', url); @@ -97,6 +99,17 @@ if (alt != null) node.setAttribute('alt', alt); } } + // #429 — branding URLs are customization content (server theme, stored + // overrides, theme import), so they get the scheme check roles.js exports + // for miniMarkdown and Home footer links, in its image-sink form. Fails + // closed when roles.js is not loaded. + function _brandUrlOk(url) { + return typeof window.isSafeImageUrl === 'function' && window.isSafeImageUrl(url); + } + function _setFaviconUrl(url) { + var link = document.querySelector('link[rel="icon"]'); + if (link && url && _brandUrlOk(url)) link.href = url; + } function _setBrandAlt(alt) { var node = document.querySelector('.nav-brand .brand-logo'); if (!node) return; @@ -795,15 +808,8 @@ var brandEl = document.querySelector('.brand-text'); if (brandEl) brandEl.textContent = br.siteName; } - if (br.logoUrl) { - _setBrandLogoUrl(br.logoUrl, br.siteName || null); - var iconEl = document.querySelector('.brand-icon'); - if (iconEl) iconEl.innerHTML = ''; - } - if (br.faviconUrl) { - var fav = document.querySelector('link[rel="icon"]'); - if (fav) fav.href = br.faviconUrl; - } + if (br.logoUrl) _setBrandLogoUrl(br.logoUrl, br.siteName || null); + if (br.faviconUrl) _setFaviconUrl(br.faviconUrl); // #1518 — apply validated homeUrl override to .nav-brand[href]. // Always call: invalid/empty restores '#/' so a cleared override visibly // reverts. Validator rejects javascript:/data:/etc to prevent XSS. @@ -1301,7 +1307,7 @@ function _renderBranding() { var eff = _getEffective(); var b = eff.branding || {}; - var logoPreview = b.logoUrl ? 'Logo preview' : ''; + var logoPreview = b.logoUrl && _brandUrlOk(b.logoUrl) ? 'Logo preview' : ''; return '
' + '
' + '
' + @@ -2404,15 +2410,9 @@ } if (section === 'branding' && key === 'logoUrl') { _setBrandLogoUrl(inp.value || '', null); - var iconEl = document.querySelector('.brand-icon'); - if (iconEl) { - if (inp.value) iconEl.innerHTML = ''; - else iconEl.innerHTML = ''; - } } if (section === 'branding' && key === 'faviconUrl') { - var link = document.querySelector('link[rel="icon"]'); - if (link && inp.value) link.href = inp.value; + _setFaviconUrl(inp.value); } if (section === 'branding' && key === 'homeUrl') { // #1518 — live nav-brand[href] swap. Validator silently drops @@ -2766,13 +2766,8 @@ } if (overrides.branding.logoUrl) { _setBrandLogoUrl(overrides.branding.logoUrl, overrides.branding.siteName || null); - var iconEl = document.querySelector('.brand-icon'); - if (iconEl) iconEl.innerHTML = ''; - } - if (overrides.branding.faviconUrl) { - var link = document.querySelector('link[rel="icon"]'); - if (link) link.href = overrides.branding.faviconUrl; } + if (overrides.branding.faviconUrl) _setFaviconUrl(overrides.branding.faviconUrl); // #1518 — re-apply homeUrl override after DOM is ready (handles cold // page loads where the customizer pipeline ran before .nav-brand mounted). if (overrides.branding.homeUrl) { diff --git a/public/home.js b/public/home.js index 7e35be568..761008659 100644 --- a/public/home.js +++ b/public/home.js @@ -25,6 +25,15 @@ if (m) return _renderHomeGlyph(m[1]) + ' ' + _escHtml(m[2]); return _escHtml(s); // EMOJI-OK-LEGACY-RENDER } + // #429 — footerLinks come from importable Home customization, so the URL + // gets the same scheme check as miniMarkdown links (roles.js). A rejected + // URL renders as its label text. + function footerLinkHtml(l) { + const href = window.safeUrlAttr(l.url); + const label = _renderHomeLabel(l.label); + if (!href) return label; + return `${label}`; + } function _escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } const PREF_KEY = 'meshcore-user-level'; @@ -149,7 +158,7 @@