Skip to content

fix(security): decode CSS escapes before the remote-reference gate - #1090

Merged
mrbobbytables merged 2 commits into
mainfrom
sec/fix-css-escape-remote-refs
Oct 7, 2026
Merged

mrbobbytables merged 2 commits into
mainfrom
sec/fix-css-escape-remote-refs

Conversation

@hivecommons-hive

Copy link
Copy Markdown
Contributor

Security Fix

findRemoteReferences() is the gate that keeps an imported third-party SVG from
making a visitor's browser fetch a resource from a host the diagram's author
chose — a fetch that discloses the visitor's IP address, User-Agent and Referer.

It read CSS url(...) and @import values as raw text. A browser's CSS
tokenizer resolves escape sequences before the value is ever read as a URL, so
url(\68ttps://evil.example/x.css) fetches https://evil.example/x.css while
the scanner saw a value beginning with a backslash — neither absolute nor
protocol-relative — and remoteTarget() returned null. The fetch went
unreported in <style> blocks, in style= attributes and in SVG presentation
attributes alike.

What changed

Both changes are in scripts/lib/svg-active-content.mjs:

  • New decodeCssEscapes(), implementing CSS Syntax Level 3 escape rules:
    one-to-six hex digits plus the optional single terminating whitespace
    character, backslash-newline as a line continuation, and backslash before any
    other character as that literal character. NUL, the surrogate range and
    out-of-range code points decode to U+FFFD exactly as CSS specifies, so the
    decoder cannot invent a host no browser would fetch.
  • cssTargets() calls it on the captured value before remoteTarget().
    Decoding stays scoped to the CSS callers: attribute values that are URLs
    rather than CSS keep a backslash's URL meaning, which remoteTarget() already
    handles, and ./sub\dir/x.png must stay local.
  • CSS_URL_PATTERN's bare branch widens from [^)\s"']* to
    (?:\\[0-9a-f]{1,6}[ \n\r\t\f]?|\\[\s\S]|[^)\s"'\\])*. The whitespace that
    terminates a hex escape belongs to the escape, so
    url(\000068 ttps://evil.example/x.css) is one url token worth the full URL;
    the old branch captured only \000068 and lost the host, and decoding alone
    would not have caught that spelling.

Verification

Nine new tests in tests/svg-active-content.test.mjs cover the bare and quoted
escaped url(), the escape-terminating whitespace, escaped separators, the
literal-character escape, the line continuation, the presentation-attribute
spelling, the U+FFFD arms (NUL, surrogate, out-of-range), and the local
interior-backslash path that must keep reporting nothing.

  • node --test tests/svg-active-content.test.mjs — 101/101 pass
  • npm run test:unit:coverage:check — exits 0; scripts/lib/svg-active-content.mjs
    at 100.00% lines, tests/svg-active-content.test.mjs at 100.00%/100.00%
  • npx prettier --check and npx eslint on both changed files — clean

Scope

Touches only scripts/lib/svg-active-content.mjs and
tests/svg-active-content.test.mjs; no workflow file is involved. The test file
is shared with open PR #1088, which covers styleBlockContents' unterminated-CDATA
scan — a different function, and these cases are appended at the end of the file,
so the claimed ground (decodeCssEscapes, cssTargets, CSS_URL_PATTERN) is
disjoint from it.

Closes #1089


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

@hivecommons-hive hivecommons-hive Bot added the hold label Oct 5, 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 automatically remove the hold label once current policy no longer requires a level hold for "sec-check". If this is an outreach PR, a human must review it and remove the label.

@hivecommons-hive

Copy link
Copy Markdown
Contributor Author

CI note: run 37338929390 (Validate repository) on the current head f882695 had its non-required End-to-end coverage job cancelled by automation ~20 min in, with no replacement run queued. The required jobs (Validate repository, Lint repository, End-to-end tests) and CodeQL are all green on this head.

Rerun is not permitted at my tier (no workflows/rerun grant; policy also forbids empty retrigger commits). If you want coverage evidence on this head, a maintainer can rerun with:

gh run rerun 37338929390 --repo cncf/endusers --failed

Otherwise this is cosmetic — nothing blocks review.


🐝 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

@castrojo castrojo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Holding: the new CSS_URL_PATTERN in scripts/lib/svg-active-content.mjs (line 338) backtracks exponentially. The alternatives \\[0-9a-f]{1,6}[ \n\r\t\f]?, \\[\s\S] and [^)\s"'\\] overlap on hex escapes, so an unterminated url( followed by repeated \abcdef grows about 7x per repetition (measured on node: 6 reps 3 ms, 7 reps 23 ms, 8 reps 159 ms; 10 reps ~26 s). findRemoteReferences runs on third-party SVGs in import-architectures, validate-architecture-assets and the enduser collector, so one upstream diagram can hang those jobs. Make the alternatives mutually exclusive, so hex digits cannot be split across them, e.g. \\[0-9a-f]{6}[ \t\n\r\f]?|\\[0-9a-f]{1,5}(?![0-9a-f])[ \t\n\r\f]?|\\[^0-9a-f\n\r\f]|[^)\s"'\\] (or a tokenizer loop), and add a test with a long unterminated escaped url() that must finish quickly. Note this PR also touches tests/svg-active-content.test.mjs, as #1088 does; expect a rebase.

@hivecommons-hive

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed in 7203ec4 — thank you, the backtracking was real and I measured the same curve you did before changing anything: 6 reps 20ms, 8 reps 244ms, 9 reps 870ms, 10 reps 6.8s on url( + \\abcdef×n with no closing paren.

The fix. I took your suggested shape, with one deliberate difference:

\\[0-9a-f]{6}[ \n\r\t\f]?|\\[0-9a-f]{1,5}(?![0-9a-f])[ \n\r\t\f]?|\\[^0-9a-f]|[^)\s"'\\]

Your third alternative was \\[^0-9a-f\n\r\f]; I used \\[^0-9a-f] instead. Excluding the newline there would have dropped backslash-newline line continuations out of the bare branch, and decodeCssEscapes handles those deliberately (there is a test for it at tests/svg-active-content.test.mjs, 'a backslash-newline line continuation joins the text either side'). Keeping the newline in costs nothing for mutual exclusivity, since all three escape alternatives are still distinguished by whether the character after the backslash is a hex digit.

Evidence it is a backtracking-only change. I differential-fuzzed old vs new over 300,000 random url(...) inputs drawn from an alphabet of hex digits, non-hex letters, backslashes, both quote styles, parens, whitespace/newline/tab, and URL punctuation — comparing full matches and all three capture groups. Zero differences. So the escape-decoding semantics the rest of the PR depends on are untouched.

Timings after. Linear: the four adversarial shapes (unterminated hex, hex+space, short hex, mixed) all run in ≤1ms at 2,000 repetitions.

Test. Added 'an unterminated escaped url() does not backtrack exponentially' — 400 unterminated escapes, asserts an empty result and a sub-second runtime. I verified it is a real regression test rather than a tautology: stashing the source fix and running that one test alone, it does not complete in 180s and node reports the event loop resolved with the promise still pending. With the fix it runs in 0.37ms. Full file: 102/102 pass, prettier clean.

On the rebase. gh pr view reports this PR MERGEABLE against current main, so I pushed the fix as a plain fast-forward commit rather than rebasing — no history rewrite, and the two commits stay separately reviewable. If #1088 lands first and this does conflict in tests/svg-active-content.test.mjs, say so and I will rebase then.

The hold label stays on; this still needs your review.


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

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

sec-check added 2 commits October 7, 2026 07:23
A browser's CSS tokenizer resolves escape sequences before a url() or
@import value is ever read as a URL, so url(\68ttps://evil.example/x.css)
fetches https://evil.example/x.css. findRemoteReferences() scanned the raw
text, saw a value starting with a backslash -- neither absolute nor
protocol-relative -- and let the fetch past the gate that keeps a visitor's
IP, User-Agent and Referer off a host the diagram's author chose.

Decode escapes in the CSS callers only: attribute values that are URLs
rather than CSS keep a backslash's URL meaning, which remoteTarget()
already handles.

CSS_URL_PATTERN's bare branch needed widening too. The single whitespace
character that terminates a hex escape belongs to the escape, so
url(\000068 ttps://evil.example/x.css) is one token worth the full URL
while [^)\s"']* captured only \000068 and lost the host.

NUL, the surrogate range and out-of-range code points decode to U+FFFD as
CSS specifies, so the decoder cannot invent a host no browser fetches.

Signed-off-by: sec-check <sec-check@hive.kubestellar.io>
The bare-url alternatives added in the previous commit overlapped on hex
escapes: `\\[0-9a-f]{1,6}` could match `\abcde` and leave the trailing `f`
to `[^)\s"'\\]`, and `\\[\s\S]` could take any of the same escapes a third
way. With no closing `)` to stop the search, every repetition multiplied
the number of ways to carve the same text, so an unterminated `url(`
followed by repeated `\abcdef` grew about 7x per repetition: 8 reps 244ms,
10 reps 6.8s, and 400 reps does not finish.

findRemoteReferences() runs over third-party SVGs in import-architectures,
validate-architecture-assets and the enduser collector, so a single
upstream diagram could hang each of those jobs -- the scanner meant to
gate untrusted input was itself a denial-of-service sink for it.

Make the alternatives mutually exclusive so each input has exactly one
parse: a full six-digit escape, a shorter escape guarded by `(?![0-9a-f])`
so it cannot split a longer run, a single-character escape that excludes
hex digits, and the unchanged bare character class. Differential fuzzing
over 300,000 random url() inputs found no match or capture-group
difference against the old pattern, so this is a backtracking fix only and
the escape-decoding behaviour is unchanged.

The regression test scans 400 unterminated escapes and asserts it finishes
within a second; against the old pattern it does not complete in 180s.

Signed-off-by: sec-check <sec-check@hive.kubestellar.io>
@mrbobbytables
mrbobbytables dismissed castrojo’s stale review October 7, 2026 12:23

Addressed by the follow-up commit: the bare-url alternatives are now mutually exclusive (full six-digit hex form, short form guarded by (?![0-9a-f]), single-char escape excluding hex digits), and the regression test 'an unterminated escaped url() does not backtrack exponentially' pins linear-time behavior. Dismissing to unblock the merge queue; re-review welcome.

@mrbobbytables
mrbobbytables force-pushed the sec/fix-css-escape-remote-refs branch from 7203ec4 to 053d3c7 Compare October 7, 2026 12:23
@mrbobbytables
mrbobbytables added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit c48fa1c Oct 7, 2026
7 checks passed
@mrbobbytables
mrbobbytables deleted the sec/fix-css-escape-remote-refs branch October 7, 2026 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[sec-check] CSS escapes hide remote url()/@import targets from findRemoteReferences

2 participants