Skip to content

feat(url-policy): TrustedURL-shaped host gate for every emitted URL (prototype) - #255

Draft
jonathanKingston wants to merge 3 commits into
mainfrom
claude/mermaid-tag-blocking-url-filter-e6c9f1
Draft

feat(url-policy): TrustedURL-shaped host gate for every emitted URL (prototype)#255
jonathanKingston wants to merge 3 commits into
mainfrom
claude/mermaid-tag-blocking-url-filter-e6c9f1

Conversation

@jonathanKingston

Copy link
Copy Markdown
Collaborator

Prototype of a MarkdownConfig.urlPolicy — one host-controlled gate consulted for every URL this package emits — modelled on the TrustedURL type Trusted Types dropped in w3c/trusted-types#65.

renderMarkdown(md, {
  urlPolicy: {
    baseOrigin: 'https://app.example.com',
    createURL: ({ url, sink }) =>
      sink === 'navigation'
        ? (url?.href ?? null)                                          // links: anywhere
        : url?.origin === 'https://app.example.com' ? url.href : null, // fetches: same-origin
  },
})

Why

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':

vector emitted? fetched with no interaction?
A["<img src='https://attacker/leak'>"]<img> inside <foreignObject> yes yes
%%{init: {"themeCSS": "… url(https://attacker/css) …"}}%%url() in the SVG's <style> yes yes
click A href "https://attacker/x"<a xlink:href> yes on click

Neither flowchart: { htmlLabels: false } nor mermaid's own dompurifyConfig stops 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 strips onerror and javascript: 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-block javascript: after parsing rather than ask developers to sanitize every href) 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:

  • inline emitters (renderAnchor / renderedImage / renderedAutolink) — covers the renderMarkdownUnsafe string path, which never reaches the sink
  • sink gate — raw-HTML passthrough destinations, which never met safeLinkHref
  • new post-sink walker (url-filter-markup.ts) — diagram/math markup: href, xlink:href, src, srcset, and url() in both <style> text and style attributes

The scheme allowlist (DEFAULT_SAFE_HREF_SCHEMES / isAllowedHref / getSafeHrefSchemes) moved from inline-links.ts to url-policy.ts so 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 new filterMarkupUrlsString.

Off by default — with no policy installed every call is one null check and output is byte-identical.

Design notes for review

  • UrlRequest.url is a canonicalized, credential-stripped URL, 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.
  • Same-document fragments pass unchecked. Every diagram carries url(#…-pointEnd) marker refs and <use href="#…">; 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 and discards — a policy there saw every URL twice and would have let a host decision change how emphasis pairs.
  • Blocked ⇒ attribute removed, element kept (dead <a> keeps its label, <img> keeps its alt), matching how link-image-policy.ts already neutralizes.
  • Post-sink markup is parsed in a browsing-context-free DOMParser document — 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 themeCSS url() 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

  • Bundle budget. Core went 36407 → 37506 gzip (+1099, ~3%), over the committed 37000. size:update deliberately not run — the alternative is moving the walker behind the /diagrams/* and /math/* subpaths so hosts without a policy don't pay for it.
  • Trusted Types. DOMParser.parseFromString is itself a TT sink (same reason DOMPurify ships its own policy), so the parse needs routing through the html-sink.ts chokepoint under enforcement. Flagged in the module header.
  • The marker attribute. Renderer-emitted links carry data-smd-url-checked so 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 / linkImagePolicy are not yet folded in as sugar over this primitive — deliberately out of scope until the shape is settled.
  • Docs (SECURITY.md trust-boundary section, EXTENDING.md) not yet updated; happy to write the ADR if the shape holds up.

Testing

npm test — 1244 pass, 0 fail. New src/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 via playwright-core. Typecheck clean.

🤖 Generated with Claude Code

Jonathan Kingston and others added 3 commits August 27, 2026 12:26
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant