Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions scripts/lib/uri-safety.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
44 changes: 44 additions & 0 deletions tests/svg-active-content.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<svg xmlns="http://www.w3.org/2000/svg"><image href="${value}"/></svg>`;
assert.deepEqual(findRemoteReferences(svg), [
`references a remote resource in <image> 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 = `<svg xmlns="http://www.w3.org/2000/svg"><image href="${value}"/></svg>`;
assert.deepEqual(findRemoteReferences(svg), []);
});
}

test('detects a slashless http: authority in a CSS url()', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><style>rect{fill:url(http:evil.example/b.png)}</style><rect/></svg>';
assert.deepEqual(findRemoteReferences(svg), [
'references a remote resource in a <style> block: http://evil.example/b.png',
]);
});

test('detects a remote url() in a <style> block, including @font-face src', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><style>@font-face{font-family:x;src:url(https://evil.example/f.woff)}</style><text style="font-family:x">a</text></svg>';
Expand Down
Loading