web/elements/ak-mdx: sanitize replacer output, note pipeline drift

Address PR review feedback on the URL-mode trust boundary. `<ak-mdx>`'s
`replacers` hook runs over pre-rendered build-time HTML before it is
stamped into the DOM, and consumers (e.g. `ProxyProviderViewPage`) splice
admin-controlled values such as `provider.externalHost` into it. The old
React pipeline ran replacers on raw markdown that was then compiled, so
those values were HTML-escaped on serialization; the new URL mode passed
the post-replacer HTML straight through, dropping that guarantee.

Replace the passthrough `CompiledMarkdownTrustPolicy` with
`CompiledMarkdownSanitizePolicy`: a DOMPurify policy that whitelists the
custom elements (`<ak-alert>`, `<ak-md-a>`, `<ak-diagram>`) and the
`part`/`level` attributes our pipeline emits, and strips anything else a
replacer could inject.

Also add a reciprocal drift note to the runtime `markdown.ts` pointing at
`bundler/mdx-plugin/`, mirroring the existing note on the bundler side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Teffen Ellis
2026-06-11 07:15:15 +02:00
parent ab7626e378
commit c025fdd703
3 changed files with 45 additions and 16 deletions
+23 -7
View File
@@ -51,14 +51,30 @@ export const SanitizedTrustPolicy = trustedTypes.createPolicy("authentik-sanitiz
});
/**
* Trusted types policy for HTML produced by our own build-time markdown
* pipeline. The HTML is generated from source we own (the `mdx-plugin`),
* including custom elements like `<ak-md-a>` and `<ak-alert>` that
* DOMPurify's default tag list would strip. Treat the input as already
* trusted and pass it through unmodified.
* Trusted types policy for HTML produced by our build-time markdown
* pipeline (`mdx-plugin`) and stamped into `<ak-mdx>` in URL mode.
*
* The compiled HTML originates from source we own, but `<ak-mdx>` exposes
* a `replacers` hook that lets consumers splice dynamic — and sometimes
* admin-controlled — values into it before render (e.g. a proxy
* provider's `externalHost` in `ProxyProviderViewPage`). Once a replacer
* has run, "we own every byte" no longer holds, so we re-sanitize here
* rather than passing the string through untouched.
*
* DOMPurify's default tag list would strip the custom elements our
* pipeline emits (`<ak-alert>`, `<ak-md-a>`, `<ak-diagram>`) along with
* the `part`/`level` attributes they rely on, so those are explicitly
* allowed. Everything else — script handlers, unknown elements, unsafe
* URLs injected via a replacer — is still removed.
*/
export const CompiledMarkdownTrustPolicy = trustedTypes.createPolicy("authentik-markdown", {
createHTML: (trustedHTML: string) => trustedHTML,
export const CompiledMarkdownSanitizePolicy = trustedTypes.createPolicy("authentik-markdown", {
createHTML: (untrustedHTML: string) => {
return DOMPurify.sanitize(untrustedHTML, {
RETURN_TRUSTED_TYPE: false,
ADD_TAGS: ["ak-alert", "ak-md-a", "ak-diagram"],
ADD_ATTR: ["part", "level"],
});
},
});
/**
+9 -6
View File
@@ -3,7 +3,7 @@ import "#elements/Diagram/ak-diagram";
import "#elements/ak-mdx/components/ak-md-a";
import { globalAK } from "#common/global";
import { BrandedHTMLPolicy, CompiledMarkdownTrustPolicy, sanitizeHTML } from "#common/purify";
import { BrandedHTMLPolicy, CompiledMarkdownSanitizePolicy, sanitizeHTML } from "#common/purify";
import { compileRuntimeMarkdown } from "#elements/ak-mdx/markdown";
import Styles from "#elements/ak-mdx/styles.css";
@@ -88,10 +88,13 @@ export class AKMDX extends AKElement {
}
/**
* URL mode: HTML comes from our build-time pipeline. It may contain
* custom-element tags (`<ak-alert>`, `<ak-md-a>`) that DOMPurify's
* default tag list would strip, so we route it through a Trusted
* Types policy that passes the input through unmodified.
* URL mode: HTML comes from our build-time pipeline. `replacers` may
* splice dynamic, sometimes admin-controlled, values into it (see
* `ProxyProviderViewPage`), so the post-replacer string is routed
* through {@linkcode CompiledMarkdownSanitizePolicy} — a DOMPurify
* policy that preserves the custom elements our pipeline emits
* (`<ak-alert>`, `<ak-md-a>`, `<ak-diagram>`) while stripping anything
* a replacer could have injected.
*/
async #hydrateFromURL(url: string): Promise<SlottedTemplateResult> {
const { relBase } = globalAK().api;
@@ -107,7 +110,7 @@ export class AKMDX extends AKElement {
this.dataset.publicDirectory = module.publicDirectory;
}
const trustedHTML = CompiledMarkdownTrustPolicy.createHTML(
const trustedHTML = CompiledMarkdownSanitizePolicy.createHTML(
this.#applyReplacers(module.content),
);
+13 -3
View File
@@ -14,9 +14,19 @@ import { unified } from "unified";
/**
* Compile an admin-supplied markdown string to an HTML string in the
* browser. The pipeline is a strict subset of the build-time one: no
* syntax highlighting, no anchor rewriting — the output is plain HTML
* that the existing `BrandedHTMLPolicy` (DOMPurify) sanitizes cleanly.
* browser. The pipeline is a strict subset of the build-time one in
* `bundler/mdx-plugin/compile.js`: no syntax highlighting, no anchor
* rewriting, no mermaid — the output is plain HTML that the existing
* `BrandedHTMLPolicy` (DOMPurify) sanitizes cleanly.
*
* The two pipelines share the remark transforms (admonitions, headings,
* lists) but deliberately diverge on the rehype side: this one stays
* minimal, whereas the build-time one emits custom elements
* (`<ak-alert>`, `<ak-md-a>`, `<ak-diagram>`) that are preserved by the
* URL-mode `CompiledMarkdownSanitizePolicy`. **If you add a transform
* that should apply to both surfaces, update `bundler/mdx-plugin/`
* too** — the shared remark plugins live in `#elements/ak-mdx/remark/*`
* and are mirrored there to keep drift easy to spot.
*
* Unlike `@mdx-js/mdx`'s `evaluate` / `run`, none of the `unified`,
* `remark-*`, or `rehype-*` packages execute the input as JavaScript: