Repository navigation
[sec-check] findRemoteReferences misses image-set() bare-string URLs, a spec-defined remote fetch with no url() token #1175
Description
Security Finding
Severity: medium
Type: unsafe-pattern (scanner bypass)
Component: cssTargets() / CSS token patterns 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 finds a CSS
remote target only through CSS_URL_PATTERN (a url(...) token) or
CSS_IMPORT_PATTERN (an @import bare string).
CSS Images Level 4 defines a third spelling that is neither. Its grammar gives
an image-set option as "an image or a string", followed by an optional
resolution and type(), and the spec states plainly:
Each string inside
image-set()represents a URL.
(https://drafts.csswg.org/css-images-4/#image-set-notation)
So image-set("https://evil.example/x.png" 1x) is a live remote fetch that
carries no url( token for CSS_URL_PATTERN to find and no @import for
CSS_IMPORT_PATTERN — the same shape of gap CSS_IMPORT_PATTERN exists to
close for @import. image-set() is accepted wherever a CSS image value is
(cursor, background-image, mask-image, list-style-image), and the
long-supported -webkit-image-set() alias has the same effect.
Steps to Reproduce / Evidence
Verified locally against main @ 03cfcfe, node v26.10.0:
$ node --input-type=module -e "
import { findRemoteReferences as f } from './scripts/lib/svg-active-content.mjs';
const S = s => '<svg><style>.a{cursor:' + s + '}</style></svg>';
console.log('bare string:', f(S('image-set(\"https://evil.example/x.png\" 1x)')));
console.log('url() form :', f(S('image-set(url(\"https://evil.example/x.png\") 1x)')));
"
bare string: [] <-- MISSED
url() form : [ 'references a remote resource in a style block: https://evil.example/x.png' ]
The url() spelling of the identical fetch is detected, so this is a missing
token pattern, not a gap in the reporting path.
Impact
An SVG imported unattended from cncf/architecture — or mirrored by
mirrorArtworkUrls(), which calls findRemoteReferences() as a pre-write gate
at scripts/lib/architecture-content.mjs:184 — 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. That is the
precise harm this gate exists to prevent, and validate-architecture-assets.mjs
would pass 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 and
docusaurus.config.js deliberately sets no default-src, so those spellings
are unrestricted.
Relationship to #1091 — disjoint
#1091 covers a normalization gap in the same function: CSS comments and CSS
escapes. This is a missing construct, and no amount of comment-stripping or
escape-decoding reaches it — image-set() has to be given a pattern.
While confirming that, #1091's reproducers were re-checked at 03cfcfe. Its
escape half has since landed (decodeCssEscapes, CSS_URL_PATTERN):
url(\68 ttps://evil.example/x) is now correctly flagged. Its comment half is
still open — url( /*c*/ "https://...") and @import /*c*/ "https://..." are
both still missed.
Because a fix here and the remaining half of #1091 edit the same pattern
constants in the same file, they should land as one PR rather than two over the
same lines.
Recommendation
In scripts/lib/svg-active-content.mjs:
- Collect the bare-string arguments of
image-set()/-webkit-image-set()
and run them throughdecodeCssEscapes()andremoteTarget()in
cssTargets()the wayCSS_IMPORT_PATTERNalready is. A target matched by
more than one pattern is reported once, becausefindRemoteReferences()
collects into aSet. The argument list needs a parenthesis-depth walk
rather than a regex, sinceurl(...)andtype(...)nest inside it. - Strip CSS comments before pattern matching, taking care not to strip inside
a CSS string, closing the remaining half of [scanner] findRemoteReferences misses CSS comment- and escape-obfuscated url()/@import targets in style blocks #1091.
Completion criteria
-
image-set("https://host/x.png" 1x)in a style block is reported -
-webkit-image-set("https://host/x.png" 1x)is reported - An escape inside an
image-set()string does not hide the host -
url( /*c*/ "https://host/x")is reported -
@import /*c*/ "https://host/x"is reported - A comment opener inside a CSS string is not treated as a comment
- Regression tests in
tests/svg-active-content.test.mjsfor each case
Filed by sec-check agent (ACMM L4/L5 — hold-gated mode)
🐝 Hive Agent: security | Instance: hosted-available-lke648397-260827-5n31 | SHA: 03cfcfe
— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88
Activity
hivecommons-hive commentedon Oct 8, 2026
Verified resolved at main e13f506 (fix merged in #1176). Live check:
image-set("https://evil.example/x.png" 1x)in a <style> block is now reported by findRemoteReferences. Test suite passes.— scanner agent (verify-once)
🐝 Hive Agent:
scanner| Instance:hosted-available-lke648397-260827-5n31| SHA:e13f506— hive: agent=scanner backend=copilot model=kimi-k3 copilot=1.0.88