From a5bf59a9b9d3491a2fcba41b6cc8e1f003314db9 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 27 Aug 2026 12:26:39 +0100 Subject: [PATCH 1/3] feat(url-policy): TrustedURL-shaped host gate for every emitted URL (prototype) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `MarkdownConfig.urlPolicy` a host installs once and that is consulted for every URL this package emits, modelled on the `TrustedURL` type Trusted Types dropped in w3c/trusted-types#65. `createURL(request)` returns the URL to use — which need not be the one it was given — or `null` to block it. Motivation: mermaid SVG and KaTeX HTML are injected *after* the sink sanitizer, so `linkImagePolicy` never sees them. Measured against real mermaid 11 in Chromium at its default `securityLevel: 'strict'`: an HTML label emits `` inside ``, and an injected `themeCSS` reaches the SVG's `' + + 'x' + + '' + + '' + + '
' + + '' + + '
' + + '' + + it('strips every automatic fetch, including through foreignObject and CSS', () => { + const { policy } = recordingPolicy(blockSubresources) + const out = withConfig({ urlPolicy: policy }, () => filterMarkupUrlsString(SVG, 'diagram')) + assert.doesNotMatch(out, /attacker\.example\/leak/, 'foreignObject src') + assert.doesNotMatch(out, /attacker\.example\/pixel/, 'SVG ') + assert.doesNotMatch(out, /attacker\.example\/css/, 'url() in `, + (r) => r.raw.replace('cdn.example', 'proxy.example'), + ) + assert.match(out, /url\("https:\/\/proxy\.example\/a\.png"\)/) + assert.match(out, /url\("https:\/\/proxy\.example\/b\.png"\)/) + }) + + it('preserves every URL when the policy returns each one unchanged', () => { + // Not byte-identical: the STRING path re-serializes (`` comes back as + // ``), which is why the node path is preferred for a DOM sink. + const markup = + '' + const out = filter(markup, (r) => r.raw) + assert.match(out, /href="https:\/\/cdn\.example\/a\.png"/) + assert.match(out, /url\(https:\/\/cdn\.example\/b\.png\)/) + }) + + it('skips an empty
t
', () => null) + assert.match(out, /class="x"/) + assert.match(out, /data-y="z"/) + }) +}) From e48dc811623858519e5b0f2869b87b1e8934e004 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 27 Aug 2026 17:20:38 +0100 Subject: [PATCH 3/3] fix(url-policy): close three unpoliced URL sinks, and guard against the next MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking "how do we guarantee we have every sink" broke the prototype three times. All three verified in real Chromium; the first two actually fetched. - `@import "https://…"` in a ', + 'style @import string': '', + 'style @import url()': '', + 'style image-set': '', + 'style -webkit-image-set': '', + 'style @font-face src': '', + 'style attribute': '', + 'srcset in foreignObject': '
', +} + +describe('urlPolicy completeness — post-sink markup', () => { + for (const [name, markup] of Object.entries(POST_SINK_CORPUS)) { + it(`lets no URL through: ${name}`, () => { + const out = withConfig({ urlPolicy: BLOCK_ALL }, () => + filterMarkupUrlsString(markup, 'diagram'), + ) + assert.deepEqual(survivingUrls(out), [], `unpoliced sink in "${name}": ${out}`) + }) + } + + it('keeps same-document fragment references, which are not a channel', () => { + const markup = '' + const out = withConfig({ urlPolicy: BLOCK_ALL }, () => filterMarkupUrlsString(markup, 'diagram')) + assert.match(out, /href="#node"/) + assert.match(out, /url\(#arrow\)/) + }) +}) diff --git a/src/url-policy.test.ts b/src/url-policy.test.ts index 00892fd..639eaee 100644 --- a/src/url-policy.test.ts +++ b/src/url-policy.test.ts @@ -195,7 +195,7 @@ describe('urlPolicy — post-sink markup (mermaid SVG shape)', () => { it('preserves same-document fragment refs, or every arrowhead disappears', () => { const { policy, seen } = recordingPolicy(blockSubresources) const out = withConfig({ urlPolicy: policy }, () => filterMarkupUrlsString(SVG, 'diagram')) - assert.match(out, /url\(#arrow-end\)/) + assert.match(out, /url\(["']?#arrow-end["']?\)/, 'CSS parser re-serializes; the ref survives') assert.match(out, /href="#node-shape"/) assert.equal( seen.some((r) => r.raw.startsWith('#')), @@ -246,6 +246,26 @@ describe('urlPolicy — mermaid hydration end to end', () => { assert.equal(host.querySelector('use')?.getAttribute('href'), '#marker') }) + it('does not leave a container in both the rendered and error states', async () => { + // Both injection sinks can throw (Trusted Types rejects the filter's own + // DOMParser call under enforcement), and the caller then marks the diagram + // errored — the two state classes must never coexist. + const throwing: DiagramRenderer = { render: () => Promise.resolve({ svg: EVIL_SVG }) } + const host = pendingHost() + const exploding: UrlPolicy = { + createURL() { + throw new Error('policy blew up mid-injection') + }, + } + const count = await hydratePendingDiagrams(host, { renderer: throwing, urlPolicy: exploding }) + + assert.equal(count, 0) + const container = host.querySelector('.mermaid-diagram') + assert.equal(container?.classList.contains('mermaid-diagram--error'), true) + assert.equal(container?.classList.contains('mermaid-diagram--rendered'), false) + assert.ok(host.querySelector('pre.mermaid'), 'inert source stays visible') + }) + it('leaves the SVG untouched when no policy is supplied', async () => { const host = pendingHost() await hydratePendingDiagrams(host, { renderer: stub }) @@ -365,7 +385,7 @@ describe('urlPolicy — filter edge cases', () => { '' const out = filter(markup, (r) => r.raw) assert.match(out, /href="https:\/\/cdn\.example\/a\.png"/) - assert.match(out, /url\(https:\/\/cdn\.example\/b\.png\)/) + assert.match(out, /url\(["']?https:\/\/cdn\.example\/b\.png["']?\)/) }) it('skips an empty `, 'diagram'), + ), + ) + } + + it('catches @import in the string form, which url() matching misses', () => { + const out = filterCss('@import "https://attacker.example/a";', () => null) + assert.doesNotMatch(out, /attacker\.example/) + }) + + it('catches @import in the url() form', () => { + const out = filterCss('@import url(https://attacker.example/b);', () => null) + assert.doesNotMatch(out, /attacker\.example/) + }) + + it('catches the bare strings image-set accepts, in both spellings', () => { + const out = filterCss( + '.a{background-image:image-set("https://attacker.example/c" 1x)}' + + '.b{background-image:-webkit-image-set("https://attacker.example/d" 2x)}', + () => null, + ) + assert.doesNotMatch(out, /attacker\.example/) + }) + + it('still honours a rewrite rather than only blocking', () => { + const out = filterCss('@import "https://cdn.example/e";', () => 'https://proxy.example/e') + assert.match(out, /proxy\.example/) + assert.doesNotMatch(out, /cdn\.example/) + }) + + it('leaves same-document fragment refs alone', () => { + const out = filterCss('.a{marker-end:url(#arrow)}', () => null) + assert.match(out, /url\(["']?#arrow["']?\)/) + }) +}) + +describe('urlPolicy — CSS filtering through the CSS parser', () => { + const filterCss = (css: string, decide: (r: UrlRequest) => string | null): string => { + const { policy } = recordingPolicy(decide) + return withConfig({ urlPolicy: policy }, () => + filterMarkupUrlsString(``, 'diagram'), + ) + } + + it('rewrites an @import rather than only blocking it', () => { + const out = filterCss('@import "https://cdn.example/a";', (r) => + r.attribute === '@import' ? 'https://proxy.example/a' : r.raw, + ) + assert.doesNotMatch(out, /cdn\.example/) + // Chromium drops @import from a constructible sheet outright (per the CSSOM + // spec) so nothing is left to rewrite; jsdom keeps the rule, and there the + // rewritten href must be what lands. + if (/@import/.test(out)) assert.match(out, /proxy\.example/) + }) + + it('presents @import to the policy with a style sink', () => { + const { policy, seen } = recordingPolicy(() => null) + withConfig({ urlPolicy: policy }, () => + filterMarkupUrlsString('', 'diagram'), + ) + // Skipped entirely on an engine that drops @import before we ever see it. + if (seen.length > 0) { + assert.equal(seen[0]?.sink, 'style') + assert.equal(seen[0]?.attribute, '@import') + } + }) + + it('falls back rather than throwing when the CSS parser rejects outright', () => { + const globals = globalThis as { CSSStyleSheet?: unknown } + const saved = globals.CSSStyleSheet + globals.CSSStyleSheet = function Broken() { + throw new Error('no constructible stylesheets here') + } + try { + const out = filterCss('.a{background:url(https://attacker.example/z)}', () => null) + assert.doesNotMatch(out, /attacker\.example/, 'the fallback still filters') + } finally { + globals.CSSStyleSheet = saved + } + }) +}) diff --git a/tests/setup-dom-jsdom.ts b/tests/setup-dom-jsdom.ts index 7899c64..9f8f392 100644 --- a/tests/setup-dom-jsdom.ts +++ b/tests/setup-dom-jsdom.ts @@ -19,6 +19,13 @@ Object.assign(globalThis, { // Needed by the post-sink URL filter, which parses markup in an inert // (browsing-context-free) document before deciding what may be injected. DOMParser: win.DOMParser, + // The same filter parses CSS with a constructible stylesheet. jsdom supports + // them, but only exposes the constructor on its window — without this the + // filter silently takes its no-CSSOM fallback and the real path is never + // tested. (jsdom does NOT build CSSOM for DOMParser/createHTMLDocument + // documents the way browsers do, which is why the filter does not rely on + // `style.sheet`.) + CSSStyleSheet: win.CSSStyleSheet, }) // jsdom has no native Sanitizer API (`Element.setHTML`), so the default backend