Security Finding
Severity: medium
Type: unsafe-pattern / security-control bypass
Component: scripts/lib/svg-active-content.mjs — STYLE_BLOCK_PATTERN / findRemoteReferences
findRemoteReferences() is an error-level gate in
scripts/validate-architecture-assets.mjs:268-270: every SVG published from
static/ is refused if it fetches a resource from a third-party host. The
<style> arm of that gate reads its CSS with a single lazy regex:
const STYLE_BLOCK_PATTERN = new RegExp(
`<\\s*${NS_PREFIX}style\\b(?:"[^"]*"|'[^']*'|[^>"'])*>([\\s\\S]*?)<\\s*/\\s*${NS_PREFIX}style\\s*>`,
'gi',
);
A browser parses a standalone .svg as XML, where a </style> inside a
CDATA section is ordinary character data and does not close the element. The
lazy match stops there anyway, so every declaration after the decoy is never
scanned — in a file that is well-formed and renders normally.
Proof of concept
This file is well-formed XML (verified with xml.etree.ElementTree) and its
url(...) is live CSS that a browser fetches when the SVG is opened at the
site origin:
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<style type="text/css"><![CDATA[
/* decoy that closes the regex early: </style> */
rect { fill: url(https://evil.example/track.svg#g); }
]]></style>
<rect width="100" height="100"/>
</svg>
At main 900592b:
findRemoteReferences(payload) -> []
findActiveContent(payload) -> []
Both gates pass, so npm run validate:architecture-assets accepts the file and
it publishes.
A second, smaller hole in the same function: a <style> element that is never
closed matches nothing, so its CSS is not scanned at all. An HTML parser runs
such an element to EOF, and in XML the file is rejected outright, so reading
the remainder is the conservative answer under either grammar.
Impact
Defeats the control that exists specifically to stop a diagram from beaconing
visitors. A url() in a CSS rule discloses the visitor's IP address,
User-Agent and Referer to a host the diagram's author chose, from the site's
own origin. @font-face { src: url(...) } and @import have the same reach.
This is tracking/disclosure, not code execution — the element allow-list and
the <script> scan are unaffected — which is why this is filed as medium and
not high.
Reachable through either SVG path into static/: an architecture diagram
imported from cncf/architecture (scripts/import-architectures.mjs) and a
logo hand-committed in a pull request are both published verbatim at the origin
and both rely on this gate.
Recommendation
Replace STYLE_BLOCK_PATTERN with a scan that consumes CDATA sections whole
before < is given any meaning, and that reads an unclosed <style> to the
end of the document. A self-closing <style/> must still swallow no content.
Cluster: scripts/lib/svg-active-content.mjs STYLE_BLOCK_PATTERN /
findRemoteReferences style-block scan. Disjoint from #1042, which only adds
tests for XML_DECLARATION_ENCODING and PROCESSING_INSTRUCTION, and from
#1039, which is scripts/lib/mdx-active-content.mjs.
Filed by sec-check agent (ACMM L4/L5 — hold-gated mode)
🐝 Hive Agent: security | Instance: hosted-available-lke648397-260827-5n31 | SHA: 900592b
— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88
Security Finding
Severity: medium
Type: unsafe-pattern / security-control bypass
Component:
scripts/lib/svg-active-content.mjs—STYLE_BLOCK_PATTERN/findRemoteReferencesfindRemoteReferences()is an error-level gate inscripts/validate-architecture-assets.mjs:268-270: every SVG published fromstatic/is refused if it fetches a resource from a third-party host. The<style>arm of that gate reads its CSS with a single lazy regex:A browser parses a standalone
.svgas XML, where a</style>inside aCDATA section is ordinary character data and does not close the element. The
lazy match stops there anyway, so every declaration after the decoy is never
scanned — in a file that is well-formed and renders normally.
Proof of concept
This file is well-formed XML (verified with
xml.etree.ElementTree) and itsurl(...)is live CSS that a browser fetches when the SVG is opened at thesite origin:
At
main900592b:Both gates pass, so
npm run validate:architecture-assetsaccepts the file andit publishes.
A second, smaller hole in the same function: a
<style>element that is neverclosed matches nothing, so its CSS is not scanned at all. An HTML parser runs
such an element to EOF, and in XML the file is rejected outright, so reading
the remainder is the conservative answer under either grammar.
Impact
Defeats the control that exists specifically to stop a diagram from beaconing
visitors. A
url()in a CSS rule discloses the visitor's IP address,User-Agent and
Refererto a host the diagram's author chose, from the site'sown origin.
@font-face { src: url(...) }and@importhave the same reach.This is tracking/disclosure, not code execution — the element allow-list and
the
<script>scan are unaffected — which is why this is filed as medium andnot high.
Reachable through either SVG path into
static/: an architecture diagramimported from
cncf/architecture(scripts/import-architectures.mjs) and alogo hand-committed in a pull request are both published verbatim at the origin
and both rely on this gate.
Recommendation
Replace
STYLE_BLOCK_PATTERNwith a scan that consumes CDATA sections wholebefore
<is given any meaning, and that reads an unclosed<style>to theend of the document. A self-closing
<style/>must still swallow no content.<style>CSS is read by a CDATA-aware scan instead of one lazy regextests/svg-active-content.test.mjscovering theCDATA decoy, a second block after a decoy, an unclosed block, and
<style/>Cluster:
scripts/lib/svg-active-content.mjsSTYLE_BLOCK_PATTERN/findRemoteReferencesstyle-block scan. Disjoint from #1042, which only addstests for
XML_DECLARATION_ENCODINGandPROCESSING_INSTRUCTION, and from#1039, which is
scripts/lib/mdx-active-content.mjs.Filed by sec-check agent (ACMM L4/L5 — hold-gated mode)
🐝 Hive Agent:
security| Instance:hosted-available-lke648397-260827-5n31| SHA:900592b— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88