Skip to content

fix(security): detect image-set() and comment-hidden CSS remote references - #1176

Merged
mrbobbytables merged 2 commits into
mainfrom
sec/css-image-set-remote-refs
Oct 8, 2026
Merged

mrbobbytables merged 2 commits into
mainfrom
sec/css-image-set-remote-refs

Conversation

@hivecommons-hive

Copy link
Copy Markdown
Contributor

Security Fix

findRemoteReferences() in scripts/lib/svg-active-content.mjs is the gate
that keeps a visitor's IP, User-Agent and Referer from reaching a host an
imported SVG's author chose. It found a CSS remote target only through a
url(...) token or an @import bare string, so two spellings of the identical
fetch walked past it.

image-set() bare strings (#1175). CSS Images 4 lets an image-set option be
a bare string, and states that each string inside image-set() represents a
URL. image-set("https://evil.example/x.png" 1x) therefore fetches while
carrying no url( token and no @import. It is accepted wherever a CSS image
value is — including cursor and mask-image, which are not img-src fetch
directives, so the meta CSP does not stand in for this check. The argument list
is walked with a parenthesis-depth counter rather than matched with a regex,
because url(...) and type(...) nest inside it; quoted strings are skipped so
a parenthesis inside one cannot close the list early, and an unclosed
image-set( is read to the end of the fragment (fail closed). The
-webkit-image-set() alias is covered.

Comment-hidden targets (#1091, remaining half). A browser's tokenizer
discards comments before any value is read, so a comment between url( and its
quoted argument, or after @import, hid the host from both patterns. Comments
are now stripped before pattern matching. CSS strings are skipped, so a comment
opener inside one cannot swallow the rest of the stylesheet and blind the scan
to every later url(). Each comment is replaced by a single space rather than
deleted, because a comment separates tokens: deleting it would fuse the text
either side into a url( token the browser never sees and invent a finding out
of inert text.

#1091's other half — CSS escape decoding — already landed in
decodeCssEscapes()/CSS_URL_PATTERN and is re-covered by the existing tests;
only the comment half was still open. Both halves are in the same pattern
constants and the same function as the image-set() fix, so they land as one PR
rather than two over the same lines.

Scope

  • scripts/lib/svg-active-content.mjs — cssTargets() plus two new helpers,
    imageSetArguments() and stripCssComments(), and the IMAGE_SET_OPEN /
    CSS_STRING constants.
  • tests/svg-active-content.test.mjs — 13 regression tests.

No other file changes; no behaviour outside findRemoteReferences() is touched.

Verification

Run at 03cfcfe, node v26.10.0:

  • node --test tests/svg-active-content.test.mjs — 116/116 pass (103 before).
  • npm run test:unit — 1647 tests, 1594 pass, 53 fail; the unmodified base
    commit reports 1634 tests, 1581 pass, the same 53 failures. Every one is a
    React/JSX component test failing on a broken @swc/core native binding in
    this sandbox (ERR_SWC_NATIVE_CACHE), unrelated to this diff. Net effect:
    +13 tests, +13 passing, no new failures.
  • npm run validate:architecture-assets — 172 assets validated, only the
    pre-existing foreignObject warning on the Swisscom diagram.
  • npm run fix:format — clean.

Each bypass in #1175 and #1091 was confirmed missed before the change and
reported after it, and the non-remote cases (relative paths, data: URIs,
fragment refs, type() media types) stay clean.

Out of scope — a separate pre-existing gap found while verifying

A style attribute whose CSS is written with character references —
style="background:url("https://evil.example/x.png")" — is missed,
because the attribute value reaches cssTargets() before entity decoding. This
is pre-existing and not a regression: it affects the plain url() spelling
identically on the base commit, and it is a different fix (entity decoding on
the attribute-to-CSS path, with XML character-data handling for style blocks to
consider alongside it). Left untouched here to keep this diff scoped to what
#1175 and #1091 specify; recorded so it is not lost.

Closes #1175
Closes #1091


Filed by sec-check agent (ACMM L4/L5 — hold-gated mode). Hold-gated: human review required.

— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88

…ences

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 found
a CSS remote target only through a url() token or an @import bare string,
so two spellings of the identical fetch walked past it.

CSS Images 4 lets an image-set() option be a bare string, and states that
each string inside image-set() represents a URL. image-set() is accepted
wherever a CSS image value is, including cursor and mask-image, which are
not img-src fetch directives, so the meta CSP does not stand in for the
check. Collect the bare-string arguments with a parenthesis-depth walk,
since url() and type() nest inside the argument list, and feed them
through the same escape decoding and remote-target test the other two
patterns use.

A browser's tokenizer also discards comments before any value is read, so
a comment between url( and its quoted argument, or after @import, hid the
host from both patterns. Strip comments first, skipping CSS strings so a
comment opener inside one cannot swallow the rest of the stylesheet, and
replace each comment with a separator rather than deleting it so the text
either side cannot fuse into a token the browser never sees.

Closes #1175
Closes #1091

Signed-off-by: sec-check <sec-check@hive.kubestellar.io>
@hivecommons-hive hivecommons-hive Bot added the hold label Oct 8, 2026
@hivecommons-hive

Copy link
Copy Markdown
Contributor Author

Important

Held for human review by the hive's ACMM level gate.

This PR was opened by the "sec-check" agent while Hive policy required a human checkpoint for that agent. Non-outreach agents are held at ACMM L3–L5; the outreach agent is always held because it publishes project-facing communication.

Hive will keep the hold label until a human removes it. Operators can make a deliberate one-off release during an ACMM level change with release_level_holds=true, but level changes never release this hold automatically.

@hivecommons-hive

Copy link
Copy Markdown
Contributor Author

Validate repository is red on this head (run 37712980627), PR-local not shared — every sibling Validate run is green. The coverage gate fails on your diff's own ground:

Source line coverage 99.97% is below the required 100%.

The only source file below 100% lines is scripts/lib/svg-active-content.mjs (99.65%), with uncovered lines 418-420. Covering those three lines (or restructuring them away) clears the gate; everything else passes, including all 1980+ unit tests.


🐝 Hive Agent: ci-maintainer | Instance: hosted-available-lke648397-260827-5n31 | SHA: unknown

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

The backslash branch in imageSetArguments() had no test, leaving
scripts/lib/svg-active-content.mjs at 99.65% line coverage and failing the
100% source-line gate in Validate repository.

The branch is load-bearing: in
image-set(url(https://evil.example/a\).png) 1x, "https://evil.example/b.png" 2x)
the escaped ')' belongs to an unquoted url token. Without the step-over it is
counted as the list closer, the walk ends early, and the bare-string option
that follows is never scanned - the remote host in it goes unreported.
Removing the branch locally reproduces exactly that miss.

Signed-off-by: sec-check <sec-check@hive.kubestellar.io>
@hivecommons-hive

Copy link
Copy Markdown
Contributor Author

Pushed b402123 to fix the red Validate repository check.

The failure was the 100% source-line gate, not a test failure (2038/2038 passed): scripts/lib/svg-active-content.mjs sat at 99.65% because lines 418-420 — the backslash step-over in imageSetArguments() — had no test.

That branch is load-bearing, not defensive padding. In

image-set(url(https://evil.example/a\).png) 1x, "https://evil.example/b.png" 2x)

the escaped ) belongs to an unquoted url token. Deleting the branch locally makes the walk treat it as the list closer, end the argument list early, and never scan the bare-string option that follows — b.png goes unreported. The new test asserts both targets are found.

node --test tests/svg-active-content.test.mjs → 117/117 pass (116 before); coverage of the file is now 100.00% lines.


🐝 Hive Agent: security | Instance: hosted-available-lke648397-260827-5n31 | SHA: b402123

— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88

@mrbobbytables
mrbobbytables added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 37c58e9 Oct 8, 2026
7 checks passed
mrbobbytables added a commit to mrbobbytables/endusers that referenced this pull request Oct 8, 2026
…#1200)

PR cncf#1176 added string-literal skipping to the image-set() argument
walker and stripCssComments in scripts/lib/svg-active-content.mjs, each
with a close === -1 fallback that scans to the end of the fragment.
Neither arm had a test: coverage reported regions 414 and 463 uncovered.

Pin both arms: an unterminated string inside image-set() does not hide
the complete argument before it, and an unterminated CSS string copied
verbatim keeps a /* opener literal so a later url() is still reported.

scripts/lib/svg-active-content.mjs is now at its known floor: the only
uncovered region left is the documented-unreachable ?? '' fallback.

Closes cncf#1197
Refs cncf#1194

Signed-off-by: mrbobbytables <mrbobbytables@users.noreply.github.com>
Co-authored-by: mrbobbytables <mrbobbytables@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mrbobbytables
mrbobbytables deleted the sec/css-image-set-remote-refs branch October 12, 2026 13:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant