Skip to content
Closed
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
62 changes: 59 additions & 3 deletions scripts/lib/svg-active-content.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,63 @@ function describeTarget(target) {
return target.length > 120 ? `${target.slice(0, 117)}...` : target;
}

/**
* Attribute names whose value a browser fetches on load, on any element.
*
* `href` is deliberately absent: it is gated per-element by RESOURCE_ELEMENTS,
* because `<a href>` is navigation the visitor chooses rather than a load the
* page performs.
*
* The rest are here because `<foreignObject>` is allowed, so an imported
* diagram can carry ordinary HTML. `srcset` on `<img>`/`<source>`, `poster`
* on `<video>` and the legacy presentational `background` on `<body>`/
* `<table>`/`<td>` each make the browser fetch a third-party host with no
* user interaction, and none of them is active content, so neither
* findActiveContent() nor stripActiveContent() sees them. Gating only `src`
* let all three walk past this check and publish from static/ at the site
* origin, where a visitor opening the SVG directly -- a document, not an
* image, and served without the meta CSP that only Docusaurus HTML pages
* carry -- discloses their IP, User-Agent and Referer to the chosen host.
*/
const FETCHED_URL_ATTRIBUTES = new Set([
'src',
'srcset',
'poster',
'background',
]);

/**
* The remote targets named by one fetched-URL attribute value.
*
* `srcset` is a comma-separated candidate list, each candidate a URL followed
* by an optional descriptor, so the whole value is not a URL. Testing it
* undivided only happens to work when the list has one candidate:
* normalizeUri() strips the whitespace, so `"/local.png 1x,
* https://evil.example/x.png 2x"` collapses to a value that begins with
* `/local.png` and is read as same-origin, hiding the second candidate. Each
* candidate's URL part is therefore tested on its own. Splitting can never
* lose a remote target: a comma cannot appear in a scheme or an authority, so
* a URL containing one still carries its full authority in the first piece.
*
* @param {string} name - Local attribute name, lowercased.
* @param {string} value - Raw attribute value.
* @returns {string[]}
*/
function urlAttributeTargets(name, value) {
const raw = String(value ?? '');
const candidates =
name === 'srcset'
? raw.split(',').map((part) => part.trim().split(/\s+/)[0])
: [raw];
const targets = [];
for (const candidate of candidates) {
if (!candidate) continue;
const target = remoteTarget(candidate);
if (target && !targets.includes(target)) targets.push(target);
}
return targets;
}

/**
* Describe every remote resource reference in an SVG source string.
*
Expand All @@ -541,10 +598,9 @@ export function findRemoteReferences(source) {

if (
(name === 'href' && RESOURCE_ELEMENTS.has(element)) ||
name === 'src'
FETCHED_URL_ATTRIBUTES.has(name)
) {
const target = remoteTarget(value);
if (target) {
for (const target of urlAttributeTargets(name, value)) {
findings.add(
`references a remote resource in <${element}> ${match[1].toLowerCase()}: ${describeTarget(target)}`,
);
Expand Down
68 changes: 68 additions & 0 deletions tests/svg-active-content.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,74 @@ for (const [label, element] of [
});
}

// `<foreignObject>` is deliberately allowed, so an imported diagram can carry
// ordinary HTML. These attributes are fetched on load with no user
// interaction and carry no active content, so nothing else in this module
// reports them; gating only `src` published them from static/ at the site
// origin, where opening the SVG directly beacons the visitor to the chosen
// host.
test('detects a remote srcset on <img> in a foreignObject', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject width="1" height="1">' +
'<img xmlns="http://www.w3.org/1999/xhtml" srcset="https://evil.example/beacon.png 1x"/>' +
'</foreignObject></svg>';
assert.deepEqual(findRemoteReferences(svg), [
'references a remote resource in <img> srcset: https://evil.example/beacon.png',
]);
});

// The whole srcset value is not a URL. Normalizing it undivided strips the
// descriptors' whitespace and leaves a value beginning with the local first
// candidate, which reads as same-origin and hides every candidate after it.
test('detects a remote srcset candidate after a local one', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject width="1" height="1">' +
'<img xmlns="http://www.w3.org/1999/xhtml" srcset="/local.png 1x, https://evil.example/x.png 2x"/>' +
'</foreignObject></svg>';
assert.deepEqual(findRemoteReferences(svg), [
'references a remote resource in <img> srcset: https://evil.example/x.png',
]);
});

test('detects a remote srcset on <source> inside <picture>', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject width="1" height="1">' +
'<picture xmlns="http://www.w3.org/1999/xhtml">' +
'<source srcset="https://evil.example/b.webp"/><img src="/local.png"/>' +
'</picture></foreignObject></svg>';
assert.deepEqual(findRemoteReferences(svg), [
'references a remote resource in <source> srcset: https://evil.example/b.webp',
]);
});

test('detects a remote poster on <video>', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject width="1" height="1">' +
'<video xmlns="http://www.w3.org/1999/xhtml" poster="https://evil.example/beacon.png"></video>' +
'</foreignObject></svg>';
assert.deepEqual(findRemoteReferences(svg), [
'references a remote resource in <video> poster: https://evil.example/beacon.png',
]);
});

test('detects a remote legacy background attribute', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject width="1" height="1">' +
'<table xmlns="http://www.w3.org/1999/xhtml" background="https://evil.example/b.png">' +
'<tr><td>x</td></tr></table></foreignObject></svg>';
assert.deepEqual(findRemoteReferences(svg), [
'references a remote resource in <table> background: https://evil.example/b.png',
]);
});

test('leaves a srcset whose every candidate is local alone', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject width="1" height="1">' +
'<img xmlns="http://www.w3.org/1999/xhtml" srcset="/a.png 1x, ./b.png 2x"/>' +
'</foreignObject></svg>';
assert.deepEqual(findRemoteReferences(svg), []);
});

test('detects a remote xlink:href, the legacy spelling', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><image xlink:href="http://evil.example/b.png"/></svg>';
Expand Down
Loading