diff --git a/scripts/bundle-size-budget.json b/scripts/bundle-size-budget.json index dc223e4..7d0c5ff 100644 --- a/scripts/bundle-size-budget.json +++ b/scripts/bundle-size-budget.json @@ -13,7 +13,7 @@ "entries": { ".": { "file": "dist/index.js", - "gzipBudget": 37000 + "gzipBudget": 39390 }, "./inline/emoji": { "file": "dist/emoji-shortcodes.js", diff --git a/src/config.ts b/src/config.ts index 70c5df5..3940385 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,7 @@ import type { HtmlPolicy } from './html-policy.ts' import type { SanitizeExtension, SanitizerBackend } from './sanitize.ts' import type { LinkImagePolicy } from './link-image-policy.ts' +import type { UrlPolicy } from './url-policy.ts' import type { TrustedTypesPolicy } from './html-sink.ts' import type { LinkDecorator } from './inline-links.ts' import type { FenceHandler } from './fence-handlers.ts' @@ -67,6 +68,17 @@ export interface MarkdownConfig { sanitizeExtension?: SanitizeExtension | null /** Opt-in link/image origin allowlist; `null` disables it (unrestricted). See link-image-policy.ts. */ linkImagePolicy?: LinkImagePolicy | null + /** + * Opt-in host gate consulted for **every** URL this package emits — markdown + * links/images/autolinks, raw-HTML passthrough destinations, and the URLs + * inside diagram/math markup that bypasses the sink sanitizer. `null` (the + * default) disables it and every URL is emitted unchanged. + * + * Modelled on the `TrustedURL` type Trusted Types dropped (w3c/trusted-types#65): + * the scheme allowlist stays a floor a policy cannot lift, and everything above + * it is the host's. See url-policy.ts. + */ + urlPolicy?: UrlPolicy | null /** Trusted Types policy used to bless sink output; `null` uses the default. See html-sink.ts. */ trustedTypesPolicy?: TrustedTypesPolicy | null /** diff --git a/src/index.test.ts b/src/index.test.ts index 8d776b0..8f4e3fb 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -28,6 +28,9 @@ const EXPECTED_FUNCTIONS = [ 'hydratePendingDiagrams', 'hydratePendingMath', 'renderAnchor', + // PROTOTYPE (#url-policy): lets a host run the URL policy over post-sink + // markup by hand, through `transformSvg` / `transformHtml`. + 'filterMarkupUrlsString', ] as const describe('public API barrel (index.ts)', () => { diff --git a/src/index.ts b/src/index.ts index 00b9acd..428a9d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,16 @@ export { // sanitizer rather than replacing them. Scope it via // `MarkdownConfig.linkImagePolicy`. See docs/EXTENDING.md. export { type LinkImagePolicy } from './link-image-policy.ts' +// PROTOTYPE (#url-policy): the TrustedURL-shaped gate every emitted URL passes +// through — markdown links/images/autolinks, raw-HTML passthrough destinations, +// and the URLs inside diagram/math markup that bypasses the sink sanitizer. +export { + type UrlPolicy, + type UrlRequest, + type UrlSink, + type UrlSource, +} from './url-policy.ts' +export { filterMarkupUrlsString } from './url-filter-markup.ts' // Trusted Types support: every internal `innerHTML` write routes through the // html-sink chokepoint, which sanitizes and then blesses the markup with a TT // policy when one is active (a lazily created `streaming-markdown` policy by diff --git a/src/inline-autolinks.ts b/src/inline-autolinks.ts index 9162f40..a4335bb 100644 --- a/src/inline-autolinks.ts +++ b/src/inline-autolinks.ts @@ -2,6 +2,7 @@ import { escapeHtml } from './escape.ts' import { INLINE_HTML_SHIELD_RE } from './inline-emphasis.ts' import { isAllowedHref } from './inline-links.ts' import { encodeHrefForOutput } from './link-references.ts' +import { applyUrlPolicy, urlPolicyMarkerAttr } from './url-policy.ts' /** * CommonMark URI autolink: ``. Scheme is an ASCII letter followed by @@ -37,7 +38,9 @@ function autolinkHref(raw: string): string | null { function renderedAutolink(label: string, href: string): string { // Label stays raw so the outer `escapeHtmlTextNodes` pass escapes it exactly // once; pre-escaping here would double-encode `&` in URLs (`&`, #595). - return `${label}` + const decided = applyUrlPolicy(href, 'navigation', 'markdown', 'a', 'href') + const hrefAttr = decided === null ? '' : ` href="${escapeHtml(decided)}"` + return `${label}` } /** diff --git a/src/inline-links.ts b/src/inline-links.ts index e4eb7fd..886fe82 100644 --- a/src/inline-links.ts +++ b/src/inline-links.ts @@ -1,5 +1,19 @@ import { decodeEscapedPunctuationRaw } from './backslash-escapes.ts' import { activeConfig } from './config.ts' +import { + applyUrlPolicy, + DEFAULT_SAFE_HREF_SCHEMES, + getSafeHrefSchemes, + isAllowedHref, + urlPolicyMarkerAttr, + withUrlPolicySuppressed, +} from './url-policy.ts' + +// The scheme allowlist moved to url-policy.ts so the floor can be enforced on +// every URL path — including the ones that never reach this module (post-sink +// diagram/math markup) and the value a host policy hands back. Re-exported here +// because this is where the package's surface has always named it. +export { DEFAULT_SAFE_HREF_SCHEMES, getSafeHrefSchemes, isAllowedHref } import { decodeEscapedHref, escapeHtml } from './escape.ts' import { isWorkspaceMarkdownLinkHref } from './workspace-link-href.ts' import { @@ -57,65 +71,18 @@ function lookupWithRenderedLabels( } /** - * Default URL schemes permitted on a link/image destination. Anything carrying - * a scheme outside the active set — `javascript:`, `data:`, `vbscript:`, - * `file:`, and every unknown scheme — is rejected. An allowlist fails *closed*: - * a new dangerous scheme is blocked by default, unlike a denylist that only - * knows the three it was told about. Relative/absolute paths, fragments, and - * query-only destinations carry no scheme and are always allowed. - */ -export const DEFAULT_SAFE_HREF_SCHEMES: readonly string[] = [ - 'http', - 'https', - 'mailto', - 'tel', - 'sms', - 'ftp', - 'ftps', -] - -const HREF_SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):/ - -const DEFAULT_SAFE_HREF_SCHEMES_SET: ReadonlySet = new Set(DEFAULT_SAFE_HREF_SCHEMES) - -// `config.safeHrefSchemes` is an arbitrary iterable of possibly-mixed-case scheme -// names; resolve it to a lowercased Set once per distinct config value (the read -// runs per link/image destination). -let cachedSchemesSource: Iterable | null | undefined -let cachedSchemes: ReadonlySet = DEFAULT_SAFE_HREF_SCHEMES_SET -function activeSafeHrefSchemes(): ReadonlySet { - const source = activeConfig().safeHrefSchemes - if (source == null) return DEFAULT_SAFE_HREF_SCHEMES_SET - if (source !== cachedSchemesSource) { - cachedSchemesSource = source - cachedSchemes = new Set(Array.from(source, (scheme) => scheme.toLowerCase())) - } - return cachedSchemes -} - -/** - * The scheme allowlist currently enforced by {@link safeLinkHref}. + * Allowed link destinations: http(s), mailto, and relative/path forms. Rejects + * dangerous schemes. * - * @internal Introspection getter that reads the ambient render config; outside - * a render it returns {@link DEFAULT_SAFE_HREF_SCHEMES}. Not part of the stable v1 - * surface (#147) — scope behaviour via `MarkdownConfig.safeHrefSchemes` instead - * (the default constant stays stable). Not exported from the package entry since 1.0. + * This is a *parse* gate — whether the construct renders as a link at all — and + * deliberately does NOT consult the host {@link UrlPolicy}. `linkOrImageEndAt` + * calls it to probe link boundaries for the emphasis pass and throws the result + * away, so a policy consulted here would see every link twice and would make a + * host's decision change how emphasis pairs. The policy applies at the emitters + * instead ({@link renderAnchor} / `renderedImage`), where a blocked destination + * drops the attribute and keeps the element — the same neutralization the sink's + * origin policy performs. */ -export function getSafeHrefSchemes(): string[] { - return [...activeSafeHrefSchemes()] -} - -/** - * True when `href` is a relative destination or carries an allowlisted scheme. - * Exported so angle autolinks share the exact allowlist markdown links use - * (#139) — autolink destinations are verbatim (no escapes to decode first). - */ -export function isAllowedHref(href: string): boolean { - const scheme = HREF_SCHEME_RE.exec(href)?.[1] - return scheme === undefined || activeSafeHrefSchemes().has(scheme.toLowerCase()) -} - -/** Allowed link destinations: http(s), mailto, and relative/path forms. Rejects dangerous schemes. */ export function safeLinkHref(raw: string): string | null { // Resolve to the exact string the browser will act on *before* validating: // undo source HTML-escaping and PUA-escaped punctuation, then decode HTML @@ -184,7 +151,12 @@ export function renderAnchor(label: string, href: string, title?: string): strin } const decorator = activeConfig().linkDecorator ?? neutralLinkDecorator const attrs = decorator(decoration) - return `${label}` + // A blocked destination leaves the anchor in place without an `href`: the + // label stays readable and nothing is navigable, matching how the sink's + // origin policy neutralizes an off-origin link. + const decided = applyUrlPolicy(href, 'navigation', 'markdown', 'a', 'href') + const hrefAttr = decided === null ? '' : ` href="${escapeHtml(decided)}"` + return `${label}` } function renderedLink(label: string, href: string, title?: string): string { @@ -207,7 +179,10 @@ function imageAltText(renderedLabel: string): string { function renderedImage(alt: string, src: string, title?: string): string { const titleAttr = title ? ` title="${escapeHtml(title)}"` : '' - return `${escapeHtml(imageAltText(alt))}` + // Blocked: drop the `src` so nothing loads and the `alt` still shows. + const decided = applyUrlPolicy(src, 'image', 'markdown', 'img', 'src') + const srcAttr = decided === null ? '' : ` src="${escapeHtml(decided)}"` + return `` } function renderLinkLabel( @@ -265,7 +240,11 @@ export function linkOrImageEndAt( start: number, refs: LinkReferenceMap = new Map(), ): number | null { - return tryParseLinkOrImage(text, start, refs, (label) => label)?.end ?? null + // The rendered HTML is discarded here — only the offset matters — so the host + // URL policy must not see these destinations (url-policy.ts). + return withUrlPolicySuppressed( + () => tryParseLinkOrImage(text, start, refs, (label) => label)?.end ?? null, + ) } /** diff --git a/src/math.ts b/src/math.ts index 4cf1855..4f4d8c3 100644 --- a/src/math.ts +++ b/src/math.ts @@ -1,4 +1,7 @@ import { setHostTrustedHtml, type TrustedHTMLValue } from './html-sink.ts' +import { injectFilteredMarkup } from './url-filter-markup.ts' +import { withConfig } from './config.ts' +import type { UrlPolicy } from './url-policy.ts' // The math-renderer registry (#70): the KaTeX analogue of the pluggable diagram // renderer. Like mermaid, the KaTeX *library* is never bundled by this package — @@ -65,6 +68,12 @@ export interface HydrateMathOptions { * will be rejected by the page's CSP. */ transformHtml?: (html: string) => string | TrustedHTMLValue + /** + * {@link UrlPolicy} to enforce over the backend's HTML before it is injected. + * Passed explicitly for the same reason as {@link HydrateDiagramsOptions.urlPolicy}: + * hydration runs after the synchronous render scope has been restored. + */ + urlPolicy?: UrlPolicy | null } /** Read a pending element's TeX source (block scaffolding wraps it in `pre.math`). */ @@ -116,7 +125,13 @@ export async function hydratePendingMath( // the escaped-source error state; the sink throws before mutating, so the // inert source stays visible. try { - setHostTrustedHtml(el, options.transformHtml ? options.transformHtml(html) : html) + const markup = options.transformHtml ? options.transformHtml(html) : html + const inject = (): void => { + // Filtered node path first — see the note in mermaid.ts markRendered. + if (!injectFilteredMarkup(el, markup, 'math')) setHostTrustedHtml(el, markup) + } + if (options.urlPolicy === undefined) inject() + else withConfig({ urlPolicy: options.urlPolicy }, inject) } catch { markError(el, kind) continue diff --git a/src/mermaid.ts b/src/mermaid.ts index 79c6c36..8359d29 100644 --- a/src/mermaid.ts +++ b/src/mermaid.ts @@ -1,5 +1,8 @@ import { mermaidSourceCandidates } from './mermaid-source.ts' import { setHostTrustedHtml, type TrustedHTMLValue } from './html-sink.ts' +import { injectFilteredMarkup } from './url-filter-markup.ts' +import { withConfig } from './config.ts' +import type { UrlPolicy } from './url-policy.ts' // PROTOTYPE (#lazy-load): the diagram-renderer registry, the mermaid analogue of // the pluggable code highlighter. Unlike highlight.js, the mermaid *library* is @@ -58,6 +61,19 @@ export interface HydrateDiagramsOptions { * will be rejected by the page's CSP. */ transformSvg?: (svg: string) => string | TrustedHTMLValue + /** + * {@link UrlPolicy} to enforce over the backend's SVG before it is injected — + * the mermaid analogue of the sink's link/image gate, and the only thing that + * sees a `url()` in an injected `', + '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 new file mode 100644 index 0000000..639eaee --- /dev/null +++ b/src/url-policy.test.ts @@ -0,0 +1,497 @@ +// PROTOTYPE (#url-policy). jsdom because the sink gate, the post-sink markup +// filter and the mermaid hydration path all run over real parsed elements. +import '../tests/setup-dom-jsdom.ts' +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { withConfig } from './config.ts' +import { renderMarkdownUnsafe } from './renderer.ts' +import { sanitizeRenderedMarkdown } from './sanitize.ts' +import { filterMarkupUrlsString } from './url-filter-markup.ts' +import { hydratePendingDiagrams, type DiagramRenderer } from './mermaid.ts' +import { hydratePendingMath, type MathRenderer } from './math.ts' +import type { UrlPolicy, UrlRequest } from './url-policy.ts' + +const APP = 'https://app.example.com' + +/** A policy that records every request and answers with `decide`. */ +function recordingPolicy(decide: (r: UrlRequest) => string | null): { + policy: UrlPolicy + seen: UrlRequest[] +} { + const seen: UrlRequest[] = [] + return { + seen, + policy: { + baseOrigin: APP, + createURL(request) { + seen.push(request) + return decide(request) + }, + }, + } +} + +/** Block anything the browser would fetch unattended; allow navigation anywhere. */ +const blockSubresources = (r: UrlRequest): string | null => + r.sink === 'navigation' ? (r.url?.href ?? r.raw) : null + +const IMG_EXTENSION = { allowedTags: ['img'], allowedAttr: ['src', 'alt'] } + +describe('urlPolicy — off by default', () => { + it('leaves rendered output byte-identical and stamps no marker', () => { + const md = '[docs](https://docs.example.com/p) ![x](https://cdn.example.com/a.png) ' + const html = renderMarkdownUnsafe(md) + assert.doesNotMatch(html, /data-smd-url-checked/) + assert.match(html, /href="https:\/\/docs\.example\.com\/p"/) + assert.match(html, /src="https:\/\/cdn\.example\.com\/a\.png"/) + }) +}) + +describe('urlPolicy — the markdown surface', () => { + it('sees links, images and autolinks with the right sink', () => { + const { policy, seen } = recordingPolicy((r) => r.raw) + withConfig({ urlPolicy: policy }, () => + renderMarkdownUnsafe( + '[a](https://one.example/) ![b](https://two.example/i.png) and https://four.example/', + ), + ) + // Sorted: autolinks are linkified in an earlier pass than markdown links, and + // that pass order is an implementation detail, not part of the contract. + const bySink = seen.map((r) => `${r.sink}:${r.raw}`).sort() + assert.deepEqual(bySink, [ + 'image:https://two.example/i.png', + 'navigation:https://four.example/', + 'navigation:https://one.example/', + 'navigation:https://three.example/', + ]) + assert.equal(seen.length, 4, 'each destination is presented exactly once') + }) + + it('neutralizes a blocked image but keeps its alt, and leaves the link beside it', () => { + const { policy } = recordingPolicy(blockSubresources) + const html = withConfig({ urlPolicy: policy }, () => + renderMarkdownUnsafe('[keep](https://one.example/) ![drop](https://two.example/i.png)'), + ) + assert.match(html, /href="https:\/\/one\.example\/"/) + assert.match(html, /drop { + const { policy } = recordingPolicy((r) => + r.sink === 'image' ? `${APP}/proxy?u=${encodeURIComponent(r.raw)}` : r.raw, + ) + const html = withConfig({ urlPolicy: policy, sanitizeExtension: IMG_EXTENSION }, () => + sanitizeRenderedMarkdown(renderMarkdownUnsafe('![x](https://cdn.example.com/a.png)')), + ) + assert.match(html, /src="https:\/\/app\.example\.com\/proxy\?u=https%3A%2F%2Fcdn\.example\.com%2Fa\.png"/) + }) + + it('hands the policy a canonical URL, so credential tricks cannot fool a prefix test', () => { + const { policy, seen } = recordingPolicy((r) => r.raw) + withConfig({ urlPolicy: policy }, () => + renderMarkdownUnsafe('[x](https://good.example.com@evil.example/path)'), + ) + assert.equal(seen[0]?.url?.origin, 'https://evil.example') + assert.doesNotMatch(seen[0]?.url?.href ?? '', /good\.example\.com/) + }) + + it('resolves a relative destination against baseOrigin', () => { + const { policy, seen } = recordingPolicy((r) => r.raw) + withConfig({ urlPolicy: policy }, () => renderMarkdownUnsafe('[x](/docs/page)')) + assert.equal(seen[0]?.url?.href, `${APP}/docs/page`) + }) +}) + +describe('urlPolicy — the scheme allowlist stays a floor', () => { + it('never presents a javascript: destination to the policy', () => { + const { policy, seen } = recordingPolicy((r) => r.raw) + const html = withConfig({ urlPolicy: policy }, () => + renderMarkdownUnsafe('[x](javascript:alert(1)) [y](https://ok.example/)'), + ) + assert.deepEqual( + seen.map((r) => r.raw), + ['https://ok.example/'], + ) + // The rejected destination stays literal text (no link is built at all), so + // the assertion is about live attributes, not the substring. + assert.doesNotMatch(html, /href="javascript:/) + assert.match(html, /\[x\]\(javascript:alert\(1\)\)/, 'left as literal text') + }) + + it('cannot be talked into a dangerous scheme by a policy that returns one', () => { + // The policy tries to swap a safe destination for a scriptable one. This has + // to fail on the UNSANITIZED path too, or the guarantee is really just the + // sink's, and post-sink diagram markup would have no floor at all. + const policy: UrlPolicy = { createURL: () => 'javascript:alert(1)' } + const unsafe = withConfig({ urlPolicy: policy }, () => + renderMarkdownUnsafe('[x](https://ok.example/)'), + ) + assert.doesNotMatch(unsafe, /javascript:/) + assert.match(unsafe, /]*>x<\/a>/, 'anchor kept, destination dropped') + + const svg = withConfig({ urlPolicy: policy }, () => + filterMarkupUrlsString('x', 'diagram'), + ) + assert.doesNotMatch(svg, /javascript:/) + }) +}) + +describe('urlPolicy — raw HTML passthrough', () => { + it('gates a destination that never met the inline emitters', () => { + const { policy, seen } = recordingPolicy(blockSubresources) + const html = withConfig({ urlPolicy: policy }, () => + sanitizeRenderedMarkdown(renderMarkdownUnsafe('x')), + ) + assert.deepEqual( + seen.map((r) => r.raw), + ['https://raw.example/?leak=1'], + ) + assert.match(html, /]*>x<\/a>/) + }) + + it('does not present a renderer-emitted destination twice', () => { + const { policy, seen } = recordingPolicy((r) => r.raw) + withConfig({ urlPolicy: policy }, () => + sanitizeRenderedMarkdown(renderMarkdownUnsafe('[x](https://one.example/)')), + ) + assert.equal(seen.length, 1) + }) + + it('never leaks the internal marker into sanitized output', () => { + const { policy } = recordingPolicy((r) => r.raw) + const html = withConfig({ urlPolicy: policy }, () => + sanitizeRenderedMarkdown(renderMarkdownUnsafe('[x](https://one.example/) ')), + ) + assert.doesNotMatch(html, /data-smd-url-checked/) + }) +}) + +describe('urlPolicy — post-sink markup (mermaid SVG shape)', () => { + // The real shapes mermaid 11 emits: an HTML label inside , an + // injected themeCSS ' + + '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"/) + }) +}) + +describe('urlPolicy — CSS filtering without a CSS parser', () => { + // The no-CSSStyleSheet fallback: an SSR shim with a DOM but no CSSOM. Its + // output can still end up in a browser, so each author syntax is scanned + // directly. Exercised by removing the global for the duration of the call. + const withoutCssom = (fn: () => T): T => { + const globals = globalThis as { CSSStyleSheet?: unknown } + const saved = globals.CSSStyleSheet + delete globals.CSSStyleSheet + try { + return fn() + } finally { + if (saved !== undefined) globals.CSSStyleSheet = saved + } + } + + const filterCss = (css: string, decide: (r: UrlRequest) => string | null): string => { + const { policy } = recordingPolicy(decide) + return withoutCssom(() => + withConfig({ urlPolicy: policy }, () => + filterMarkupUrlsString(``, '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/src/url-policy.ts b/src/url-policy.ts new file mode 100644 index 0000000..adf6383 --- /dev/null +++ b/src/url-policy.ts @@ -0,0 +1,283 @@ +import { activeConfig } from './config.ts' + +// PROTOTYPE (#url-policy): one host-controlled gate every URL the renderer is +// about to emit passes through — modelled on the `TrustedURL` type Trusted Types +// dropped in w3c/trusted-types#65. +// +// TrustedURL died for two reasons: enforcing it across the platform's unbounded +// URL sink surface broke too much ("linking to other content is common in the +// web"), and the residual non-XSS risks — off-site navigation, third-party +// subresource loads, stylesheet-based exfiltration — were delegated to CSP's +// `*-src` directives. Neither objection transfers to a markdown renderer: +// +// - the sink surface here is small and enumerable, because this package emits +// it. Every URL-bearing position routes through `applyUrlPolicy`, so +// "the policy sees every URL" is a property the package can actually hold. +// - a library cannot set the host's CSP, so the delegation target does not +// exist at this layer. That is exactly why mermaid's post-sink SVG can +// exfiltrate today (`` inside ``, `url()` in an injected +// `