Repository navigation
[sec-check] Reference-style images bypass the remote-image demotion in cleanMarkdown, publishing third-party beacons on architecture pages #1228
Description
Security Finding
Severity: medium
Type: unsafe-pattern (third-party beacon in published pages)
cleanMarkdown() in scripts/lib/architecture-content.mjs demotes a remote
image to a plain link so a published architecture page never hot-links a
third-party host:
.replace(
new RegExp(`!\\[([^\\]]*)\\]\\((${REMOTE_DESTINATION}[^\\)]+)\\)`, 'g'),
(_, alt, url) => {
const asset = projectAsset(url);
return asset ? `` : `[${alt}](${url})`;
},
)That pattern only matches the inline image form . CommonMark
has three more image forms whose destination lives in a link-reference
definition elsewhere in the document — full ![alt][label], collapsed
![alt][] and shortcut ![alt] — and none of them are matched. The
destination is never inspected, never demoted and never rewritten, so the
compiled page renders <img src="https://third-party.example/..."> exactly as
submitted.
findActiveContent() (scripts/lib/mdx-active-content.mjs), which
scripts/validate-architectures.mjs runs over every generated page, reports
script-capable schemes and disallowed elements — not remote resource
references — so nothing downstream catches it either.
Both producers share this transform and are therefore both affected:
scripts/import-architectures.mjs (unattended daily import of
third-party cncf/architecture Markdown) and
scripts/import-architecture-issue.mjs (community submission issue form).
escapeMdx() neutralises < and {, but leaves !, [ and ] alone, so
the reference form survives the submission path intact.
Reproduction
Submitting a reference-architecture issue whose "Describe your organisation"
answer is
We are a demo. ![tracker][beacon]
[beacon]: https://evil.example/pixel.png
produces docs/architectures/<id>.md containing those two lines verbatim, and
npm run validate:architectures reports Validated 9 architecture records
with no error. The same text written inline as
 is correctly demoted to
[tracker](https://evil.example/pixel.png).
Directly against the library:
cleanMarkdown('![alt][beacon]\n\n[beacon]: https://evil.example/pixel.png', 'demo')
// -> '![alt][beacon]\n\n[beacon]: https://evil.example/pixel.png' (unchanged)
cleanMarkdown('', 'demo')
// -> '[alt](https://evil.example/pixel.png)' (demoted)Impact
Any submitter — or anyone whose content reaches cncf/architecture — can place
an <img> on a published cncf/endusers page pointed at a host they control.
Every visitor's browser fetches it on page load, handing that host the
visitor's IP address, User-Agent and Referer (which names the page being
read). That is precisely the hot-linking and visitor-tracking the inline rule
exists to prevent, reachable by choosing a different Markdown spelling of the
same image. A host that serves the beacon can also stop serving it, or serve
different bytes later, since nothing is mirrored.
The same gap means a reference-style image pointing at a genuine cncf/artwork
URL is never rewritten to its mirrored /img/cncf-projects/... path, and a
relative reference-style image is never scoped to
/img/architectures/<id>/..., so both render broken instead of local.
Recommendation
Resolve reference-style images against the document's link-reference
definitions before the existing image rules run, so every image form reaches
the same remote-demotion and local-path rewrite. Concretely, in
scripts/lib/architecture-content.mjs:
- collect link-reference definitions (
[label]: destination, with the optional
<...>destination form and an optional title), normalising labels the way
CommonMark does (trim, lowercase, collapse internal whitespace); - rewrite
![alt][label],![alt][]and![alt]to
when a definition matches, skipping a match already followed by((that is
the inline form the existing rules handle); - fail closed for a destination containing whitespace or parentheses — which
the downstream[^\)]+patterns cannot express — by dropping the!so the
construct renders as a link reference rather than an image.
Leaving the definition line in place is correct: a definition renders as
nothing, and it may still be the target of a text link elsewhere in the page.
Cover it in tests/architecture-content-clean-markdown.test.mjs with the full,
collapsed and shortcut forms for a remote destination, a cncf/artwork
destination and a relative destination.
Files: scripts/lib/architecture-content.mjs (cleanMarkdown),
tests/architecture-content-clean-markdown.test.mjs. This cluster is disjoint
from the open security PRs #1206 and #1213 (scripts/lib/svg-active-content.mjs)
and #1219 (scripts/lib/mdx-active-content.mjs).
🐝 Hive Agent: security | Instance: hosted-available-lke648397-260827-5n31 | SHA: 593b9fe
— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88
Activity
fix(security): parser-based SVG/MDX scanning, replacing per-bypass re…