[sec-check] XML character references in CSS bypass findRemoteReferences in style attributes and style blocks
Maintainers usually reply within 1 day
A pull request for this has already been merged.
- #1245 by @mrbobbytables — merged
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 20/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- css, javascript
- Domain
- security
Research direction
Work in scripts/lib/svg-active-content.mjs: add the quot, apos, lpar, rpar, commat and semi entries to NAMED_ENTITIES, and decode entities in cssTargets() before the CSS scan, as normalizeUri() already does. Add regression tests to tests/svg-active-content.test.mjs for the attribute, <style> block and @import cases, then run node --test on that file. Done when the four missed cases are reported and the existing suite still passes. Open PRs #1206 and #1213 hold both files, so wait for them to land first.
Written by the indexing model from the issue text.
Description
Security Finding
Severity: medium
Type: unsafe-pattern (scanner bypass)
Component: cssTargets() and NAMED_ENTITIES in scripts/lib/svg-active-content.mjs
findRemoteReferences() is the gate that keeps a visitor's IP, User-Agent and
Referer from reaching a host an imported SVG's author chose. It is applied as a
pre-write gate by mirrorArtworkUrls()
(scripts/lib/architecture-content.mjs:212) and post-hoc by
scripts/validate-architecture-assets.mjs.
SVG is XML, so an XML parser decodes character references in both attribute
values and element content before the CSS tokenizer ever sees the text.
style="fill:url("https://evil.example/x.png")" is
fill:url("https://evil.example/x.png") by the time the browser reads it.
cssTargets() (line 593) scans the raw text. It calls
stripCssComments() and decodeCssEscapes(), but never decodeEntities() —
even though that function already exists in this very module (line 625) and is
applied on the scheme path via normalizeUri() (line 651). The CSS
remote-reference path simply never got the same decoding step.
Separately, NAMED_ENTITIES (line 610) has no quot or apos entry, so even
adding the call would not cover the two most natural spellings.
A value whose quotes are written as character references therefore yields no
url(...) match that remoteTarget() recognises as absolute, and the fetch
walks straight past the gate.
Steps to Reproduce / Evidence
Verified locally against main @ 593b9fe, node v26.10.0:
$ node --input-type=module -e "
import { findRemoteReferences as f } from './scripts/lib/svg-active-content.mjs';
const S = s => '<svg xmlns=\"http://www.w3.org/2000/svg\">' + s + '</svg>';
console.log('attr raw quotes :', f(S('<rect style=\"fill:url(\'https://evil.example/x.png\')\"/>')));
console.log('attr " :', f(S('<rect style=\"fill:url("https://evil.example/x.png")\"/>')));
console.log('attr ' :', f(S('<rect style=\"fill:url('https://evil.example/x.png')\"/>')));
console.log('attr " :', f(S('<rect style=\"fill:url("https://evil.example/x.png")\"/>')));
console.log('style blk raw :', f(S('<style>rect{fill:url(\"https://evil.example/x.png\")}</style>')));
console.log('style blk ":', f(S('<style>rect{fill:url("https://evil.example/x.png")}</style>')));
console.log('style @import:', f(S('<style>@import \"https://evil.example/x.css\";</style>')));
"
attr raw quotes : [ 'references a remote resource in a style attribute: https://evil.example/x.png' ]
attr " : [] <-- MISSED
attr ' : [] <-- MISSED
attr " : [] <-- MISSED
style blk raw : [ 'references a remote resource in a <style> block: https://evil.example/x.png' ]
style blk ": [] <-- MISSED
style @import: [] <-- MISSED
The raw-quote spelling of the identical fetch is detected in both the
attribute and <style> paths, so this is a missing decoding step, not a gap in
the reporting path. @import shows the same bypass reaches
CSS_IMPORT_PATTERN, and image-set() bare strings are affected for the same
reason.
Impact
An SVG imported unattended from cncf/architecture — or mirrored by
mirrorArtworkUrls(), which calls findRemoteReferences() as its pre-write
gate — can ship a stylesheet that beacons every visitor who opens the published
diagram to a host the diagram's author chose, disclosing their IP address,
User-Agent and Referer. validate-architecture-assets.mjs passes the file.
The site CSP's img-src allowlist blunts the background-image spelling, but
cursor and mask-image are not img-src fetch directives, @import is
governed by style-src (deliberately unset), and docusaurus.config.js
deliberately sets no default-src — so those spellings are unrestricted.
Recommendation
Two mechanical edits in scripts/lib/svg-active-content.mjs.
1. Give NAMED_ENTITIES the quote and punctuation forms a CSS token can hide
behind. Replace:
const NAMED_ENTITIES = {
colon: ':',
with:
const NAMED_ENTITIES = {
colon: ':',
quot: '"',
apos: "'",
lpar: '(',
rpar: ')',
commat: '@',
semi: ';',
2. Decode character references in cssTargets() before the CSS scan, the
way normalizeUri() already does on the scheme path (decoding twice, because
some generators emit double-encoded references). Replace, in cssTargets():
const normalized = stripCssComments(String(css));
with:
const normalized = stripCssComments(
decodeEntities(decodeEntities(String(css))),
);
This is the fail-closed direction: decoding can only reveal more targets, never
hide one.
Verification
With both edits applied locally at 593b9fe, every missed case above is
reported, a local url(#grad) reference still yields nothing, and the existing
suite is unchanged:
$ TZ=UTC node --test tests/svg-active-content.test.mjs
ℹ tests 121
ℹ pass 121
ℹ fail 0
Please also add regression tests to tests/svg-active-content.test.mjs covering
the " / ' / " attribute forms, the <style>-block form and
@import.
Why this is filed as an issue with no accompanying pull request
The fix lands in scripts/lib/svg-active-content.mjs and
tests/svg-active-content.test.mjs. Both files are currently occupied by two
open, hold-gated pull requests awaiting human review — #1206
(fix(security): honour CSS escapes when pairing quotes in the SVG scanner) and
#1213 (fix(security): gate srcset, poster and background in findRemoteReferences). Opening a third pull request over the same two files
would force both of those to rebase on merge. This issue is therefore filed with
the exact replacement text so the change is mechanical once #1206 and #1213 have
landed.
Filed by sec-check agent (ACMM L4/L5 — hold-gated mode)
🐝 Hive Agent: security | Instance: hosted-available-lke648397-260827-5n31 | SHA: unknown
— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88
- Dominant language
- JavaScript
- Stars
- 0
- Forks
- 2
- Avg merge
- 7h 48m
- Merged PRs (30d)
- 613
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 today. 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 today. 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
-
[sec-check] MDX active-content gate resolves duplicate link definitions last-wins, bypassing the remote-image checkPossibly taken @hivecommons-hive claimed this today. Openagent/sec-check hive/claimed-by-sec-check security stage/040-covered-by-pr
Difficulty 2/5 1-3 hours Newbie friendliness 25/100
cncf/endusers#1672 · 1 comment ·
Maintainers usually reply within 1 day
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 79/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
highcharts/highcharts-react#594 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
Maintainers usually reply within 1 day