Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/bundle-size-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"entries": {
".": {
"file": "dist/index.js",
"gzipBudget": 37000
"gzipBudget": 39390
},
"./inline/emoji": {
"file": "dist/emoji-shortcodes.js",
Expand Down
12 changes: 12 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
/**
Expand Down
3 changes: 3 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/inline-autolinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:...>`. Scheme is an ASCII letter followed by
Expand Down Expand Up @@ -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 (`&amp;amp;`, #595).
return `<a href="${escapeHtml(href)}">${label}</a>`
const decided = applyUrlPolicy(href, 'navigation', 'markdown', 'a', 'href')
const hrefAttr = decided === null ? '' : ` href="${escapeHtml(decided)}"`
return `<a${hrefAttr}${urlPolicyMarkerAttr()}>${label}</a>`
}

/**
Expand Down
99 changes: 39 additions & 60 deletions src/inline-links.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<string> = 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<string> | null | undefined
let cachedSchemes: ReadonlySet<string> = DEFAULT_SAFE_HREF_SCHEMES_SET
function activeSafeHrefSchemes(): ReadonlySet<string> {
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
Expand Down Expand Up @@ -184,7 +151,12 @@ export function renderAnchor(label: string, href: string, title?: string): strin
}
const decorator = activeConfig().linkDecorator ?? neutralLinkDecorator
const attrs = decorator(decoration)
return `<a href="${escapeHtml(href)}"${attrs}>${label}</a>`
// 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 `<a${hrefAttr}${attrs}${urlPolicyMarkerAttr()}>${label}</a>`
}

function renderedLink(label: string, href: string, title?: string): string {
Expand All @@ -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 `<img src="${escapeHtml(src)}" alt="${escapeHtml(imageAltText(alt))}"${titleAttr} data-md-rendered="1" />`
// 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 `<img${srcAttr} alt="${escapeHtml(imageAltText(alt))}"${titleAttr} data-md-rendered="1"${urlPolicyMarkerAttr()} />`
}

function renderLinkLabel(
Expand Down Expand Up @@ -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,
)
}

/**
Expand Down
17 changes: 16 additions & 1 deletion src/math.ts
Original file line number Diff line number Diff line change
@@ -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 —
Expand Down Expand Up @@ -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`). */
Expand Down Expand Up @@ -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
Expand Down
35 changes: 33 additions & 2 deletions src/mermaid.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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>` or an `<img>` inside a
* `<foreignObject>`.
*
* Passed explicitly rather than read from the ambient render config because
* hydration runs *after* the synchronous render scope has been restored
* (config.ts). `StreamingMarkdownRenderer.hydrate()` forwards its own
* `urlPolicy` here; a direct caller that installed one process-wide via
* `setDefaultConfig` need not pass it.
*/
urlPolicy?: UrlPolicy | null
}

/** Read a pending container's diagram source from its `<pre class="mermaid">`. */
Expand All @@ -67,9 +83,22 @@ function readDiagramSource(container: Element): string {
}

function markRendered(container: Element, svg: string | TrustedHTMLValue): void {
// Inject BEFORE flipping the state classes: both sinks here can throw (Trusted
// Types rejecting a plain-string SVG, or rejecting the filter's own parse), and
// the caller then calls markError. Setting `--rendered` first left a container
// carrying `--rendered` AND `--error` at once, with conflicting styling.
//
// Filtered node path first: it decides every URL *before* anything is in a
// live document, so an off-origin subresource never gets to start its fetch.
if (!injectFilteredMarkup(container, svg, 'diagram')) setHostTrustedHtml(container, svg)
container.classList.remove('mermaid-diagram--pending')
container.classList.add('mermaid-diagram--rendered')
setHostTrustedHtml(container, svg)
}

/** Run one injection under an explicitly supplied policy, when the caller passed one. */
function withUrlPolicy(urlPolicy: UrlPolicy | null | undefined, inject: () => void): void {
if (urlPolicy === undefined) inject()
else withConfig({ urlPolicy }, inject)
}

function markError(container: Element): void {
Expand Down Expand Up @@ -116,7 +145,9 @@ export async function hydratePendingDiagrams(
// TrustedHTML — so fail the diagram without re-rendering every
// candidate just to hit the same sink error.
try {
markRendered(container, options.transformSvg ? options.transformSvg(svg) : svg)
withUrlPolicy(options.urlPolicy, () => {
markRendered(container, options.transformSvg ? options.transformSvg(svg) : svg)
})
ok = true
rendered++
} catch {
Expand Down
Loading