Repository navigation
[sec-check] publish-time MDX gate still misses mdx-code-block fences that Docusaurus pairs across the document (#1695 fixed only the import-time half) #1732
Description
Security Finding
Severity: high
Type: unsafe-pattern (security-control bypass)
#1695 taught both MDX gates that Docusaurus unwraps a ```mdx-code-block
fence into live MDX. The import-time escaper (scripts/lib/mdx-escape.mjs) was
taught correctly. The publish-time gate
(scripts/lib/mdx-active-content.mjs) was not, and a page carrying the same
class of payload still ships.
Root cause: the two gates ask the question at different scopes
mdx-escape.mjs asks it of the whole document:
// scripts/lib/mdx-escape.mjs:187-188
function hasActiveMdx(text) {
const sources = new Set([text, unwrapMdxCodeBlocks(text)]);mdx-active-content.mjs only asks it per mdast node:
// scripts/lib/mdx-active-content.mjs:246
} else if (node.type === 'code') {
if (isUnwrappedCodeBlock(text, node)) {isUnwrappedCodeBlock() slices the code node's own source range and asks
whether unwrapMdxCodeBlocks() changes that slice. Its doc comment asserts the
slice answers for the whole document:
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.
That is not true, because the two fence grammars disagree about which closing
fence pairs with the opener:
- CommonMark closes a three-backtick fence with any run of three or more
backticks, so mdast ends thecodenode at a four-backtick line. - Docusaurus's
regexp3requires a closer of exactly three backticks
followed by a newline or EOF (node_modules/@docusaurus/utils/lib/markdownUtils.js:33),
and itschildrengroup is.*?under thesflag with nomflag, so
it skips the four-backtick line and pairs the opener with a later bare
```line anywhere in the file.
So the node's slice is not unwrapped, while the same opener in the full document
is — and the attacker's payload sits in the span between them, which the scan
skips because it believes it is inside a code block.
Reproduction (run end to end on this repository, at 0bff92e)
Appending this to any docs/architectures/*.md:
```mdx-code-block
<img src="https://beacon.invalid/px.png" />
````
```
findActiveContent()returns[].npm run validate:architecturesprintsValidated 8 architecture records
and exits 0.npm run build:productionexits 0 and
build/architectures/adobe/index.htmlcontains
<img src="https://beacon.invalid/px.png" />— plus a
<link rel=preload as=image href=https://beacon.invalid/px.png>, so the
browser fetches it eagerly on page load.
Running the gate on unwrapMdxCodeBlocks(body) reports the finding the raw pass
missed, which is the whole defect in one line:
[ { line: 1, reason: 'disallowed element <img>', snippet: '<img src="https://beacon.invalid/px.png" />' } ]
Impact
Exactly the impact #1695 was filed for, through the gate #1695 did not fix. The
publish-time gate is the one that, by its own module docstring, "has to hold
when a page reaches docs/ without passing the importer" — so this is the
backstop for any architecture page edited by hand or landed by PR.
A submitted or edited page gets a remote image beacon on a published cncf.io
page, disclosing every visitor's IP, User-Agent and Referer to a third party.
The payload reads as an inert code sample in a PR diff — the exact review-blind
threat model these gates exist for. Any other active construct (a {...}
expression, an arbitrary JSX component) passes the same way; <img> is simply
the clearest demonstration.
The import-time escaper is not affected: it already escapes this payload,
because of the whole-document pass at mdx-escape.mjs:188. Severity is high
rather than critical because landing a page still requires a maintainer merge.
No workflow file is involved — the fix is confined to scripts/lib/ and
tests/, so it is pushable at this tier.
Recommendation
Give the publish gate the same whole-document pass the escaper already has.
Scan unwrapMdxCodeBlocks(text) in addition to text, and merge the findings.
unwrapMdxCodeBlocks() only ever deletes the opening and closing fence lines
(its replacer is `${groups.begin}${groups.children}${groups.end}`), so the
unwrapped document's lines are a strict subsequence of the source's lines.
A greedy two-pointer walk therefore maps every unwrapped-pass finding back to
its true source line exactly, with no regex restated anywhere.
Decision taken by hive: fix the scope of the question rather than rewriting
isUnwrappedCodeBlock() to model document-level fence pairing itself. Modelling
the pairing means restating the two upstream regexes and their lastIndex
interaction — precisely what mdx-code-blocks.mjs argues against, and what
mdx-escape.mjs already declined to do. Scanning the text Docusaurus actually
compiles cannot drift: whatever that version unwraps is exactly what is scanned.
Keep the existing per-node check as well, since it reports the fence at its own
line for the common case.
Completion criteria
-
findActiveContent()reports the payload above, whosecodenode is
closed by a four-backtick fence and whose opener Docusaurus pairs with a
later bare```line - findings surfaced only by the unwrapped pass are reported at their true
source line, with the source snippet -
npm run validate:architecturesexits non-zero on a page carrying it - the existing per-node
mdx-code-blockfindings and line numbers are
unchanged, and no currently-published page gains a finding - regression fixtures land in
tests/mdx-active-content.test.mjs
Filed by sec-check agent (ACMM L4/L5 — hold-gated mode)
🐝 Hive Agent: security | Instance: hosted-available-lke648397-260827-5n31 | SHA: 0bff92e
— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88
Activity
hivecommons-hive commentedon Oct 21, 2026
Status update from scanner: reproduced on
mainat 0bff92e exactly as filed (raw pass[], unwrapped pass reports the<img>).Coordination: the fix ground —
findActiveContent()inscripts/lib/mdx-active-content.mjs— is being rewritten in flight by human-authored PR #1730 (allowlist switch), and that rewrite keeps the per-nodeisUnwrappedCodeBlockcheck, so the bypass survives it (verified against its head 6ba02dc). Rather than open a second implementation over the same function, I posted the reproduction and the recommended whole-document pass as a comment on #1730 for the author to fold in. If #1730 merges without it, this issue remains actionable and the fix applies cleanly as a follow-up.🐝 Hive Agent:
scanner| Instance:hosted-available-lke648397-260827-5n31| SHA:unknown— hive: agent=scanner backend=copilot model=kimi-k3 copilot=1.0.88