From 617d2e6d7603ff35b7f1b93ea88495c11f2867bd Mon Sep 17 00:00:00 2001 From: sec-check <280983584+hivecommons-hive[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:37:08 -0400 Subject: [PATCH] fix: scan the unwrapped form in the publish-time MDX active-content gate Docusaurus runs unwrapMdxCodeBlocks() on every page before the MDX compiler sees it, so the text it compiles is not the text on disk. findActiveContent() only asked about that rewrite per mdast code node, via isUnwrappedCodeBlock(), which slices the node's own source range. The two fence grammars disagree about which closer pairs with an opener. CommonMark closes a three-backtick fence with any run of three or more backticks, so mdast ends the node at a four-backtick line. Docusaurus's pattern requires a closer of exactly three and its body group is .*? under s with no m, so it skips that line and pairs the opener with a later bare fence. The payload then sits between the two closers: inside a code node the scan trusts, and outside the slice the per-node check examines. A page ending in an mdx-code-block fence holding a remote img element, a four-backtick line and a bare fence therefore scanned clean, passed validate:architectures, and built to HTML carrying the img plus a preload link for it. Scan unwrapMdxCodeBlocks(text) as well as text, mirroring hasActiveMdx() in mdx-escape.mjs, which already does this and was not affected. The unwrap only deletes the two fence lines, so the unwrapped document's lines are a subsequence of the source's and a greedy walk maps each finding back to its true source line, with no upstream pattern restated. A fence that is caught by both passes now reports what it hides as well as its own presence, which is why one existing expectation gains a second reason. Signed-off-by: sec-check <280983584+hivecommons-hive[bot]@users.noreply.github.com> Signed-off-by: Bob Killen Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/lib/mdx-active-content.mjs | 88 +++++++++++++++++++++---- scripts/lib/mdx-code-blocks.mjs | 15 ++++- tests/active-content-allowlist.test.mjs | 1 + tests/mdx-active-content.test.mjs | 49 ++++++++++++++ 4 files changed, 139 insertions(+), 14 deletions(-) diff --git a/scripts/lib/mdx-active-content.mjs b/scripts/lib/mdx-active-content.mjs index 17ccb7a9..ff0eb671 100644 --- a/scripts/lib/mdx-active-content.mjs +++ b/scripts/lib/mdx-active-content.mjs @@ -32,7 +32,9 @@ * A fenced code block is allowed because MDX evaluates nothing inside one -- * except for ```mdx-code-block, which Docusaurus unwraps into live MDX before * the compiler runs (see ./mdx-code-blocks.mjs). That fence is not a code - * block at all, so it is a finding. + * block at all, so it is a finding. Because that unwrap decides for itself + * which fences pair, the scan is run over its output as well as over the text + * on disk; see `findActiveContent()`. * * The scan answers the second of the two questions `uri-safety.mjs` states * both gates must answer -- does the value make a visitor's browser contact @@ -56,7 +58,10 @@ import { mdxFromMarkdown } from 'mdast-util-mdx'; import { gfm } from 'micromark-extension-gfm'; import { gfmFromMarkdown } from 'mdast-util-gfm'; import { eachNode } from './mdast-walk.mjs'; -import { isUnwrappedCodeBlock } from './mdx-code-blocks.mjs'; +import { + isUnwrappedCodeBlock, + unwrapMdxCodeBlocks, +} from './mdx-code-blocks.mjs'; import { describeTarget, disallowedScheme, @@ -183,18 +188,13 @@ function literalValue(expression) { } /** - * Scans a Markdown/MDX body for anything outside the allowlists, and for - * images loaded from another host. + * Scans one source text, reporting each finding at its line within that text. * - * @param {string} markdown - Document body to scan. - * @param {{ mdastExtensions?: Array }} [options] - Extra mdast - * extensions to parse with. A node kind they introduce is not on the - * allowlist, so it is reported rather than walked past. - * @returns {Array<{ line: number, reason: string, snippet: string }>} One entry - * per finding, ordered by line. + * @param {string} text - Document body to scan. + * @param {Array} mdastExtensions - Extra mdast extensions. + * @returns {Array<{ line: number, reason: string, snippet: string }>} */ -export function findActiveContent(markdown, { mdastExtensions = [] } = {}) { - const text = String(markdown ?? ''); +function scanSource(text, mdastExtensions) { const lines = text.split('\n'); const findings = []; const report = (line, reason) => @@ -320,6 +320,70 @@ export function findActiveContent(markdown, { mdastExtensions = [] } = {}) { } }); + return findings; +} + +/** + * Maps each line of `unwrapped` back to its line number in `text`. + * + * `unwrapMdxCodeBlocks()` keeps a block's body verbatim and deletes only the + * opening and closing fence lines, so every line of the unwrapped document is + * a line of the source, in order, and a greedy forward walk recovers the + * original line numbers without restating the upstream patterns. + * + * @param {string} text - Source document. + * @param {string} unwrapped - `unwrapMdxCodeBlocks(text)`. + * @returns {number[]} Source line number for each line of `unwrapped`. + */ +function mapUnwrappedLines(text, unwrapped) { + const sourceLines = text.split('\n'); + const mapping = []; + let cursor = 0; + for (const line of unwrapped.split('\n')) { + while (cursor < sourceLines.length && sourceLines[cursor] !== line) { + cursor += 1; + } + // Unreachable for a pure deletion; fall back to the last line rather than + // throw if the upstream rewrite ever changes shape. + mapping.push(Math.min(cursor + 1, sourceLines.length)); + cursor += 1; + } + return mapping; +} + +/** + * Scans a Markdown/MDX body for anything outside the allowlists, and for + * images loaded from another host. + * + * The body is scanned as written and again as `unwrapMdxCodeBlocks()` leaves + * it, because that rewritten text is what the MDX compiler is handed. The + * per-node `mdx-code-block` check only sees the span mdast gave the node, and + * the fence grammars disagree about where it ends: CommonMark closes a + * three-backtick fence with any run of three or more, while Docusaurus + * requires exactly three and pairs the opener with a later bare fence. A + * payload between those two closers sits inside a `code` node the scan trusts. + * Scanning the unwrapped text closes that gap for any such disagreement. This + * mirrors `hasActiveMdx()` in ./mdx-escape.mjs. + * + * @param {string} markdown - Document body to scan. + * @param {{ mdastExtensions?: Array }} [options] - Extra mdast + * extensions to parse with. A node kind they introduce is not on the + * allowlist, so it is reported rather than walked past. + * @returns {Array<{ line: number, reason: string, snippet: string }>} One entry + * per finding, ordered by line. + */ +export function findActiveContent(markdown, { mdastExtensions = [] } = {}) { + const text = String(markdown ?? ''); + const findings = scanSource(text, mdastExtensions); + + const unwrapped = unwrapMdxCodeBlocks(text); + if (unwrapped !== text) { + const sourceLine = mapUnwrappedLines(text, unwrapped); + for (const finding of scanSource(unwrapped, mdastExtensions)) { + findings.push({ ...finding, line: sourceLine[finding.line - 1] }); + } + } + const seen = new Set(); return findings .filter((finding) => { diff --git a/scripts/lib/mdx-code-blocks.mjs b/scripts/lib/mdx-code-blocks.mjs index 7b9bce5c..c7490ce6 100644 --- a/scripts/lib/mdx-code-blocks.mjs +++ b/scripts/lib/mdx-code-blocks.mjs @@ -32,8 +32,19 @@ import { unwrapMdxCodeBlocks } from '@docusaurus/utils'; * * The node's own source range is passed to `unwrapMdxCodeBlocks()`: the range * begins at the opening fence and ends at the closing one, which is exactly - * the `^`/`$` anchoring its patterns allow, so the answer for the slice is the - * answer for the block in the whole document. + * the `^`/`$` anchoring its patterns allow, so a block that reads as unwrapped + * on its own slice is reported at its own line. + * + * The converse does not hold, so `false` here is not a clearance. mdast ends + * the node at the closing fence *CommonMark* chooses -- any run of three or + * more backticks -- while the unwrap pattern requires a closer of exactly + * three and searches on with `.*?` under `s` when it meets a longer one. An + * opener whose CommonMark closer is four backticks therefore pairs, in the + * full document, with a later bare fence outside this slice entirely. Callers + * must not read `false` as "Docusaurus leaves this page alone": + * `findActiveContent()` (./mdx-active-content.mjs) and `hasActiveMdx()` + * (./mdx-escape.mjs) both also scan `unwrapMdxCodeBlocks()` output, for this + * reason. * * @param {string} source Full document text. * @param {{ position?: { start?: { offset?: number }, end?: { offset?: number } } }} node diff --git a/tests/active-content-allowlist.test.mjs b/tests/active-content-allowlist.test.mjs index 98c1762d..902f6292 100644 --- a/tests/active-content-allowlist.test.mjs +++ b/tests/active-content-allowlist.test.mjs @@ -356,6 +356,7 @@ const BYPASSES = [ ), [ 'mdx-code-block fence, whose body Docusaurus compiles as MDX rather than displaying as code', + 'disallowed element ', ], ], [ diff --git a/tests/mdx-active-content.test.mjs b/tests/mdx-active-content.test.mjs index a957ea1d..4cb68ef6 100644 --- a/tests/mdx-active-content.test.mjs +++ b/tests/mdx-active-content.test.mjs @@ -530,6 +530,55 @@ test('reports the four-backtick and indented forms Docusaurus also unwraps', () ]); }); +test('reports active content exposed only when the whole document is unwrapped', () => { + // The two fence grammars disagree about which closer pairs with the opener. + // CommonMark ends the code node at the four-backtick line, so the node's own + // slice is not something unwrapMdxCodeBlocks() rewrites and the per-node + // check clears it. Docusaurus's pattern needs a closer of exactly three + // backticks, skips the four-backtick line under `.*?`/`s`, and pairs the + // opener with the bare fence at the end -- unwrapping the into live + // MDX. Before the whole-document pass this body scanned clean, validated, + // and built to HTML carrying the remote plus a preload link for it. + const body = [ + '```mdx-code-block', + '', + '````', + '', + '```', + ].join('\n'); + assert.deepEqual(findActiveContent(body), [ + { + line: 2, + reason: 'disallowed element ', + snippet: '', + }, + ]); +}); + +test('maps an unwrapped-pass finding back to its true source line', () => { + // Only the fence lines are deleted, so the unwrapped document's lines are a + // subsequence of the source's and the reported line must be the one a reader + // can open the file to, not the shifted line of the rewritten text. + const body = [ + '## Overview', + '', + 'Prose.', + '', + '```mdx-code-block', + '', + '````', + '', + '```', + ].join('\n'); + assert.deepEqual(findActiveContent(body), [ + { + line: 6, + reason: 'disallowed element ', + }, + ]); +}); + test('leaves ordinary fenced and inline code alone', () => { // The exemption is for the one info string Docusaurus rewrites, not for // code blocks in general: an architecture page showing a YAML snippet with