[sec-check] MDX active-content gate resolves duplicate link definitions last-wins, bypassing the remote-image check
Maintainers usually reply within 1 day
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 25/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Stale
- Tech stack
- javascript
- Domain
- security
Research direction
Read scripts/lib/mdx-active-content.mjs and scripts/lib/architecture-content.mjs, then inspect the duplicate-definition handling described in the issue. Run the named tests in tests/mdx-active-content.test.mjs and tests/architecture-content-clean-markdown.test.mjs; done means both maps use the first definition and the tests cover remote/local ordering and cleanMarkdown() rewriting.
Written by the indexing model from the issue text.
Description
Security Finding
Severity: medium
Type: unsafe-pattern (security-control bypass)
scripts/lib/mdx-active-content.mjs and scripts/lib/architecture-content.mjs each build their link-reference definition map with a bare Map.set(), so a repeated label resolves to the last definition in the document:
scripts/lib/mdx-active-content.mjs:159—if (node.type === 'definition') definitions.set(node.identifier, node.url);scripts/lib/architecture-content.mjs:81—if (node.type === 'definition') definitions.set(node.identifier, node.url);
CommonMark resolves a repeated label to the first definition, and the renderer this site actually publishes through does the same. node_modules/mdast-util-to-hast/lib/state.js:227-237 builds its map under if (!map.has(id)) with the comment "Mimick CM behavior of link definitions.", and @mdx-js/mdx runs that transform. So for a document carrying two definitions of one label, the gate inspects a URL the published page never uses.
Reproduction (run at a621619, Node 26, repo node_modules installed)
import { findActiveContent } from './scripts/lib/mdx-active-content.mjs';
import { compile } from '@mdx-js/mdx';
const body = `# Imported architecture
![diagram][d]
[d]: https://beacon.evil.example/px.png
[d]: /img/architectures/local.png
`;
console.log('findActiveContent =>', JSON.stringify(findActiveContent(body)));
console.log(String(await compile(body)).includes('beacon.evil.example'));
Output:
findActiveContent => []
true <-- compiled page contains src: "https://beacon.evil.example/px.png"
The gate reports zero findings while the compiled page loads the third-party image.
Impact
Bounded, and deliberately stated narrowly:
- Not XSS. Every
definitionnode is checked for a script-capable scheme regardless of which one wins, becauseURL_NODESinmdx-active-content.mjs:100includesdefinitionand that arm runs per node rather than through the map. Ajavascript:destination is still caught on both copies. - The remote-image beacon check is bypassed.
reportRemoteImage()is the only check that resolves a reference through the map, so a remote<img>destination reaches the published page unflagged. That is precisely the control the module's own header documents: "this is the gate on what is published, and it has to hold when a page reachesdocs/without passing the importer." A remote image is fetched by every visitor's browser with no user action, disclosing their IP, User-Agent and Referer to a host the page author chose. - Reachable at the gate that matters.
scripts/validate-architectures.mjs:135runsfindActiveContent()over every.mdon disk underdocs/architectures/, driven by the filesystem rather than the catalog, so this is the check standing between a page added by a pull request and publication. architecture-content.mjsis a correctness bug, not an exposure.rewriteImages()replaces the wholeimageReferencenode with an inline image, so whichever definition it picks the emitted destination is mirrored, rebased or demoted — the output stays safe. But it applies the image policy to a URL the renderer would not have used, which means the published page can show the wrong diagram, and it leaves the two modules disagreeing about reference resolution when they are written to agree.
Recommendation
Make both maps first-wins, matching CommonMark and mdast-util-to-hast:
scripts/lib/mdx-active-content.mjs
eachNode(tree, (node) => {
// First definition wins, as CommonMark specifies and as
// mdast-util-to-hast implements for the published page; a later
// duplicate of the same label is dead text the renderer ignores.
if (node.type === 'definition' && !definitions.has(node.identifier))
definitions.set(node.identifier, node.url);
});
scripts/lib/architecture-content.mjs
if (node.type === 'definition' && !definitions.has(node.identifier))
definitions.set(node.identifier, node.url);
Both identifier keys stay consistent with the renderer: mdast normalizes identifier, and mdast-util-to-hast upper-cases that normalized value, which is injective, so the two collapse the same labels.
Add regression fixtures so a future refactor cannot reintroduce last-wins:
tests/mdx-active-content.test.mjs— a duplicate-label document whose first definition is remote and second is local must reportremote image destination …, and the mirror case (first local, second remote) must report nothing.tests/architecture-content-clean-markdown.test.mjs—cleanMarkdown()must rewrite a duplicated reference from its first definition.
Completion criteria
-
mdx-active-content.mjsresolves a repeated label to the first definition -
architecture-content.mjsresolves a repeated label to the first definition - Regression tests cover both orderings in
tests/mdx-active-content.test.mjs - Regression test covers
cleanMarkdown()duplicate-label rewriting
No workflow file is involved; the whole fix is pushable.
Filed by sec-check agent (ACMM L4/L5 — hold-gated mode)
🐝 Hive Agent: security | Instance: hosted-available-lke648397-260827-5n31 | SHA: a621619
— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88
- Dominant language
- JavaScript
- Stars
- 0
- Forks
- 3
- Avg merge
- 7h 20m
- Merged PRs (30d)
- 641
Getting set up
Starts the project's dev container in your browser, under your own GitHub account.
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from cncf/endusers
-
[quality] GroupLinkStatus's inconclusive-recheck rule (archived/reachable null) is unpinnedPossibly taken @hivecommons-hive claimed this 1 day ago. Openagent/quality stage/040-covered-by-pr testing
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 1 day
-
[quality] FilterToolbar object-option labels are unpinned: collapsing the value/label ternary leaves the whole unit suite greenPossibly taken @hivecommons-hive claimed this 1 day ago. Openagent/quality hive/claimed-by-quality stage/040-covered-by-pr testing
Difficulty 2/5 1-3 hours Newbie friendliness 73/100
cncf/endusers#1674 · 2 comments ·
Maintainers usually reply within 1 day
-
[architect] duplicated freshness-note CSS across GroupLinkStatus, DirectoryFreshness, PeopleFreshnessPossibly taken @hivecommons-hive claimed this today. Openagent/architect stage/040-covered-by-pr
Difficulty 3/5 1-2 days Newbie friendliness 69/100
Maintainers usually reply within 1 day
-
[quality] initials() emits a broken monogram for non-ASCII names, and both coverage lanes report it 100% coveredPossibly taken @hivecommons-hive claimed this today. Openagent/quality hive/claimed-by-scanner stage/040-covered-by-pr testing
Difficulty 2/5 1-3 hours Newbie friendliness 55/100
cncf/endusers#1678 · 1 comment ·
Maintainers usually reply within 1 day
-
[quality] activeScheme: the data: branch of the shared scheme-boundary rule has no positive assertionPossibly taken @hivecommons-hive claimed this 1 day ago. Openagent/quality stage/040-covered-by-pr testing
Difficulty 2/5 1-3 hours Newbie friendliness 25/100
Maintainers usually reply within 1 day
Similar issues
-
release-followup
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Maintainers usually reply within 1 day
-
area: right sidebar
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
zulip/zulip#40449 · 3 comments ·
Maintainers usually reply within 2 days
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
Maintainers usually reply within 1 day
-
cleanup
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
uclaacm/membership-portal-ui#409 ·
Maintainers usually reply within 1 day