commit d2ef48d7d9a1826c4d3b15e747acd4e26f65b0da
parent e13a20a96911e681dec15ea9884add09f01af7c8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 19:50:52 -0400
common: the icon checker drops title and desc, refuses anything that loads from elsewhere, allows presentation styles only, and names how to export an acceptable file (re-review R1, R2, R5, R6, R8, R9)
- R1: title and desc are no longer allowed (HTML integration points, where a
child element left the icon unclosed around the page); one holding text
only is removed before the check, one with a child is refused.
- R2: after decoding character references, a backslash (CSS escape), a CSS
comment, and any function that can load something (image-set,
-webkit-image-set, image, cross-fade, element, -moz-element, src, paint,
@import, expression) are refused in every value, an animation's to/from/
values/by included; a style attribute holds presentation properties only
(fill, stroke, stop-color, opacities, stroke width/caps/joins, fill-rule,
clip-rule, display, visibility, paint-order).
- R5: an animation may not target anything named href, or a handler, in any
prefixed form. R6: an href must be a plain #id as written, and every url()
one scopeSvgIds rewrites.
- R8: a named tag or attribute is reduced to [A-Za-z0-9_.:-]. R9: the
element, attribute and style reasons say to export with presentation
attributes (in Inkscape, save as Plain SVG); a DOCTYPE with an internal
subset has its own reason.
- socialSvg.vectors.ts: the review's battery, the eight that loaded from
elsewhere, and the shapes real icons take; unit tests over all of them.
- SETTINGS.md / SITE.md: what an icon may contain.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
6 files changed, 365 insertions(+), 36 deletions(-)
diff --git a/SETTINGS.md b/SETTINGS.md
@@ -473,7 +473,7 @@ Per entry — each entry spells its own values.
|---|---|
| `label` | The link's name: the icon's accessible name and its tooltip, never text beside it. Shown as text only in place of an icon that fails the check at render. |
| `url` | Link target: http(s), mailto: or a site-relative path. |
-| `svg` | Inline SVG markup: ONE well-formed `<svg>` element, checked on save and again every time it is rendered (a link whose icon fails at render shows its label instead). It may contain shapes, groups, defs, gradients, patterns, clip paths, masks, filters, text, title/desc and animate/animateTransform/set — no script, style, foreignObject, a, image or any HTML element; SVG presentation attributes plus aria-*, data-* and xmlns:* — no event handler (on…); an href or url(…) only to an id inside the icon; ids plain names. A leading XML declaration, a DOCTYPE without an internal subset and comments are removed. Normalized on save: width/height stripped, aria-hidden added, a single-colour icon's fills made fill="currentColor" (an icon of two or more colours keeps them). A root with no viewBox but a numeric width W and height H (unitless or px) is given `viewBox="0 0 W H"`, so a file pasted as downloaded is accepted. A refused icon's save names why. |
+| `svg` | Inline SVG markup: ONE well-formed `<svg>` element, checked when it is saved new or edited and again every time it is rendered (a link whose icon fails at render shows its label instead; `archilyzer doctor` names it). It may contain shapes, groups, defs, gradients, patterns, clip paths, masks, filters, text and animate/animateTransform/set — no script, style block, foreignObject, a, image, title, desc or any HTML element (a title or desc holding text only is removed); SVG presentation attributes plus aria-*, data-* and xmlns:* — no event handler (on…); a `style` attribute of presentation properties only; an href or url(…) only to an id inside the icon, written plainly; no CSS escape, comment or function that loads anything (image-set, image, cross-fade, element, src, paint, @import); ids plain names. A leading XML declaration, a DOCTYPE without an internal subset and comments are removed. Normalized on save: width/height stripped, aria-hidden added, a single-colour icon's fills made fill="currentColor" (an icon of two or more colours keeps them). A root with no viewBox but a numeric width W and height H (unitless or px) is given `viewBox="0 0 W H"`, so a file pasted as downloaded is accepted. A refused save names why; export from a drawing program with presentation attributes rather than a style block (in Inkscape, save as Plain SVG). |
| `featured` | Show this link in a header. A header shows at most 4 links: the featured ones when any link is marked, else all of them; of those, the last 4 (a narrow header shows fewer). The footer shows every link. Written only when true. |
Default:
diff --git a/SITE.md b/SITE.md
@@ -79,7 +79,7 @@ Per entry — each entry spells its own values.
|---|---|
| `label` | The link's name: the icon's accessible name and its tooltip, never text beside it. Shown as text only in place of an icon that fails the check at render. |
| `url` | Link target: http(s), mailto: or a site-relative path. |
-| `svg` | Inline SVG markup: ONE well-formed `<svg>` element, checked on save and again every time it is rendered (a link whose icon fails at render shows its label instead). It may contain shapes, groups, defs, gradients, patterns, clip paths, masks, filters, text, title/desc and animate/animateTransform/set — no script, style, foreignObject, a, image or any HTML element; SVG presentation attributes plus aria-*, data-* and xmlns:* — no event handler (on…); an href or url(…) only to an id inside the icon; ids plain names. A leading XML declaration, a DOCTYPE without an internal subset and comments are removed. Normalized on save: width/height stripped, aria-hidden added, a single-colour icon's fills made fill="currentColor" (an icon of two or more colours keeps them). A root with no viewBox but a numeric width W and height H (unitless or px) is given `viewBox="0 0 W H"`, so a file pasted as downloaded is accepted. A refused icon's save names why. |
+| `svg` | Inline SVG markup: ONE well-formed `<svg>` element, checked when it is saved new or edited and again every time it is rendered (a link whose icon fails at render shows its label instead; `archilyzer doctor` names it). It may contain shapes, groups, defs, gradients, patterns, clip paths, masks, filters, text and animate/animateTransform/set — no script, style block, foreignObject, a, image, title, desc or any HTML element (a title or desc holding text only is removed); SVG presentation attributes plus aria-*, data-* and xmlns:* — no event handler (on…); a `style` attribute of presentation properties only; an href or url(…) only to an id inside the icon, written plainly; no CSS escape, comment or function that loads anything (image-set, image, cross-fade, element, src, paint, @import); ids plain names. A leading XML declaration, a DOCTYPE without an internal subset and comments are removed. Normalized on save: width/height stripped, aria-hidden added, a single-colour icon's fills made fill="currentColor" (an icon of two or more colours keeps them). A root with no viewBox but a numeric width W and height H (unitless or px) is given `viewBox="0 0 W H"`, so a file pasted as downloaded is accepted. A refused save names why; export from a drawing program with presentation attributes rather than a style block (in Inkscape, save as Plain SVG). |
| `featured` | Show this link in a header. A header shows at most 4 links: the featured ones when any link is marked, else all of them; of those, the last 4 (a narrow header shows fewer). The footer shows every link. Written only when true. |
## `groups`
diff --git a/common/lib/normalizeSocialSvg.test.ts b/common/lib/normalizeSocialSvg.test.ts
@@ -2,6 +2,8 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { normalizeSocialSvg, socialSvgProblem } from "./settingsSchema";
import { SVG_PROBLEM } from "./socialSvg";
+import { scopeSvgIds, sizeSocialSvg } from "./socialLinks";
+import { ADVERSARIAL, LOADS_ELSEWHERE, REAL_SHAPES } from "./socialSvg.vectors";
// normalizeSocialSvg runs on every save of a social link (Settings, a site's
// form, the homepage config). It used to theme only the ROOT <svg>'s fill, so a
@@ -370,7 +372,7 @@ test("attributes an icon has no use for, named; a style with an escape or an imp
assert.equal(socialSvgProblem(OK(`<rect formaction="x"/>`)), SVG_PROBLEM.attribute("formaction"));
assert.equal(socialSvgProblem(OK(`<rect inkscape:label="x"/>`)), SVG_PROBLEM.attribute("inkscape:label"));
assert.equal(socialSvgProblem(OK(`<rect style="fill:u\\72l(x)"/>`)), SVG_PROBLEM.style);
- assert.equal(socialSvgProblem(OK(`<rect style="@import 'x'"/>`)), SVG_PROBLEM.style);
+ assert.equal(socialSvgProblem(OK(`<rect style="@import 'x'"/>`)), SVG_PROBLEM.external);
});
test("markup that is not one well-formed <svg>: declarations, CDATA, instructions, strays", () => {
@@ -397,8 +399,9 @@ test("comments are removed first; an XML declaration and a plain DOCTYPE at the
assert.equal(out, `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 8 8"><path d="M0 0"/></svg>`);
// A comment cannot hide a handler: what is left after removal is checked.
assert.equal(socialSvgProblem(`<svg viewBox="0 0 8 8" on<!-- -->load="x()"></svg>`), SVG_PROBLEM.handler);
- // A DOCTYPE with an internal subset is not removed, and is refused.
- assert.equal(socialSvgProblem(`<!DOCTYPE svg [<!ENTITY x "y">]><svg viewBox="0 0 8 8"></svg>`), SVG_PROBLEM.markup);
+ // A DOCTYPE with an internal subset is not removed, and is refused with
+ // what to do about it.
+ assert.equal(socialSvgProblem(`<!DOCTYPE svg [<!ENTITY x "y">]><svg viewBox="0 0 8 8"></svg>`), SVG_PROBLEM.doctype);
});
test("an id a rendered copy cannot prefix safely is refused", () => {
@@ -423,3 +426,124 @@ test("the shapes real icons take are accepted", () => {
assert.equal(socialSvgProblem(out), null);
}
});
+
+// ── THE RE-REVIEW (R1, R2, R5, R6, the style properties) ────────────────────
+
+test("R1: a title or desc with text only is removed; with a child element it is refused", () => {
+ assert.equal(
+ normalized(OK(`<title>A name</title><desc/><path d="M0 0"/>`)),
+ `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 8 8"><path d="M0 0"/></svg>`,
+ );
+ for (const raw of [
+ OK(`<title><path d="M0 0"/></title>`),
+ OK(`<desc><g></g></desc>`),
+ OK(`<title><svg viewBox="0 0 1 1"><title>t</title></svg></title>`),
+ ]) {
+ assert.equal(socialSvgProblem(raw), SVG_PROBLEM.element(raw.includes("<desc>") ? "desc" : "title"), raw);
+ }
+});
+
+test("R2: the eight inputs that loaded from another origin are refused", () => {
+ for (const name of LOADS_ELSEWHERE) {
+ assert.equal(normalizeSocialSvg(ADVERSARIAL[name]), null, name);
+ }
+ for (const raw of [
+ OK(`<rect fill="\\75 rl(#a)"/>`),
+ OK(`<rect style="fill:url/**/(#a)"/>`),
+ OK(`<rect mask="cross-fade(url(#a), url(#b))"/>`),
+ OK(`<rect mask="-webkit-cross-fade(url(#a), url(#b))"/>`),
+ OK(`<rect style="fill:element(#a)"/>`),
+ OK(`<rect style="fill:-moz-element(#a)"/>`),
+ OK(`<rect style="fill:image(#a)"/>`),
+ OK(`<rect style="fill:src(#a)"/>`),
+ OK(`<rect style="fill:paint(x)"/>`),
+ OK(`<rect style="fill:expression(x)"/>`),
+ OK(`<rect><set attributeName="fill" to="image-set('${"https://x.example"}/a.png' 1x)"/></rect>`),
+ ]) {
+ assert.ok(socialSvgProblem(raw), raw);
+ }
+});
+
+test("a style holds presentation properties only", () => {
+ for (const decl of ["position:fixed", "inset:0", "z-index:9", "top:0", "width:100vw", "background:red", "cursor:pointer", "transform:scale(9)"]) {
+ assert.equal(socialSvgProblem(OK(`<rect style="${decl}"/>`)), SVG_PROBLEM.style, decl);
+ }
+ assert.equal(
+ socialSvgProblem(OK(`<rect style="fill:#fff; stroke:#000;stroke-width:2;opacity:.5;paint-order:stroke;display:inline"/>`)),
+ null,
+ );
+});
+
+test("R5: an animation targets nothing named href and no handler, in any spelling", () => {
+ for (const attr of ["x:href", " HREF ", "xlink:href", "href"]) {
+ assert.equal(socialSvgProblem(OK(`<set attributeName="${attr}" to="#a"/>`)), SVG_PROBLEM.external, attr);
+ }
+ for (const attr of ["onclick", "xlink:onclick", "x:onload"]) {
+ assert.equal(socialSvgProblem(OK(`<set attributeName="${attr}" to="1"/>`)), SVG_PROBLEM.handler, attr);
+ }
+});
+
+test("R6: a reference the render cannot scope is refused: an encoded or padded fragment", () => {
+ for (const raw of [
+ OK(`<defs><g id="a"/></defs><use href="#a"/>`),
+ OK(`<defs><g id="a"/></defs><use href=" #a "/>`),
+ OK(`<defs><g id="a"/></defs><rect fill="url(#a)"/>`),
+ OK(`<defs><g id="a"/></defs><rect fill="url(#a)"/>`),
+ ]) {
+ assert.equal(socialSvgProblem(raw), SVG_PROBLEM.external, raw);
+ }
+ // …and each accepted spelling is one scopeSvgIds rewrites.
+ const ok = normalized(OK(`<defs><g id="a"/></defs><use href="#a"/><rect fill="url(#a)" mask="url("#a")" style="fill:url('#a')"/>`));
+ const scoped = scopeSvgIds(ok, "s");
+ assert.ok(!/#a\b/.test(scoped.replace(/#s-a/g, "")), scoped);
+});
+
+test("the shapes real icons take pass, stay stable and survive the render", () => {
+ for (const [name, raw] of Object.entries(REAL_SHAPES)) {
+ const out = normalizeSocialSvg(raw);
+ assert.ok(out, `${name}: ${socialSvgProblem(raw)}`);
+ assert.equal(normalizeSocialSvg(out), out, `${name} idempotent`);
+ assert.ok(normalizeSocialSvg(sizeSocialSvg(scopeSvgIds(out, "sl_S_1_-0"))), `${name} rendered`);
+ }
+ assert.ok(normalized(REAL_SHAPES.gradient_outlined).includes('viewBox="-3 -3 76.3 80.8"'));
+});
+
+// The review's whole battery: an input is refused, or its output is stable,
+// survives the render's scoping and sizing, and — the structural half of R1's
+// invariant — holds only SVG elements the HTML parser keeps in foreign content
+// (no title, desc, foreignObject or HTML element can reach a page). The
+// browser half (a real parser, #after outside the icon) is the homepage e2e's
+// svg-vectors.spec.ts: the repo has no HTML parser to run it here.
+const FOREIGN_SAFE = new Set([
+ "svg", "g", "defs", "symbol", "use", "path", "rect", "circle", "ellipse", "line",
+ "polyline", "polygon", "text", "tspan", "lineargradient", "radialgradient", "stop",
+ "pattern", "clippath", "mask", "filter", "feblend", "fecolormatrix",
+ "fecomponenttransfer", "fecomposite", "fedropshadow", "feflood", "fefunca", "fefuncb",
+ "fefuncg", "fefuncr", "fegaussianblur", "femerge", "femergenode", "femorphology",
+ "feoffset", "animate", "animatetransform", "set",
+]);
+
+test("every adversarial input is refused, or accepted in a form that stays inside its <svg>", () => {
+ let accepted = 0;
+ for (const [name, raw] of Object.entries(ADVERSARIAL)) {
+ const out = normalizeSocialSvg(raw);
+ if (out === null) {
+ assert.ok(socialSvgProblem(raw), name);
+ continue;
+ }
+ accepted += 1;
+ assert.equal(normalizeSocialSvg(out), out, `${name} idempotent`);
+ const rendered = sizeSocialSvg(scopeSvgIds(out, "sl_S_1_-0"));
+ assert.ok(normalizeSocialSvg(rendered), `${name} rendered`);
+ // Tag names outside attribute values (a quoted `<img>` is a value, and inert).
+ const markup = rendered.replace(/"[^"]*"|'[^']*'/g, '""');
+ for (const m of markup.matchAll(/<\/?([A-Za-z][\w:.-]*)/g)) {
+ assert.ok(FOREIGN_SAFE.has(m[1].toLowerCase()), `${name}: <${m[1]}>`);
+ }
+ assert.ok(!/<!|<\?/.test(markup), `${name}: markup`);
+ }
+ assert.ok(accepted > 10, `${accepted} accepted`);
+ for (const name of ["title_child_el", "desc_child_el", "title_title", "title_svg_title", "overlay_style", ...LOADS_ELSEWHERE]) {
+ assert.equal(normalizeSocialSvg(ADVERSARIAL[name]), null, name);
+ }
+});
diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts
@@ -563,20 +563,26 @@ export const SOCIAL_LINK_FIELD_DOCS: FieldDocs<SocialLink> = {
url:
"Link target: http(s), mailto: or a site-relative path.",
svg:
- "Inline SVG markup: ONE well-formed `<svg>` element, checked on save and " +
- "again every time it is rendered (a link whose icon fails at render shows " +
- "its label instead). It may contain shapes, groups, defs, gradients, " +
- "patterns, clip paths, masks, filters, text, title/desc and " +
- "animate/animateTransform/set — no script, style, foreignObject, a, " +
- "image or any HTML element; SVG presentation attributes plus aria-*, " +
- "data-* and xmlns:* — no event handler (on…); an href or url(…) only to " +
- "an id inside the icon; ids plain names. A leading XML declaration, a " +
- "DOCTYPE without an internal subset and comments are removed. Normalized " +
- "on save: width/height stripped, aria-hidden added, a single-colour " +
- "icon's fills made fill=\"currentColor\" (an icon of two or more " +
- "colours keeps them). A root with no viewBox but a numeric width W and " +
- "height H (unitless or px) is given `viewBox=\"0 0 W H\"`, so a file " +
- "pasted as downloaded is accepted. A refused icon's save names why.",
+ "Inline SVG markup: ONE well-formed `<svg>` element, checked when it is " +
+ "saved new or edited and again every time it is rendered (a link whose " +
+ "icon fails at render shows its label instead; `archilyzer doctor` names " +
+ "it). It may contain shapes, groups, defs, gradients, patterns, clip " +
+ "paths, masks, filters, text and animate/animateTransform/set — no " +
+ "script, style block, foreignObject, a, image, title, desc or any HTML " +
+ "element (a title or desc holding text only is removed); SVG " +
+ "presentation attributes plus aria-*, data-* and xmlns:* — no event " +
+ "handler (on…); a `style` attribute of presentation properties only; an " +
+ "href or url(…) only to an id inside the icon, written plainly; no CSS " +
+ "escape, comment or function that loads anything (image-set, image, " +
+ "cross-fade, element, src, paint, @import); ids plain names. A leading " +
+ "XML declaration, a DOCTYPE without an internal subset and comments are " +
+ "removed. Normalized on save: width/height stripped, aria-hidden added, " +
+ "a single-colour icon's fills made fill=\"currentColor\" (an icon of two " +
+ "or more colours keeps them). A root with no viewBox but a numeric width " +
+ "W and height H (unitless or px) is given `viewBox=\"0 0 W H\"`, so a " +
+ "file pasted as downloaded is accepted. A refused save names why; export " +
+ "from a drawing program with presentation attributes rather than a style " +
+ "block (in Inkscape, save as Plain SVG).",
featured:
"Show this link in a header. A header shows at most 4 links: the " +
"featured ones when any link is marked, else all of them; of those, the " +
diff --git a/common/lib/socialSvg.ts b/common/lib/socialSvg.ts
@@ -17,16 +17,27 @@
// - every element is on ELEMENTS (shapes, groups, gradients, clips, masks,
// filters, text, and the three animation elements) — so no script,
// foreignObject, style, a, image, iframe, object, embed, audio or video,
-// and none of the HTML elements that break out of SVG;
+// none of the HTML elements that break out of SVG, and no title or desc:
+// those two are HTML integration points, where the HTML parser reads a
+// child as HTML and can leave the icon unclosed around the rest of the
+// page. A title or desc holding text only is removed before the check
+// (the link's aria-label names the icon; the root is aria-hidden); one
+// with a child element is refused;
// - every attribute is on ATTRIBUTES (the SVG presentation, geometry, filter
// and animation set, plus aria-*, data-* and xmlns:*), and none is an event
// handler (a name starting with "on");
// - after decoding character references (numeric and named, with or without
// the `;`) and dropping the whitespace a browser ignores in a URL, no value
-// holds `javascript:` or `vbscript:`; an `href` / `xlink:href` is a fragment
-// of this icon (`#id`); every `url(…)` points at a fragment of this icon; a
-// `style` has no escape, `@import`, `expression(`, `behavior` or binding;
-// an animation never targets `href` or a handler;
+// holds `javascript:` or `vbscript:`, a backslash (a CSS escape), a CSS
+// comment, `@import`, `expression(`, or a function that can load
+// something (`image-set(`, `-webkit-image-set(`, `image(`, `cross-fade(`,
+// `-webkit-cross-fade(`, `element(`, `-moz-element(`, `src(`, `paint(`);
+// every `url(…)` points at a fragment of this icon, spelled plainly (a
+// quote may be a character reference); an `href` / `xlink:href` is a
+// plain fragment (`#id`, no reference, no space) — so every reference is
+// one scopeSvgIds rewrites; a `style` holds only presentation properties
+// (STYLE_PROPERTIES); an animation never targets anything named `href` or
+// a handler. The same rules cover an animation's to/from/values/by;
// - every id is a plain name (`^[A-Za-z_][\w.:-]*$`), so a rendered copy can
// prefix it safely (socialLinks.ts scopeSvgIds).
// The OUTPUT of the normalization below is checked again the same way, so no
@@ -34,7 +45,7 @@
const ELEMENTS = new Set(
[
- "svg", "g", "defs", "symbol", "use", "title", "desc",
+ "svg", "g", "defs", "symbol", "use",
"path", "rect", "circle", "ellipse", "line", "polyline", "polygon",
"text", "tspan",
"linearGradient", "radialGradient", "stop", "pattern", "clipPath", "mask",
@@ -95,21 +106,45 @@ export const SVG_ID_RE = /^[A-Za-z_][\w.:-]*$/;
const FRAGMENT_RE = /^#[A-Za-z_][\w.:-]*$/;
// The reasons a pasted icon is refused, by class. The editor shows them after
-// the link's label; none of them echoes the markup (a tag or attribute NAME is
-// at most letters, digits and `_.:-`, and is cut to 40 characters).
+// the link's label, as text (React escapes it). None echoes the markup: a tag
+// or attribute NAME is reduced to letters, digits and `_.:-` and cut to 40
+// characters before it is named. The classes a drawing program's export
+// causes (a style block, its own elements and attributes, an old DOCTYPE) say
+// how to export an acceptable file.
+const nameOf = (name: string) => name.replace(/[^A-Za-z0-9_.:-]/g, "").slice(0, 40) || "?";
+const EXPORT_HINT =
+ "export it with presentation attributes rather than a style block (in Inkscape, save as Plain SVG)";
export const SVG_PROBLEM = {
markup: "it is not one well-formed <svg> element",
+ doctype: "it has a DOCTYPE with an internal subset; export it again without one, or delete the DOCTYPE",
handler: "it has an event handler attribute",
script: "it has a script, or a link that runs one",
external: "it links to something outside the icon",
- style: "it has a style an icon cannot use (an escape, an import or a script)",
+ style: `it has a style an icon cannot use; ${EXPORT_HINT}`,
id: "it has an id an icon cannot use",
viewBox: "it has no viewBox, and no numeric width and height to make one from",
- element: (name: string) => `it has an element an icon has no use for (${name.slice(0, 40)})`,
+ element: (name: string) => `it has an element an icon has no use for (${nameOf(name)}); ${EXPORT_HINT}`,
attribute: (name: string) =>
- `it has an attribute an icon has no use for (${name.slice(0, 40)})`,
+ `it has an attribute an icon has no use for (${nameOf(name)}); ${EXPORT_HINT}`,
} as const;
+// The only properties a `style` attribute may set: presentation, never layout
+// or position (an icon that paints outside its key, or over the page, is
+// refused).
+const STYLE_PROPERTIES = new Set([
+ "fill", "stroke", "stop-color", "stop-opacity", "opacity", "fill-opacity",
+ "stroke-opacity", "stroke-width", "stroke-linecap", "stroke-linejoin",
+ "fill-rule", "clip-rule", "display", "visibility", "paint-order",
+]);
+
+// CSS functions that can load something, whatever attribute or style holds them.
+const LOADING_FUNCTIONS =
+ /(?:-webkit-)?image-set\(|(?:^|[^a-z-])image\(|(?:-webkit-)?cross-fade\(|(?:-moz-)?element\(|(?:^|[^a-z-])src\(|paint\(|@import|expression\(/i;
+
+// A url(…) as scopeSvgIds rewrites it: a plain fragment, its quote literal or a
+// character reference.
+const RAW_URL_FRAGMENT = /url\(\s*(["']|"|'|"|'|"|')?#[A-Za-z_][\w.:-]*\1\s*\)/gi;
+
// HTML whitespace, the only attribute separator this reads (a `/` between
// attributes, which HTML also takes, is refused as markup).
const WS = "[\\t\\n\\f\\r ]";
@@ -175,22 +210,40 @@ function attrProblem(element: string, a: Attr): string | null {
if (lower.includes("javascript:") || lower.includes("vbscript:") || lower.includes("livescript:")) {
return SVG_PROBLEM.script;
}
+ // A CSS escape can spell `url(` so no check sees it; a comment can split a
+ // function name. Neither has a place in an icon's value.
+ if (decoded.includes("\\") || decoded.includes("/*")) {
+ return name === "style" ? SVG_PROBLEM.style : SVG_PROBLEM.external;
+ }
+ if (LOADING_FUNCTIONS.test(tight)) return SVG_PROBLEM.external;
if (name === "href" || name === "xlink:href") {
- if (!FRAGMENT_RE.test(decoded.trim())) return SVG_PROBLEM.external;
+ // Plain, as written: `#a` or ` #a ` would name an id the render's
+ // scoping cannot see.
+ if (!FRAGMENT_RE.test(a.value)) return SVG_PROBLEM.external;
}
- // Every url(…), wherever it is (a fill, a clip, a style): a fragment here.
- for (const m of tight.matchAll(/url\(/gi)) {
+ // Every url(…), wherever it is (a fill, a clip, a style): a fragment here,
+ // and every one of them spelled so scopeSvgIds rewrites it.
+ const urls = [...tight.matchAll(/url\(/gi)];
+ for (const m of urls) {
const rest = tight.slice(m.index);
if (!/^url\((["']?)#[A-Za-z_][\w.:-]*\1\)/i.test(rest)) return SVG_PROBLEM.external;
}
+ if (urls.length > 0 && [...a.value.matchAll(RAW_URL_FRAGMENT)].length !== urls.length) {
+ return SVG_PROBLEM.external;
+ }
if (name === "style") {
- if (/\\|@import|expression\(|behavior|-moz-binding/i.test(tight)) return SVG_PROBLEM.style;
+ for (const decl of decoded.split(";")) {
+ if (!decl.trim()) continue;
+ const colon = decl.indexOf(":");
+ const prop = (colon < 0 ? decl : decl.slice(0, colon)).trim().toLowerCase();
+ if (colon < 0 || !STYLE_PROPERTIES.has(prop)) return SVG_PROBLEM.style;
+ }
}
if (name === "id" && !SVG_ID_RE.test(a.value)) return SVG_PROBLEM.id;
if (ANIMATION.has(element) && name === "attributename") {
const target = lower.trim();
- if (target.startsWith("on")) return SVG_PROBLEM.handler;
- if (target === "href" || target === "xlink:href") return SVG_PROBLEM.external;
+ if (target.includes("href")) return SVG_PROBLEM.external;
+ if (/(^|:)on/.test(target)) return SVG_PROBLEM.handler;
}
return null;
}
@@ -259,9 +312,14 @@ function prepare(raw: string): string {
.replace(/^<\?xml\b[^>]*\?>\s*/i, "")
.replace(/^<!DOCTYPE\s+svg\b[^>[]*>\s*/i, "")
.replace(/<!--[\s\S]*?-->/g, "")
+ .replace(TEXT_ONLY_TITLE, "")
.trim();
}
+// A <title> or <desc> holding text only (or nothing), removed before the
+// check: see the header. One with a child element stays, and is refused.
+const TEXT_ONLY_TITLE = /<(title|desc)\b[^<>]*?(?:\/>|>[^<]*<\/\1\s*>)/gi;
+
// A paint that is not a colour: no paint (`none`, `transparent`), a paint
// server (a gradient or pattern, `url(#…)`, with or without a fallback), or
// what the element takes from its parent already. Never counted, never swapped.
@@ -373,6 +431,7 @@ export function normalizeSocialSvg(raw: string): string | null {
function normalize(raw: string): { svg: string | null; problem: string | null } {
const refuse = (problem: string) => ({ svg: null, problem });
const src = prepare(raw);
+ if (/^<!DOCTYPE\b/i.test(src)) return refuse(SVG_PROBLEM.doctype);
if (!src.startsWith("<svg") || !src.endsWith("</svg>")) return refuse(SVG_PROBLEM.markup);
const scanned = scan(src);
if ("problem" in scanned) return refuse(scanned.problem);
diff --git a/common/lib/socialSvg.vectors.ts b/common/lib/socialSvg.vectors.ts
@@ -0,0 +1,140 @@
+// TEST VECTORS for the social icon checker (socialSvg.ts) and its render path —
+// test data only, imported by common/lib/normalizeSocialSvg.test.ts and the
+// homepage e2e (svg-vectors.spec.ts, social.spec.ts). Synthetic throughout.
+//
+// ADVERSARIAL: the review's battery (release 14, slice HP): every input the
+// checker must either refuse or render inertly — no script, no request to
+// another origin, and the page parsed around the icon unchanged.
+// LOADS_ELSEWHERE: the eight the first allowlist accepted that made Chromium
+// fetch from another origin (R2); all refused now.
+// REAL_SHAPES: the shapes an operator's pasted icons take; each must pass, or
+// that icon turns into a text label on every site.
+
+const V = `viewBox="0 0 8 8"`;
+const W = (inner: string, open = `<svg ${V}>`) => `${open}${inner}</svg>`;
+export const EVIL = "https://evil.example";
+
+export const ADVERSARIAL: Record<string, string> = {
+ // the five originals
+ slash_handler: `<svg/onload="window.__x=1" ${V}><path d="M0 0"/></svg>`,
+ deletion_join: `<svg ${V} o width="1"nload="window.__x=1"><path d="M0 0"/></svg>`,
+ image_slash: W(`<image href="x:"/onerror="window.__x=1"/>`),
+ charref_js: W(`<a href="javascript:window.__x=1"><rect width="8" height="8"/></a>`),
+ breakout_img: W(`<img src="x:"/onerror="window.__x=1">`),
+ // tokenizer
+ unterminated_tag: `<svg ${V}><path d="M0 0"</svg>`,
+ unterminated_quote: `<svg ${V}><path d="M0 0/></svg>`,
+ attr_no_value: `<svg ${V} focusable><path d="M0 0"/></svg>`,
+ handler_no_value: `<svg ${V} onload><path d="M0 0"/></svg>`,
+ dup_href_first_ok: W(`<defs><linearGradient id="g"/></defs><use href="#g" href="${EVIL}/x.svg#g"/>`),
+ dup_href_first_bad: W(`<defs><linearGradient id="g"/></defs><use href="${EVIL}/x.svg#g" href="#g"/>`),
+ dup_fill: W(`<rect fill="url(#a)" fill="url(${EVIL}/p.svg#a)"/>`),
+ upper_xlink: W(`<use XLINK:HREF="javascript:window.__x=1"/>`),
+ upper_xlink_frag: W(`<defs><g id="a"/></defs><use XLINK:HREF="#a"/>`),
+ xml_base: `<svg ${V} xml:base="${EVIL}/"><use href="#a"/></svg>`,
+ xmlns_redef: `<svg ${V} xmlns:xlink="${EVIL}/ns" xmlns:foo="http://www.w3.org/1999/xlink"><defs><g id="a"/></defs><use foo:href="${EVIL}/x"/></svg>`,
+ xmlns_js: `<svg ${V} xmlns:xlink="javascript:window.__x=1"><path d="M0 0"/></svg>`,
+ nul_in_name: `<svg ${V} on\u0000load="window.__x=1"><path d="M0 0"/></svg>`,
+ nul_in_value: `<svg ${V} data-x="a\u0000b"><path d="M0 0"/></svg>`,
+ vt_separator: `<svg ${V}\u000bonload="window.__x=1"><path d="M0 0"/></svg>`,
+ cr_separator: `<svg ${V}\ronload="window.__x=1"><path d="M0 0"/></svg>`,
+ no_ws_between_attrs: `<svg ${V} data-a="1"onload="window.__x=1"><path d="M0 0"/></svg>`,
+ backtick_value: `<svg ${V} data-x=\`a\`><path d="M0 0"/></svg>`,
+ unquoted_value: `<svg ${V} data-x=a><path d="M0 0"/></svg>`,
+ gt_in_dq_value: `<svg ${V} data-x="><img src=x onerror=window.__x=1>"><path d="M0 0"/></svg>`,
+ gt_in_sq_value: `<svg ${V} data-x='"><img src=x onerror=window.__x=1>'><path d="M0 0"/></svg>`,
+ sq_value_with_fill: `<svg ${V} data-x=' fill="#fff" style="fill:#000"'><path fill="#f00" d="M0 0"/></svg>`,
+ // entities
+ ent_leading_zeros: W(`<rect data-x="javascript:window.__x=1"/>`),
+ ent_hex_nosemi: W(`<rect data-x="ڪvascript:x"/>`),
+ ent_colon_tab_nl: W(`<rect data-x="java	scr
ipt:x"/>`),
+ ent_colon_nosemi: W(`<rect data-x="javascript&colonx"/>`),
+ href_ent_hash: W(`<defs><g id="a"/></defs><use href="#a"/>`),
+ href_ws_frag: W(`<defs><g id="a"/></defs><use href=" #a "/>`),
+ // css url forms
+ style_url_escape: W(`<rect style="fill:\\75 rl(${EVIL}/p.svg#a)"/>`),
+ style_comment_url: W(`<rect style="fill:url/**/(${EVIL}/p.svg#a)"/>`),
+ pres_url_escape_fill: W(`<rect width="8" height="8" fill="\\75 rl(${EVIL}/fill.svg#a)"/>`),
+ pres_url_escape_filter: W(`<rect width="8" height="8" filter="\\75 rl(${EVIL}/filter.svg#a)"/>`),
+ pres_url_escape_mask: W(`<rect width="8" height="8" mask="\\75 rl(${EVIL}/mask.svg#a)"/>`),
+ pres_url_escape_clip: W(`<rect width="8" height="8" clip-path="\\75 rl(${EVIL}/clip.svg#a)"/>`),
+ pres_mask_imageset: W(`<rect width="8" height="8" mask="image-set('${EVIL}/mask-is.png' 1x)"/>`),
+ style_bg_imageset_root: `<svg ${V} style="background-image:image-set('${EVIL}/bg-is.png' 1x)"><path d="M0 0"/></svg>`,
+ style_mask_imageset: W(`<rect width="8" height="8" style="mask-image:image-set('${EVIL}/mimg-is.png' 1x)"/>`),
+ style_cursor_imageset: `<svg ${V} style="cursor:image-set('${EVIL}/cur-is.png' 1x),auto"><path d="M0 0"/></svg>`,
+ style_webkit_imageset: `<svg ${V} style="background-image:-webkit-image-set('${EVIL}/wk-is.png' 1x)"><path d="M0 0"/></svg>`,
+ anim_fill_escape: W(`<rect width="8" height="8"><set attributeName="fill" to="\\75 rl(${EVIL}/anim.svg#a)"/></rect>`),
+ anim_mask_imageset: W(`<rect width="8" height="8"><set attributeName="mask" to="image-set('${EVIL}/anim-is.png' 1x)"/></rect>`),
+ anim_style: W(`<rect width="8" height="8"><set attributeName="style" to="background:red"/></rect>`),
+ // references
+ use_external: W(`<use href="${EVIL}/x.svg#a"/>`),
+ use_data: W(`<use href="data:image/svg+xml,%3Csvg%3E"/>`),
+ feimage: W(`<filter id="f"><feImage href="${EVIL}/i.png"/></filter>`),
+ anchor: W(`<a href="#a"><rect/></a>`),
+ anim_href: W(`<set attributeName="href" to="javascript:x"/>`),
+ anim_href_ws: W(`<set attributeName=" HREF " to="#a"/>`),
+ anim_xlink_prefix: `<svg ${V} xmlns:x="http://www.w3.org/1999/xlink"><defs><g id="a"/></defs><use href="#a"><set attributeName="x:href" to="${EVIL}/x.svg#a" begin="0s"/></use></svg>`,
+ anim_onclick: W(`<set attributeName="onclick" to="window.__x=1"/>`),
+ anim_xlink_onclick: W(`<rect width="8" height="8"><set attributeName="xlink:onclick" to="window.__x=1"/></rect>`),
+ // elements
+ style_el: W(`<style>*{}</style>`),
+ nested_svg: W(`<svg viewBox="0 0 1 1"><path d="M0 0"/></svg>`),
+ title_markup_text: W(`<title><img src=x onerror=window.__x=1></title>`),
+ title_child_el: W(`<title><path d="M0 0"/></title>`),
+ desc_child_el: W(`<desc><g></g></desc>`),
+ title_title: W(`<title><title>x</title></title>`),
+ title_svg_title: W(`<title><svg viewBox="0 0 1 1"><title>t</title></svg></title>`),
+ math: W(`<math><mi>x</mi></math>`),
+ table: W(`<table><tr><td>x</td></tr></table>`),
+ after_root_html: `<svg ${V}></svg><img src=x onerror=window.__x=1>`,
+ after_root_ws: `<svg ${V}><path d="M0 0"/></svg>\n `,
+ doctype_subset: `<!DOCTYPE svg [<!ENTITY x "y">]><svg ${V}></svg>`,
+ doctype_plain: `<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "x"><svg ${V}><path d="M0 0"/></svg>`,
+ doctype_html: `<!DOCTYPE html><svg ${V}><path d="M0 0"/></svg>`,
+ xmldecl_gt: `<?xml version="1.0" encoding="a>b"?><svg ${V}><path d="M0 0"/></svg>`,
+ comment_join: `<svg ${V}><scr<!-- -->ipt>window.__x=1</script></svg>`,
+ comment_bang_close: `<svg ${V}><!-- a --!><img src=x onerror=window.__x=1> --><path d="M0 0"/></svg>`,
+ comment_abrupt: `<svg ${V}><!--><img src=x onerror=window.__x=1>--><path d="M0 0"/></svg>`,
+ cdata: W(`<![CDATA[<img src=x onerror=window.__x=1>]]>`),
+ pi: W(`<?x y?>`),
+ upper_close: `<svg ${V}><path d="M0 0"/></SVG>`,
+ close_ws: `<svg ${V}><path d="M0 0"/></svg >`,
+ close_attr: `<svg ${V}><path d="M0 0"/></svg foo="1">`,
+ svg_ns_script: W(`<svg:script>window.__x=1</svg:script>`),
+ // layout / redress (not script)
+ overlay_style: `<svg ${V} style="position:fixed;inset:0;width:100vw;height:100vh;z-index:2147483647"><path d="M0 0"/></svg>`,
+ class_redress: `<svg ${V} class="fixed inset-0 z-50"><path d="M0 0"/></svg>`,
+ id_clobber: W(`<g id="__next_f"/>`),
+ role_aria: `<svg ${V} aria-hidden="false" role="img" aria-label="Pay here"><path d="M0 0"/></svg>`,
+};
+
+export const LOADS_ELSEWHERE: readonly string[] = [
+ "pres_url_escape_mask",
+ "pres_url_escape_clip",
+ "pres_mask_imageset",
+ "style_bg_imageset_root",
+ "style_mask_imageset",
+ "style_cursor_imageset",
+ "style_webkit_imageset",
+ "anim_mask_imageset",
+];
+
+export const REAL_SHAPES: Record<string, string> = {
+ // A gradient body with a dark outline under it, the root as a drawing
+ // program writes it.
+ gradient_outlined:
+ `<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="-3 -3 76.3 80.8" enable-background="new 0 0 198.7 74.8" xml:space="preserve">` +
+ `<defs><linearGradient id="grad_1" gradientUnits="userSpaceOnUse" x1="35" y1="0" x2="35" y2="75" gradientTransform="matrix(1,0,0,-1,0,75)">` +
+ `<stop offset="0" style="stop-color:#E0A030"/><stop offset="1" style="stop-color:#1C9A5B"/></linearGradient></defs>` +
+ `<path fill="url(#grad_1)" style="fill:url(#grad_1)" stroke="#161c21" stroke-width="6" stroke-linejoin="round" paint-order="stroke" d="M38 75C50 64 70 50 70 30 70 12 55 0 38 0S5 12 5 30c0 20 21 34 33 45z"/>` +
+ `<circle fill="#333333" cx="31" cy="10" r="2.1"/></svg>`,
+ // One path in the link's colour, `fill="none"` on the root.
+ single_path:
+ `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2 2h20v20H2z" fill="currentColor"/></svg>`,
+ // A two-colour disc with a stroke on the disc and a decimal viewBox.
+ disc_stroked:
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80.5 80.5" fill="none">` +
+ `<circle cx="42" cy="42" r="36" fill="#1a1a1a"/>` +
+ `<circle cx="38.5" cy="38.5" r="37" fill="#f4c542" stroke="#1a1a1a" stroke-width="1.557"/>` +
+ `<path fill="#1a1a1a" d="M30 22h20v8H38v6h10v8H38v14h-8z"/></svg>`,
+};