diff --git a/scripts/lib/svg-active-content.mjs b/scripts/lib/svg-active-content.mjs
index e61babeb..e618b710 100644
--- a/scripts/lib/svg-active-content.mjs
+++ b/scripts/lib/svg-active-content.mjs
@@ -278,8 +278,75 @@ function styleBlockContents(source) {
return blocks;
}
-/** A CSS `url(...)` target, quoted or bare. Covers `@font-face` `src` too. */
-const CSS_URL_PATTERN = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s"']*))\s*\)/gi;
+/**
+ * A CSS escape sequence: a hex escape of one to six digits with the single
+ * optional whitespace character that terminates it, a backslash-newline line
+ * continuation, or a backslash before any other single character.
+ */
+const CSS_ESCAPE_PATTERN =
+ /\\(?:([0-9a-f]{1,6})(?:\r\n|[ \n\r\t\f])?|(\r\n|[\n\r\f])|([\s\S]))/gi;
+
+/**
+ * Decode the CSS escape sequences in a url token or string.
+ *
+ * A browser's CSS tokenizer resolves escapes before the value is ever read as
+ * a URL, so `url(\68ttps://evil.example/x.css)` fetches
+ * `https://evil.example/x.css`. Scanning the raw text instead sees a value
+ * starting with a backslash, which is neither absolute nor protocol-relative,
+ * and the remote fetch walks past findRemoteReferences -- the gate that keeps
+ * a visitor's IP, User-Agent and Referer from reaching a host the diagram's
+ * author chose. Every escapable character is reachable this way, so no
+ * substring test on the undecoded value can stand in for decoding.
+ *
+ * This is CSS syntax only. Attribute values that are URLs rather than CSS keep
+ * a backslash's URL meaning, which remoteTarget already handles, so decoding
+ * stays scoped to the CSS callers.
+ *
+ * @param {string} value - Raw CSS url token or string contents.
+ * @returns {string}
+ */
+function decodeCssEscapes(value) {
+ return String(value).replace(
+ CSS_ESCAPE_PATTERN,
+ (match, hex, newline, literal) => {
+ if (hex !== undefined) {
+ const code = Number.parseInt(hex, 16);
+ // CSS maps NUL, the surrogate range and out-of-range code points to
+ // U+FFFD rather than to the character the digits name.
+ return code === 0 ||
+ code > 0x10ffff ||
+ (code >= 0xd800 && code <= 0xdfff)
+ ? '\ufffd'
+ : String.fromCodePoint(code);
+ }
+ // A backslash-newline inside a string is a line continuation: it
+ // contributes nothing, so the text either side joins up.
+ return newline !== undefined ? '' : literal;
+ },
+ );
+}
+
+/**
+ * A CSS `url(...)` target, quoted or bare. Covers `@font-face` `src` too.
+ *
+ * The bare branch spells out escape sequences rather than stopping at the
+ * first whitespace, because the whitespace that terminates a hex escape
+ * belongs to the escape: `url(\000068 ttps://evil.example/x.css)` is one url
+ * token whose value is `https://evil.example/x.css`, and a `[^)\s"']*` read
+ * would capture only `\000068` and lose the host entirely.
+ *
+ * The alternatives inside the bare branch are mutually exclusive on purpose.
+ * A single `\\[0-9a-f]{1,6}` alternative lets a hex run split across branches
+ * — `\abcdef` as `\abcde` plus a bare `f`, and so on — so an unterminated
+ * `url(` followed by repeated escapes backtracks exponentially (about 7x per
+ * repetition). findRemoteReferences runs on third-party SVGs, so that is a
+ * hang an upstream diagram can trigger. Splitting the hex run into a full
+ * six-digit form and a shorter form guarded by `(?![0-9a-f])`, and excluding
+ * hex digits from the single-character escape, leaves exactly one way to match
+ * any input and keeps the scan linear.
+ */
+const CSS_URL_PATTERN =
+ /url\(\s*(?:"([^"]*)"|'([^']*)'|((?:\\[0-9a-f]{6}[ \n\r\t\f]?|\\[0-9a-f]{1,5}(?![0-9a-f])[ \n\r\t\f]?|\\[^0-9a-f]|[^)\s"'\\])*))\s*\)/gi;
/**
* An `@import` whose target is a bare string rather than a `url(...)`.
@@ -407,7 +474,7 @@ function cssTargets(css) {
for (const pattern of [CSS_URL_PATTERN, CSS_IMPORT_PATTERN]) {
for (const match of String(css).matchAll(pattern)) {
const value = match[1] ?? match[2] ?? match[3];
- const target = remoteTarget(value);
+ const target = remoteTarget(decodeCssEscapes(value));
if (target) targets.push(target);
}
}
diff --git a/tests/svg-active-content.test.mjs b/tests/svg-active-content.test.mjs
index e83e4876..6d6e081f 100644
--- a/tests/svg-active-content.test.mjs
+++ b/tests/svg-active-content.test.mjs
@@ -1008,3 +1008,119 @@ test('accepts the UTF-8 encoding spellings a generator actually emits', () => {
);
}
});
+
+test('a CSS hex escape in a bare url() does not hide the host', () => {
+ // A browser's CSS tokenizer resolves escapes before the value is read as a
+ // URL, so `url(\68ttps://...)` fetches `https://...`. Scanning the raw text
+ // instead sees a value starting with a backslash -- neither absolute nor
+ // protocol-relative -- and the remote fetch walked past the gate.
+ const svg =
+ '';
+ assert.deepEqual(findRemoteReferences(svg), [
+ 'references a remote resource in a ';
+ assert.deepEqual(findRemoteReferences(svg), [
+ 'references a remote resource in a ';
+ assert.deepEqual(findRemoteReferences(svg), [
+ 'references a remote resource in a ';
+ const started = Date.now();
+ assert.deepEqual(findRemoteReferences(svg), []);
+ assert.ok(
+ Date.now() - started < 1000,
+ 'findRemoteReferences must stay linear on repeated unterminated escapes',
+ );
+});
+
+test('escaped separators still resolve to a protocol-relative target', () => {
+ const svg =
+ '';
+ assert.deepEqual(findRemoteReferences(svg), [
+ 'references a remote resource in a ';
+ assert.deepEqual(findRemoteReferences(svg), [
+ 'references a remote resource in a ';
+ assert.deepEqual(findRemoteReferences(svg), [
+ 'references a remote resource in a ';
+ assert.deepEqual(findRemoteReferences(svg), []);
+});
+
+test('an interior backslash in a local CSS path stays local', () => {
+ const svg =
+ '';
+ assert.deepEqual(findRemoteReferences(svg), []);
+});