diff --git a/scripts/lib/svg-active-content.mjs b/scripts/lib/svg-active-content.mjs
index 322bf53f..06cd0c3f 100644
--- a/scripts/lib/svg-active-content.mjs
+++ b/scripts/lib/svg-active-content.mjs
@@ -242,8 +242,22 @@ const CSS_IMPORT_PATTERN = /@import\s+(?:"([^"]*)"|'([^']*)')/gi;
*/
function remoteTarget(value) {
const normalized = normalizeUri(value);
- if (/^https?:\/\//.test(normalized) || normalized.startsWith('//')) {
- return normalized;
+ // The URL parser treats `\` as `/` in the scheme and authority prefix of a
+ // special-scheme URL, and the site is served over https, so every relative
+ // reference resolves against a special-scheme base. `\\host`, `/\host`,
+ // `\/host` and `https:\\host` therefore reach `host` exactly as `//host`
+ // does, while a `//`-only test reads all four as same-origin paths.
+ //
+ // Only the leading run of separators is translated. A single separator
+ // keeps the value on this origin (`\host` is the path `/host`, and
+ // `https:/host` likewise), and an interior backslash is an ordinary path
+ // character -- `./sub\dir/x.png` must stay local, not become a host.
+ const authority = normalized.replace(
+ /^(https?:)?[/\\]{2,}/,
+ (match, scheme) => `${scheme ?? ''}//`,
+ );
+ if (/^https?:\/\//.test(authority) || authority.startsWith('//')) {
+ return authority;
}
return null;
}
diff --git a/tests/svg-active-content.test.mjs b/tests/svg-active-content.test.mjs
index 9efdcacf..3cf3e942 100644
--- a/tests/svg-active-content.test.mjs
+++ b/tests/svg-active-content.test.mjs
@@ -454,6 +454,43 @@ test('detects a protocol-relative reference, which inherits https at the origin'
]);
});
+// The URL parser treats `\` as `/` in the scheme and authority prefix of a
+// special-scheme URL, and the site is served over https. Each value below
+// therefore reaches evil.example in a browser exactly as `//evil.example`
+// does, so a `//`-only remote test reads a cross-origin fetch as a local path.
+for (const [value, expected] of [
+ ['\\\\evil.example/b.png', '//evil.example/b.png'],
+ ['/\\evil.example/b.png', '//evil.example/b.png'],
+ ['\\/evil.example/b.png', '//evil.example/b.png'],
+ ['\\\\\\evil.example/b.png', '//evil.example/b.png'],
+ ['https:\\\\evil.example/b.png', 'https://evil.example/b.png'],
+ ['https:/\\evil.example/b.png', 'https://evil.example/b.png'],
+ ['https:\\/evil.example/b.png', 'https://evil.example/b.png'],
+]) {
+ test(`detects a backslash authority prefix: ${value}`, () => {
+ const svg = ``;
+ assert.deepEqual(findRemoteReferences(svg), [
+ `references a remote resource in href: ${expected}`,
+ ]);
+ });
+}
+
+// Only the leading run of separators is an authority. A single separator keeps
+// the value on this origin, and an interior backslash is an ordinary path
+// character -- flagging either would fail diagrams that reference their own
+// sibling assets.
+for (const [label, value] of [
+ ['a single leading backslash, which is a path on this origin', '\\local.png'],
+ ['a scheme with a single separator', 'https:/local.png'],
+ ['an interior backslash in a relative path', 'a\\b/c.png'],
+ ['a backslash inside a dot-relative path', './sub\\dir/x.png'],
+]) {
+ test(`does not flag ${label}`, () => {
+ const svg = ``;
+ assert.deepEqual(findRemoteReferences(svg), []);
+ });
+}
+
test('detects a remote url() in a a';