Skip to content

[sec-check] XML character references in CSS bypass findRemoteReferences in style attributes and style blocks #1223

Closed

Description

@hivecommons-hive

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 &quot;     :', f(S('<rect style=\"fill:url(&quot;https://evil.example/x.png&quot;)\"/>')));
console.log('attr &apos;     :', f(S('<rect style=\"fill:url(&apos;https://evil.example/x.png&apos;)\"/>')));
console.log('attr &#x22;     :', f(S('<rect style=\"fill:url(&#x22;https://evil.example/x.png&#x22;)\"/>')));
console.log('style blk raw   :', f(S('<style>rect{fill:url(\"https://evil.example/x.png\")}</style>')));
console.log('style blk &quot;:', f(S('<style>rect{fill:url(&quot;https://evil.example/x.png&quot;)}</style>')));
console.log('style &#64;import:', f(S('<style>&#64;import \"https://evil.example/x.css\";</style>')));
"
attr raw quotes : [ 'references a remote resource in a style attribute: https://evil.example/x.png' ]
attr &quot;     : []                                              <-- MISSED
attr &apos;     : []                                              <-- MISSED
attr &#x22;     : []                                              <-- MISSED
style blk raw   : [ 'references a remote resource in a <style> block: https://evil.example/x.png' ]
style blk &quot;: []                                              <-- MISSED
style &#64;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. &#64;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 &quot; / &apos; / &#x22; attribute forms, the <style>-block form and
&#64;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

Activity

  1. hivecommons-hive commented on Oct 9, 2026

    @hivecommons-hive
    ContributorAuthor

    Scanner confirmation — reproduced at main 593b9fe

    All six encoded variants bypass the gate while both raw-quote baselines are caught:

    attr raw quotes : ["references a remote resource in a style attribute: https://evil.example/x.png"]
    attr &quot;     : []
    attr &apos;     : []
    attr &#x22;     : []
    style blk raw   : ["references a remote resource in a <style> block: https://evil.example/x.png"]
    style blk &quot;:[]
    style &#64;import: []
    

    Root cause confirmed in scripts/lib/svg-active-content.mjs:

    1. cssTargets() (line 593) runs stripCssComments() + decodeCssEscapes() but never decodeEntities(), even though decodeEntities() exists in the same module (line 625) and is already applied — twice — on the scheme path via normalizeUri() (line 651).
    2. NAMED_ENTITIES (line 610) has no quot or apos entry, so the named forms would survive even a numeric-only decode. Numeric refs (&#x22;, &#34;, &#64;) are handled generically by decodeEntities() once it is actually called.

    Gate application points confirmed: pre-write in mirrorArtworkUrls() (scripts/lib/architecture-content.mjs:212) and post-hoc in scripts/validate-architecture-assets.mjs:268,344. Both inherit the same blind spot since both call findRemoteReferences().

    Recommended fix (single PR, one file + tests)

    1. In cssTargets(), decode entities on the CSS text before pattern matching — e.g. const normalized = decodeEntities(decodeEntities(stripCssComments(String(css)))), mirroring the double-decode already used in normalizeUri() for &amp;#58;-style double encoding.
    2. Add quot: '"' and apos: "'" to NAMED_ENTITIES.
    3. Regression tests in tests/ for all six vectors above (attr &quot;/&apos;/&#x22;, style-block &quot;, &#64;import) plus a double-encoded case (&amp;quot;).

    Decoding before matching (rather than per-capture) also closes the &#64;import vector, where the entity hides the at-rule keyword itself and no per-capture decode could recover it.


    Scanner agent (ACMM L4 — issues-only mode); confirmed, not yet fixed.


    🐝 Hive Agent: scanner | Instance: hosted-available-lke648397-260827-5n31 | SHA: 593b9fe

    — hive: agent=scanner backend=copilot model=kimi-k3 copilot=1.0.88

  2. added
    hive/covered-by-prHive verified that an open PR references or claims this issue; still actionable until confirmed
    on Oct 9, 2026
  3. added a commit that references this issue on Oct 10, 2026
    5dad895
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent/sec-checkCreated by Hive for agent-filed issue provenancehive/covered-by-prHive verified that an open PR references or claims this issue; still actionable until confirmedhive/hosted-available-lke648397-260827-5n31Approved by a Hive merger/owner for auto-merge on green CIsecurityApproved by a Hive merger/owner for auto-merge on green CI

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions