From 76d163940ceb33ed7cf2cda12e0376b48bacdb83 Mon Sep 17 00:00:00 2001 From: sec-check Date: Fri, 9 Oct 2026 14:10:22 -0400 Subject: [PATCH] fix(security): treat a slashless http: value as a remote authority remoteTarget() only recognised a remote value when it found two or more separators, which is right for https: -- the scheme the site is served over, where the URL parser goes relative -- but wrong for http:. A different special scheme sends the parser to "special authority ignore slashes", which skips however many separators follow, including none, and reads the next component as the host. http:evil.example/b.png therefore loaded from evil.example while findRemoteReferences() reported nothing, so a mirrored or imported SVG could beacon every visitor past the gate. Signed-off-by: sec-check --- scripts/lib/uri-safety.mjs | 27 +++++++++++++++++++ tests/svg-active-content.test.mjs | 44 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/scripts/lib/uri-safety.mjs b/scripts/lib/uri-safety.mjs index 7672d1a4..9814ef95 100644 --- a/scripts/lib/uri-safety.mjs +++ b/scripts/lib/uri-safety.mjs @@ -120,6 +120,33 @@ export function activeScheme(value) { */ export function remoteTarget(value) { const normalized = normalizeUri(value); + // A scheme does not make a value absolute on its own: the parser only + // enters the relative states when the value's scheme equals the base + // document's. The site is served over https (`url` in + // docusaurus.config.js), so `https:local.png` is the path `local.png` on + // this origin and is correctly left alone below. + // + // `http:` is a *different* special scheme, so it never reaches those + // states. It goes to "special authority ignore slashes", which skips + // however many `/` or `\` follow -- including none -- and reads what comes + // next as the host. `http:evil.example/b.png`, `http:/evil.example/b.png` + // and `http:\evil.example/b.png` therefore load from evil.example exactly + // as `http://evil.example/b.png` does, while matching neither the `//` + // test nor the two-or-more-separator rewrite below. + // + // Being cleartext does not make the reference harmless: a browser that + // blocks the mixed-content subresource has already been told to, and one + // that auto-upgrades it to https still sends the request -- with the + // visitor's IP, User-Agent and Referer -- to the host named here. + // + // Lookahead: a value with nothing after the separators (`http:`, `http:/`, + // `http://`) has no host, and one continuing with `?` or `#` is not a URL + // the parser accepts, so neither fetches anything to report. + const insecureAuthority = /^http:[/\\]*(?=[^/\\?#])/.exec(normalized); + if (insecureAuthority) { + return `http://${normalized.slice(insecureAuthority[0].length)}`; + } + // 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`, diff --git a/tests/svg-active-content.test.mjs b/tests/svg-active-content.test.mjs index 2d4c98d9..a838e26e 100644 --- a/tests/svg-active-content.test.mjs +++ b/tests/svg-active-content.test.mjs @@ -507,6 +507,50 @@ for (const [label, value] of [ }); } +// `http:` is not the scheme this site is served over, so the parser never +// treats it as relative: it skips however many separators follow -- including +// none -- and reads the next component as the host. Each value below loads +// from evil.example in a browser exactly as `http://evil.example` does, and a +// browser that auto-upgrades the mixed-content request still sends the +// visitor's IP, User-Agent and Referer to that host. +for (const [value, expected] of [ + ['http:evil.example/b.png', 'http://evil.example/b.png'], + ['http:/evil.example/b.png', 'http://evil.example/b.png'], + ['http:\\evil.example/b.png', 'http://evil.example/b.png'], + ['http:///evil.example/b.png', 'http://evil.example/b.png'], + ['HTTP:evil.example/b.png', 'http://evil.example/b.png'], +]) { + test(`detects a slashless http: authority: ${value}`, () => { + const svg = ``; + assert.deepEqual(findRemoteReferences(svg), [ + `references a remote resource in href: ${expected}`, + ]); + }); +} + +// The same shape under the *site's own* scheme is relative, and a scheme with +// no host after it is not a URL the parser accepts. Flagging either would +// fail a diagram that references its own sibling assets. +for (const [label, value] of [ + ['an https: value with no separator, which is a sibling path', 'https:a.png'], + ['an http: scheme with no host at all', 'http:'], + ['an http: scheme followed only by a query', 'http:?q'], + ['an http: scheme followed only by a fragment', 'http:#f'], +]) { + test(`does not flag ${label}`, () => { + const svg = ``; + assert.deepEqual(findRemoteReferences(svg), []); + }); +} + +test('detects a slashless http: authority in a CSS url()', () => { + const svg = + ''; + assert.deepEqual(findRemoteReferences(svg), [ + 'references a remote resource in a a';