diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 4b69907b5..9dbe3800a 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -431,6 +431,7 @@ jobs:
BASE_URL=http://localhost:13581 node test-observer-iata-1188-e2e.js 2>&1 | tee -a e2e-output.txt
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
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-1758-ng-filter-rerenders-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-fluid-1055-e2e.js 2>&1 | tee -a e2e-output.txt
diff --git a/public/home.js b/public/home.js
index 008bd7a69..7e35be568 100644
--- a/public/home.js
+++ b/public/home.js
@@ -653,7 +653,11 @@
// ==================== HELPERS ====================
function escapeHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); }
- function escapeAttr(s) { return String(s).replace(/"/g,'"').replace(/'/g,'''); }
+ /* #368: one shared implementation, defined and exported by public/roles.js
+ (which index.html loads before this file) so the Markdown link
+ destinations in miniMarkdown() and the attribute sinks here cannot drift
+ apart. Behaviour is unchanged from the local copy this replaces. */
+ function escapeAttr(s) { return window.escapeAttr(s); }
function timeSinceMs(d) { return Date.now() - d.getTime(); }
function checklist(homeCfg) {
diff --git a/public/roles.js b/public/roles.js
index 1fa21214f..f01a5f37f 100644
--- a/public/roles.js
+++ b/public/roles.js
@@ -1025,6 +1025,41 @@
}
};
+ /* #368 — attribute escape for the Markdown link destination below.
+ Same 5-char-minus-&<> set as the escapeAttr helpers in public/home.js and
+ public/path-inspector.js: by the time miniMarkdown() captures a
+ destination it has already turned & < > into entities, so the only
+ characters that can still leave a double-quoted href are the two quote
+ characters. Exported so the Home render path has one shared copy. */
+ function escapeAttr(s) {
+ return String(s == null ? '' : s).replace(/"/g, '"').replace(/'/g, ''');
+ }
+ window.escapeAttr = escapeAttr;
+
+ /* #368 — scheme allowlist for Markdown link destinations.
+ Home checklist/step text is site-customization content, which is
+ importable and therefore untrusted, so a destination may not be able to
+ pick its own URL scheme. Only these are anchored; everything without a
+ scheme (relative paths, #fragments, ?query) is left alone. mailto: is not
+ in the list because nothing in the product emits one today — adding it is
+ a product decision, not a security one. */
+ var SAFE_LINK_SCHEMES = ['http', 'https'];
+ /* RFC 3986 scheme grammar, lowercased before matching. */
+ var LINK_SCHEME_RE = /^([a-z][a-z0-9+.\-]*):/;
+
+ /* Returns the escaped href for `dest`, or '' when the destination must not
+ become a link. Normalisation mirrors what a browser does before it
+ resolves a scheme: ASCII controls and ASCII whitespace are ignored, and
+ the scheme is case-insensitive. So `JaVa\tScRiPt:alert(1)` is rejected for
+ the same reason `javascript:alert(1)` is. */
+ function safeLinkHref(dest) {
+ var raw = String(dest == null ? '' : dest);
+ var probe = raw.replace(/[\u0000-\u0020\u007f]/g, '').toLowerCase();
+ if (!probe) return '';
+ var m = LINK_SCHEME_RE.exec(probe);
+ if (m && SAFE_LINK_SCHEMES.indexOf(m[1]) === -1) return '';
+ return escapeAttr(raw);
+ }
// Simple markdown → HTML (bold, italic, links, code, lists, line breaks)
window.miniMarkdown = function(text) {
if (!text) return '';
@@ -1033,7 +1068,14 @@
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/`(.+?)`/g, '$1')
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
+ /* #368: a replacement function, not a '$2' template — a '$&'/"$'" in the
+ destination would otherwise be re-expanded by String.replace. A
+ rejected destination degrades to its already-escaped label text. */
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(_m, label, dest) {
+ var href = safeLinkHref(dest);
+ if (!href) return label;
+ return '' + label + '';
+ })
.replace(/^- (.+)/gm, '
$1')
.replace(/\n/g, '
');
// Wrap consecutive in
diff --git a/scripts/collect-frontend-coverage.js b/scripts/collect-frontend-coverage.js
index c950475d8..7fc95a3d2 100644
--- a/scripts/collect-frontend-coverage.js
+++ b/scripts/collect-frontend-coverage.js
@@ -792,6 +792,9 @@ async function run() {
if (typeof miniMarkdown === 'function') {
miniMarkdown('**bold** and *italic* and `code`');
miniMarkdown('[link](https://example.com)');
+ // #368 — both arms of the link-destination scheme check
+ miniMarkdown('[rel](/nodes) [frag](#/map)');
+ miniMarkdown('[bad](javascript:void0) [bad2](data:text/plain;base64,QQ==)');
miniMarkdown('- item1\n- item2\n- item3');
miniMarkdown('');
miniMarkdown(null);
diff --git a/test-all.sh b/test-all.sh
index eb0b5755e..fbd6d06f9 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -198,6 +198,7 @@ run test-reach-rank.js
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-393-slowpath-doc-bound.js
run test-preflight-xss-gate.js
run test-issue-1648-m4-emoji-scan.js
diff --git a/test-issue-368-minimarkdown-links.js b/test-issue-368-minimarkdown-links.js
new file mode 100644
index 000000000..ae880eaa1
--- /dev/null
+++ b/test-issue-368-minimarkdown-links.js
@@ -0,0 +1,257 @@
+/**
+ * test-issue-368-minimarkdown-links.js
+ *
+ * Issue #368 — miniMarkdown() (public/roles.js) built its anchors with
+ * .replace(/\[([^\]]+)\]\(([^)]+)\)/g,
+ * '$1')
+ * The surrounding text had already been & < > escaped, but the captured
+ * destination went into a double-quoted href with neither attribute escaping
+ * nor any restriction on the URL scheme. Home checklist/step text is
+ * site-customization content (importable, therefore untrusted) and is fed to
+ * miniMarkdown() by public/home.js, so a crafted destination could add its
+ * own attributes or use an executable scheme.
+ *
+ * These tests drive the REAL miniMarkdown from public/roles.js in a vm
+ * sandbox and assert on the produced HTML string. The browser-level
+ * counterpart (does anything actually execute on the Home page) is
+ * test-xss-368-home-links-e2e.js.
+ *
+ * Mutants this file must catch (verified by hand, see the PR):
+ * M1 drop the escapeAttr() on the destination -> A1 red
+ * M2 drop the control/whitespace stripping -> A2 red
+ * M3 accept every scheme -> A2 red
+ * M4 go back to the '$2' replacement template -> A1 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 md = W.miniMarkdown;
+assert(typeof md === 'function', 'public/roles.js did not export window.miniMarkdown');
+
+// Names of the attributes a browser would see on the first anchor. Parsing
+// the tag (rather than string-matching the payload) is what makes the
+// quote-breakout assertions payload-independent.
+function anchorAttrs(html) {
+ const m = /]*)>/i.exec(html);
+ if (!m) return null;
+ const names = [];
+ const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|[^\s>]+)/g;
+ let a;
+ while ((a = re.exec(m[1])) !== null) names.push(a[1].toLowerCase());
+ return names;
+}
+const EXPECTED_ATTRS = ['href', 'target', 'rel', 'style'];
+
+// ===== A1 — a hostile destination cannot create attributes ==============
+console.log('\n=== #368 A1: destination cannot break out of href ===');
+{
+ // Each payload tries to close the href early and add an event handler.
+ // None contains ')': miniMarkdown's destination group is [^)]+.
+ const BREAKOUTS = [
+ ['double quote + event handler', '" onmouseover="alert1'],
+ ['double quote + autofocus/onfocus', '" autofocus onfocus="alert1'],
+ ['single quote probe', "' onmouseover='alert1"],
+ ['quote, handler, and a second attribute', '" onerror="alert1" x="'],
+ ];
+ for (const [label, dest] of BREAKOUTS) {
+ test('no extra attribute for ' + label, () => {
+ const html = md('[click](' + dest + ')');
+ const attrs = anchorAttrs(html);
+ assert(attrs !== null, 'expected an anchor, got: ' + html);
+ assert.deepStrictEqual(attrs, EXPECTED_ATTRS,
+ 'destination created extra attributes: ' + html);
+ // attrs is the parsed attribute-NAME list, so an `onerror=` that only
+ // appears inside the (escaped) href value does not count.
+ assert(!attrs.some(a => /^on/.test(a) || a === 'autofocus'),
+ 'destination created an executable attribute: ' + html);
+ });
+ }
+
+ test('raw double quote is entity-encoded inside href', () => {
+ const html = md('[click](" onmouseover="alert1)');
+ assert(html.includes('"'), 'missing " marker: ' + html);
+ });
+
+ test('raw single quote is entity-encoded inside href', () => {
+ const html = md("[click](/a' onmouseover='alert1)");
+ assert(html.includes('''), 'missing ' marker: ' + html);
+ assert(!/'\s*onmouseover\s*=/i.test(html), "raw ' onmouseover= survived: " + html);
+ });
+
+ // The old '$2' replacement template re-expanded $-sequences found in the
+ // destination; the replacement function does not.
+ test("a $' in the destination is not re-expanded", () => {
+ const html = md("[click](/a$'b)");
+ assert(html.includes('href="/a$'b"'), 'destination was rewritten: ' + html);
+ });
+ test('a $& in the destination is not re-expanded', () => {
+ const html = md('[click](/a$&b)');
+ assert(html.includes('href="/a$&b"'), 'destination was rewritten: ' + html);
+ });
+}
+
+// ===== A2 — unsafe schemes are rejected, including obfuscated ones ======
+console.log('\n=== #368 A2: scheme allowlist after normalisation ===');
+{
+ const REJECTED = [
+ ['plain javascript:', 'javascript:alert1'],
+ ['mixed case', 'JaVaScRiPt:alert1'],
+ ['uppercase', 'JAVASCRIPT:alert1'],
+ ['leading spaces', ' javascript:alert1'],
+ ['embedded tab', 'java\tscript:alert1'],
+ ['embedded newline', 'java\nscript:alert1'],
+ ['embedded carriage return', 'java\rscript:alert1'],
+ ['embedded NUL', 'java\u0000script:alert1'],
+ ['embedded vertical tab', 'java\u000bscript:alert1'],
+ ['embedded form feed', 'java\u000cscript:alert1'],
+ ['embedded DEL', 'java\u007fscript:alert1'],
+ ['leading control byte', '\u0001javascript:alert1'],
+ ['space before the colon', 'javascript :alert1'],
+ ['data: html', 'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg=='],
+ ['vbscript:', 'vbscript:msgbox1'],
+ ['file:', 'file:///etc/passwd'],
+ ['mailto: (not in the allowlist today)', 'mailto:a@b.example'],
+ ['whitespace only', ' '],
+ ['controls only', '\u0001\u0002'],
+ ];
+ for (const [label, dest] of REJECTED) {
+ test('rejects ' + label, () => {
+ const html = md('[click me](' + dest + ')');
+ assert(!/ {
+ assert.strictEqual(md('before [click me](javascript:alert1) after'),
+ 'before click me after');
+ });
+
+ test('a rejected destination still has its label HTML-escaped', () => {
+ const html = md('[
](javascript:alert1)');
+ assert(!/
{
+ const html = md('[Docs](' + dest + ')');
+ const attrs = anchorAttrs(html);
+ assert(attrs !== null, 'expected an anchor for ' + dest + ', got: ' + html);
+ assert.deepStrictEqual(attrs, EXPECTED_ATTRS, 'unexpected attributes: ' + html);
+ assert(html.includes('href="' + dest + '"'), 'destination was altered: ' + html);
+ assert(html.includes('>Docs'), 'label was altered: ' + html);
+ });
+ }
+
+ test('an & in a safe destination stays entity-escaped exactly once', () => {
+ const html = md('[Docs](https://meshcore.example/?a=1&b=2)');
+ assert(html.includes('href="https://meshcore.example/?a=1&b=2"'), html);
+ assert(!html.includes('&'), 'double-escaped &: ' + html);
+ });
+
+ test('target/rel hardening is still present on an accepted link', () => {
+ const html = md('[Docs](https://meshcore.example/)');
+ assert(html.includes('target="_blank"'), html);
+ assert(html.includes('rel="noopener"'), html);
+ assert(html.includes('style="color:var(--link-color)"'), html);
+ });
+}
+
+// ===== A4 — the rest of miniMarkdown is unchanged =======================
+console.log('\n=== #368 A4: non-link Markdown behaviour unchanged ===');
+{
+ test('empty input', () => assert.strictEqual(md(''), ''));
+ test('null input', () => assert.strictEqual(md(null), ''));
+ test('bold', () => assert.strictEqual(md('a **b** c'), 'a b c'));
+ test('italic', () => assert.strictEqual(md('a *b* c'), 'a b c'));
+ test('code', () => assert.strictEqual(md('a `b` c'), 'a b c'));
+ test('angle brackets escaped', () => assert.strictEqual(md(''), '<b>'));
+ test('ampersand escaped', () => assert.strictEqual(md('a & b'), 'a & b'));
+ test('line break', () => assert.strictEqual(md('a\nb'), 'a
b'));
+ test('list wrapping', () => {
+ assert.strictEqual(md('- one\n- two\n'), '');
+ });
+ test('a link inside a list item still renders', () => {
+ const html = md('- see [Docs](https://meshcore.example/)\n');
+ assert(html.startsWith('- see {
+ const html = md('[ok](https://a.example/) and [no](javascript:void0)');
+ assert(html.includes(' {
+ assert.strictEqual(typeof W.escapeAttr, 'function');
+ assert.strictEqual(W.escapeAttr('a"b\'c'), 'a"b'c');
+ });
+ test('home.js has no second escapeAttr implementation', () => {
+ const src = fs.readFileSync('public/home.js', 'utf8');
+ const m = /function escapeAttr\(s\) \{([^\n]*)\n/.exec(src);
+ assert(m, 'escapeAttr not found in public/home.js');
+ assert(/return window\.escapeAttr\(s\);/.test(m[1]),
+ 'public/home.js defines its own escapeAttr body again: ' + m[1].trim());
+ });
+}
+
+console.log('\n' + passed + ' passed, ' + failed + ' failed');
+process.exit(failed ? 1 : 0);
diff --git a/test-xss-368-home-links-e2e.js b/test-xss-368-home-links-e2e.js
new file mode 100644
index 000000000..7079f9a7c
--- /dev/null
+++ b/test-xss-368-home-links-e2e.js
@@ -0,0 +1,266 @@
+// test-xss-368-home-links-e2e.js
+//
+// Browser regression test for issue #368 — miniMarkdown() link destinations
+// in the REAL Home rendering path.
+//
+// public/home.js feeds the Home "Getting on the mesh" steps and the FAQ
+// checklist through window.miniMarkdown() (public/roles.js) and assigns the
+// result with innerHTML. Both come from site-customization content
+// (SITE_CONFIG.home, served by /api/config/theme and importable), so the
+// Markdown link destination is attacker-reachable. Before the fix the
+// destination went into `href="$2"` with no attribute escaping and no scheme
+// check, so it could close the attribute and add an event handler, or use
+// javascript: / data:.
+//
+// The hostile customization is injected by rewriting /api/config/theme in the
+// browser with page.route — the fixture DB is untouched and no payload leaves
+// the machine. The checks are DOM-level and payload-independent:
+// * did any injected handler run (window.__xss368) or any dialog open
+// * does any anchor the checklist rendered resolve to an unsafe scheme
+// (per the live HTMLAnchorElement.protocol a browser computed, not a
+// string match on the source)
+// * do the rendered anchors carry exactly the four attributes miniMarkdown
+// is supposed to emit, and no inline event handler
+// * do safe https / http / relative / fragment links still render, still
+// point where they were told to, and still navigate
+//
+// Usage: BASE_URL=http://localhost:13581 node test-xss-368-home-links-e2e.js
+'use strict';
+const { chromium } = require('playwright');
+
+const BASE = process.env.BASE_URL || 'http://localhost:13581';
+
+// Group 1 — destinations that try to close href="…" and add their own
+// attributes. These stay links (they carry no scheme), but the quotes must
+// come out entity-encoded so the anchor gains nothing. No payload in this
+// file contains ')': miniMarkdown's destination group is [^)]+, so a ')'
+// would end the destination early and truncate the payload before it ever
+// reached the href.
+const BREAKOUT_DESTS = [
+ '" onmouseover="window.__xss368=1',
+ '" autofocus onfocus="window.__xss368=1',
+ '" onerror="window.__xss368=1" x="',
+ "/a' onmouseover='window.__xss368=1",
+];
+
+// Group 2 — destinations whose scheme is not on the allowlist, including
+// ones obfuscated with case and with ASCII controls/whitespace that a
+// browser ignores when it resolves a scheme. These must not become links at
+// all; the label survives as plain text.
+const UNSAFE_SCHEME_DESTS = [
+ 'javascript:window.__xss368=1',
+ 'JaVaScRiPt:window.__xss368=1',
+ 'java\u0000script:window.__xss368=1',
+ 'java\tscript:window.__xss368=1',
+ ' javascript:window.__xss368=1',
+ 'data:text/html;base64,PHNjcmlwdD53aW5kb3cuX194c3MzNjg9MTwvc2NyaXB0Pg==',
+ 'vbscript:msgbox1',
+];
+
+// Group 3 — safe destinations that must keep working exactly as before.
+const SAFE_DESTS = [
+ 'https://meshcore.example/docs',
+ 'http://meshcore.example/docs',
+ '/nodes',
+ '#/map',
+];
+
+// Steps carry groups 1 and 3, the FAQ checklist carries group 2 — both go
+// through miniMarkdown() on the same render pass.
+const STEP_DESTS = BREAKOUT_DESTS.concat(SAFE_DESTS);
+const HOSTILE_HOME = {
+ heroTitle: 'CoreScope',
+ heroSubtitle: 'issue #368 fixture',
+ steps: STEP_DESTS.map((d, i) => ({
+ emoji: '',
+ title: 'Step ' + i,
+ description: 'see [xss368-step-' + i + '](' + d + ') now',
+ })),
+ checklist: UNSAFE_SCHEME_DESTS.map((d, i) => ({
+ question: 'Question ' + i,
+ answer: 'see [xss368-scheme-' + i + '](' + d + ') now',
+ })),
+};
+
+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'); }
+
+async function hostileHomePage(browser) {
+ const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
+ 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 = {}; }
+ body.home = HOSTILE_HOME;
+ await route.fulfill({ response: res, json: body });
+ });
+ 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 () => {
+ 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);
+ }
+
+ const { ctx, page } = await hostileHomePage(browser);
+ await page.goto(BASE + '/#/home', { waitUntil: 'domcontentloaded' });
+ // A context with no 'meshcore-user-level' gets the experience chooser
+ // first; the checklist section only renders for the "I'm new" level.
+ await page.locator('#chooseNew').click();
+ // The checklist is rendered from SITE_CONFIG.home, which lands after
+ // /api/config/theme resolves, so wait for our own fixture text.
+ await page.waitForFunction(
+ () => /xss368-step-0/.test(document.body.textContent || ''),
+ null, { timeout: 15000 });
+ // Give any injected timer/handler a tick to fire before we count.
+ await page.waitForTimeout(300);
+
+ console.log('\n=== #368 — hostile Home Markdown link destinations ===');
+
+ await step('both payload groups actually reached the checklist', async () => {
+ const seen = await page.evaluate(() => ({
+ bodies: document.querySelectorAll('.home-checklist .checklist-a').length,
+ text: document.body.textContent || '',
+ }));
+ const want = STEP_DESTS.length + UNSAFE_SCHEME_DESTS.length;
+ assert(seen.bodies === want, 'rendered ' + seen.bodies + ' checklist bodies, want ' + want);
+ assert(seen.text.includes('xss368-scheme-0'), 'scheme payloads never rendered');
+ });
+
+ await step('no injected handler ran and no dialog opened', async () => {
+ const fired = await page.evaluate(() => window.__xss368 || 0);
+ assert(fired === 0, 'injected handler fired ' + fired + ' time(s)');
+ assert(page._dialogs === 0, page._dialogs + ' dialog(s) opened');
+ });
+
+ await step('no checklist anchor resolves to an unsafe scheme', async () => {
+ const bad = await page.evaluate(() => Array.from(
+ document.querySelectorAll('.home-checklist a'))
+ .map(a => ({ proto: a.protocol, raw: a.getAttribute('href') || '' }))
+ .filter(x => !['http:', 'https:'].includes(x.proto))
+ .map(x => x.proto + ' ' + x.raw));
+ assert(bad.length === 0, 'unsafe anchors: ' + JSON.stringify(bad));
+ });
+
+ await step('every unsafe-scheme destination degraded to plain label text', async () => {
+ const rows = await page.evaluate((n) => {
+ const out = [];
+ for (let i = 0; i < n; i++) {
+ const label = 'xss368-scheme-' + i;
+ out.push({
+ label,
+ anchored: Array.from(document.querySelectorAll('.home-checklist a'))
+ .some(a => (a.textContent || '').trim() === label),
+ present: (document.body.textContent || '').includes(label),
+ });
+ }
+ return out;
+ }, UNSAFE_SCHEME_DESTS.length);
+ const anchored = rows.filter(r => r.anchored).map(r => r.label);
+ assert(anchored.length === 0, 'unsafe destinations became links: ' + anchored.join(', '));
+ const missing = rows.filter(r => !r.present).map(r => r.label);
+ assert(missing.length === 0, 'label text was dropped entirely: ' + missing.join(', '));
+ });
+
+ await step('no checklist anchor grew an attribute miniMarkdown does not emit', async () => {
+ const extra = await page.evaluate(() => {
+ const allowed = ['href', 'target', 'rel', 'style'];
+ const out = [];
+ document.querySelectorAll('.home-checklist .checklist-a a').forEach((a) => {
+ const names = Array.from(a.attributes).map(x => x.name.toLowerCase());
+ const unexpected = names.filter(n => !allowed.includes(n));
+ if (unexpected.length) out.push(a.outerHTML.slice(0, 160) + ' -> ' + unexpected.join(','));
+ });
+ return out;
+ });
+ assert(extra.length === 0, 'unexpected attributes: ' + JSON.stringify(extra));
+ });
+
+ await step('no element the checklist rendered carries an inline event handler', async () => {
+ const handlers = await page.evaluate(() => {
+ const out = [];
+ document.querySelectorAll('.home-checklist, .home-checklist *').forEach((el) => {
+ Array.from(el.attributes || []).forEach((at) => {
+ if (/^on/i.test(at.name)) out.push(el.tagName + '[' + at.name + ']');
+ });
+ });
+ return out;
+ });
+ assert(handlers.length === 0, 'inline handlers: ' + JSON.stringify(handlers));
+ });
+
+ await step('quote-breakout destinations stay inert inside the href value', async () => {
+ const rows = await page.evaluate((dests) => dests.map((d, i) => {
+ const label = 'xss368-step-' + i;
+ const a = Array.from(document.querySelectorAll('.home-checklist .checklist-a a'))
+ .find(x => (x.textContent || '').trim() === label);
+ return { label, found: !!a, href: a ? a.getAttribute('href') : null, want: d };
+ }), BREAKOUT_DESTS);
+ for (const r of rows) {
+ assert(r.found, r.label + ' did not render (a schemeless destination stays a link)');
+ // The whole payload is still exactly one attribute VALUE: nothing
+ // escaped into attribute position.
+ assert(r.href === r.want, r.label + ' href is ' + JSON.stringify(r.href) +
+ ', want ' + JSON.stringify(r.want));
+ }
+ });
+
+ console.log('\n=== #368 — safe Home Markdown links still work ===');
+
+ await step('every safe destination still renders as an anchor with the given href', async () => {
+ const rows = await page.evaluate((args) => args.dests.map((d, i) => {
+ const label = 'xss368-step-' + (args.offset + i);
+ const a = Array.from(document.querySelectorAll('.home-checklist .checklist-a a'))
+ .find(x => (x.textContent || '').trim() === label);
+ return {
+ label,
+ found: !!a,
+ href: a ? a.getAttribute('href') : null,
+ target: a ? a.getAttribute('target') : null,
+ rel: a ? a.getAttribute('rel') : null,
+ want: d,
+ };
+ }), { dests: SAFE_DESTS, offset: BREAKOUT_DESTS.length });
+ for (const r of rows) {
+ assert(r.found, r.label + ' did not render as a link');
+ assert(r.href === r.want, r.label + ' href is ' + r.href + ', want ' + r.want);
+ assert(r.target === '_blank', r.label + ' lost target="_blank"');
+ assert(r.rel === 'noopener', r.label + ' lost rel="noopener"');
+ }
+ });
+
+ await step('a safe hash link still navigates', async () => {
+ const label = 'xss368-step-' + (STEP_DESTS.length - 1); // the '#/map' one
+ await page.evaluate((l) => {
+ const a = Array.from(document.querySelectorAll('.home-checklist .checklist-a 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 });
+ assert((await page.evaluate(() => location.hash)) === '#/map', 'hash link did not navigate');
+ });
+
+ 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); });