// THE SOCIAL ICON'S SVG: what one may contain, and its normalized form. // // Pure, no imports: settingsSchema.ts (every save of a social link — Settings, // a site's form, the homepage config) and socialLinks.ts (every render) both run // it, so a stored icon is checked again each time it is inlined into a page. // // AN ALLOWLIST, TOKENIZED. An icon is inlined into every header and footer, and // an operator pastes it from anywhere, so a text denylist is not enough: `/` // separates attributes as well as whitespace does, a character reference spells // `javascript:`, and a transform can join two fragments into a handler. The // input is read once, tag by tag, and refused unless: // - it is ONE well-formed element: every tag either self-closes or is // closed in order, attributes are separated by HTML whitespace and quoted, // and there is no other markup (no declaration, CDATA or processing // instruction; an XML declaration and a DOCTYPE with no internal subset at // the very start, and every comment, are removed first); // - 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, // 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:`, 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 // transform can assemble what the input check refused. const ELEMENTS = 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", ].map((e) => e.toLowerCase()), ); const ANIMATION = new Set(["animate", "animatetransform", "set"]); const ATTRIBUTES = new Set( [ // core "id", "class", "style", "lang", "xml:lang", "xml:space", "xmlns", "version", "baseProfile", "role", "focusable", // geometry "viewBox", "preserveAspectRatio", "width", "height", "x", "y", "x1", "y1", "x2", "y2", "cx", "cy", "r", "rx", "ry", "fx", "fy", "fr", "d", "points", "pathLength", "transform", "transform-origin", // paint and presentation "fill", "fill-opacity", "fill-rule", "clip-rule", "clip-path", "clipPathUnits", "mask", "maskUnits", "maskContentUnits", "filter", "filterUnits", "primitiveUnits", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray", "stroke-dashoffset", "stroke-opacity", "opacity", "color", "display", "visibility", "overflow", "shape-rendering", "text-rendering", "image-rendering", "color-interpolation", "color-interpolation-filters", "color-rendering", "vector-effect", "paint-order", "mix-blend-mode", "isolation", "enable-background", // gradients and patterns "stop-color", "stop-opacity", "offset", "gradientUnits", "gradientTransform", "spreadMethod", "patternUnits", "patternContentUnits", "patternTransform", // text "font-family", "font-size", "font-weight", "font-style", "font-variant", "font-stretch", "text-anchor", "dominant-baseline", "alignment-baseline", "baseline-shift", "letter-spacing", "word-spacing", "text-decoration", "writing-mode", "dx", "dy", "rotate", "textLength", "lengthAdjust", // filter primitives "in", "in2", "result", "stdDeviation", "flood-color", "flood-opacity", "lighting-color", "operator", "k1", "k2", "k3", "k4", "mode", "values", "type", "tableValues", "slope", "intercept", "amplitude", "exponent", "radius", "edgeMode", // animation "attributeName", "attributeType", "from", "to", "by", "dur", "begin", "end", "repeatCount", "repeatDur", "calcMode", "keyTimes", "keySplines", "additive", "accumulate", "restart", "min", "max", // references — a fragment of this icon only (checked below) "href", "xlink:href", ].map((a) => a.toLowerCase()), ); const ATTRIBUTE_PATTERNS = [/^aria-[a-z-]+$/, /^data-[a-z0-9_.-]+$/, /^xmlns:[a-z][a-z0-9_.-]*$/]; // An id a rendered copy can prefix (socialLinks.ts): a plain name. 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, 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. Only the refusals a drawing program's export // causes say how to export an acceptable file: a style (a `