Skip to content

fix: scan the unwrapped form in the publish-time MDX active-content gate - #1734

Merged
mrbobbytables merged 1 commit into
mainfrom
sec/fix-mdx-unwrap-publish-gate
Oct 21, 2026
Merged

mrbobbytables merged 1 commit into
mainfrom
sec/fix-mdx-unwrap-publish-gate

Conversation

@hivecommons-hive

Copy link
Copy Markdown
Contributor

Security Fix

Docusaurus runs unwrapMdxCodeBlocks() on every page before the MDX compiler
sees it, so the text it compiles is not the text on disk. The publish-time gate
only asked about that rewrite per mdast code node, through
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 code node at a four-backtick line.
  • Docusaurus's regexp3 requires a closer of exactly three backticks 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>, a
four-backtick line and a bare fence therefore scanned clean, passed
validate:architectures with exit 0, and built to HTML carrying the <img> —
plus a <link rel=preload as=image> for it, so browsers fetch it eagerly.

The fix

Scan unwrapMdxCodeBlocks(text) as well as text, mirroring hasActiveMdx()
in scripts/lib/mdx-escape.mjs, which already does exactly this and was not
affected — #1695 fixed the import-time half and left this one.

The unwrap only 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. A greedy
two-pointer walk maps every unwrapped-pass finding back to its true source line,
with no upstream pattern restated anywhere.

isUnwrappedCodeBlock()'s doc comment claimed "the answer for the slice is the
answer for the block in the whole document". That is the assumption this PR
disproves, so the comment is corrected in place to say that false is not a
clearance and why.

Behaviour change

A fence caught by both passes now reports what it hides as well as its own
presence, so one existing expectation gains a second reason. That is deliberate:
the validator now tells a maintainer both where the fence is and what it was
concealing.

No currently-published page gains a finding — docs/ and blog/ contain no
mdx-code-block fence, and validate:architectures still exits 0 on a clean
tree.

Files and scope

scripts/lib/mdx-active-content.mjs (findActiveContent split into
scanSource + a second pass, new mapUnwrappedLines),
scripts/lib/mdx-code-blocks.mjs (doc comment only),
tests/mdx-active-content.test.mjs.

Disjoint from every open hold-gated PR: #1726 is confined to
src/components/ plus tests/community-people.test.mjs and
tests/member-directory.test.mjs; #1714 is confined to tests/e2e/.

Verification

  • The two new fixtures fail without the mdx-active-content.mjs change
    (actual: []), and pass with it.
  • TZ=UTC node --test — 3011 pass, 0 fail.
  • npm run check:format — clean.
  • npm run validate:architectures on a clean tree — Validated 8 architecture records, exit 0.
  • The same command on a page carrying the payload — exit 1, reporting
    adobe.md:260: active content in imported page (disallowed element <img>),
    the correct source line and snippet. Before this change it exited 0.

Fixes #1732


Filed by sec-check agent (ACMM L4/L5 — hold-gated mode). Hold-gated: human review required.

— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88

@hivecommons-hive

Copy link
Copy Markdown
Contributor Author

Important

Held for human review by the hive's ACMM level gate.

This PR was opened by the "sec-check" agent while Hive policy required a human checkpoint for that agent. Agent PRs are held at ACMM L3–L5; an outreach PR that targets a repository this hive does not manage is held at every level because it publishes project-facing communication into someone else's project.

Hive will keep the hold label until a human removes it. Operators can make a deliberate one-off release during an ACMM level change with release_level_holds=true, but level changes never release this hold automatically.

@netlify

netlify Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for endusers ready!

Name Link
🔨 Latest commit 617d2e6
🔍 Latest deploy log https://app.netlify.com/projects/endusers/deploys/6ad83e0bc74a8b59b9408581
😎 Deploy Preview https://deploy-preview-1734--endusers.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@hivecommons-hive hivecommons-hive Bot added the security Approved by a Hive merger/owner for auto-merge on green CI label Oct 21, 2026
@hivecommons-hive

Copy link
Copy Markdown
Contributor Author

⚠️ Hold-gate mergeability: this PR has a real conflict with its base

This PR is hold-gated and has fallen behind its base branch. The hive tried to merge the base in to keep it mergeable while it waits on review, but that merge conflicts and the hive cannot resolve it automatically.

It will stay held and will NOT merge on its own even once reviewed — removing hold alone will not be enough. Someone will need to merge or rebase the base branch in by hand (or resolve the conflict another way) before this PR can merge.

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix is correct; the PR is blocked by its own coverage gate, not by its logic.

I read scanSource, mapUnwrappedLines and the dedupe tail in full at c37ddd4, plus both new fixtures. The mechanism holds: the unwrap replacer keeps body lines verbatim and deletes only fence lines, so the unwrapped document's lines are a subsequence of the source's and the greedy forward walk in mapUnwrappedLines recovers true source lines. The line:reason dedupe key correctly lets a fence caught by both passes report both the fence and what it hid. The doc-comment correction in scripts/lib/mdx-code-blocks.mjs matches what the new test demonstrates.

Why "Validate repository" is red — the job log says Source region coverage 99.93% is below the required 100%, and the two uncovered regions are both in this PR's new code:

  • scripts/lib/mdx-active-content.mjs:290 — the false arm of cursor < sourceLines.length ? cursor + 1 : sourceLines.length. The adjacent comment itself says this arm "is not possible for a pure deletion", so no test can reach it through the public API.
  • scripts/lib/mdx-active-content.mjs:332 — the ?? finding.line fallback, unreachable for the same reason: every unwrapped line gets a mapping entry.

Both are deliberate defensive arms against a future upstream change, but the repository's gate requires 100% source region coverage, so as written this cannot merge. Options: drop the unreachable arms (the comment can state the invariant instead), or restructure so the fallback is exercisable. Requesting more tests is not the issue — the arms are unreachable by construction.

Minor: the PR is also behind main (#1727 merged ESLint); a rebase will be needed regardless.

This is hold-labeled security work, so a maintainer signs off on merge either way; the coverage fix is the one actionable item for the author first.

— hive: agent=reviewer backend=copilot model=claude-fable-5 copilot=1.0.88

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 <bkillen@linuxfoundation.org>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mrbobbytables

Copy link
Copy Markdown
Member

Rebased onto main. #1730 had restructured mdx-active-content.mjs around allowlists, so the fix was ported by hand rather than merged: scanSource() keeps the mdastExtensions option, and findActiveContent() scans both the source and unwrapMdxCodeBlocks() output, mapping lines back. The "historical bypass" table test in tests/active-content-allowlist.test.mjs now expects the second reason the double scan produces. Both new regression tests fail without the fix.

Sole maintainer; no second qualified reviewer available. Reviewed the diff and proceeding after required checks passed, per GOVERNANCE.md 'Review and merge expectations'.

@mrbobbytables
mrbobbytables force-pushed the sec/fix-mdx-unwrap-publish-gate branch from c37ddd4 to 617d2e6 Compare October 21, 2026 04:22
@mrbobbytables
mrbobbytables added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit 3bbac0d Oct 21, 2026
14 checks passed
@mrbobbytables
mrbobbytables deleted the sec/fix-mdx-unwrap-publish-gate branch October 21, 2026 04:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

hold security Approved by a Hive merger/owner for auto-merge on green CI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[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)

1 participant