Files

279 lines
7.2 KiB
TypeScript

// A pull request's "### Visual" SVG, rebuilt from an allowlist for the What's new in beta dialog, which shows it only
// as an <img>. The input is parsed as XML and serialized again from scratch: exactly one <svg> root, known shape,
// paint and animation elements and attributes only, references to the document itself only, and no CSS escapes.
export * as BetaVisual from "./beta-visual"
import { DomUtils, parseDocument } from "htmlparser2"
/** The most bytes a visual may take once rebuilt. */
const LIMIT = 8 * 1024
const ELEMENTS = new Set([
"svg",
"g",
"defs",
"symbol",
"use",
"rect",
"circle",
"ellipse",
"line",
"polyline",
"polygon",
"path",
"text",
"tspan",
"title",
"desc",
"clipPath",
"mask",
"linearGradient",
"radialGradient",
"stop",
"style",
"animate",
"animateTransform",
"animateMotion",
"set",
])
const SMIL = new Set(["animate", "animateTransform", "animateMotion", "set"])
const ATTRIBUTES = new Set([
"id",
"class",
"style",
"viewBox",
"preserveAspectRatio",
"width",
"height",
"x",
"y",
"x1",
"y1",
"x2",
"y2",
"cx",
"cy",
"r",
"rx",
"ry",
"fx",
"fy",
"d",
"points",
"pathLength",
"transform",
"transform-origin",
"href",
"color",
"fill",
"fill-opacity",
"fill-rule",
"stroke",
"stroke-width",
"stroke-opacity",
"stroke-linecap",
"stroke-linejoin",
"stroke-dasharray",
"stroke-dashoffset",
"stroke-miterlimit",
"opacity",
"visibility",
"display",
"vector-effect",
"paint-order",
"clip-path",
"clip-rule",
"clipPathUnits",
"mask",
"maskUnits",
"maskContentUnits",
"offset",
"stop-color",
"stop-opacity",
"gradientUnits",
"gradientTransform",
"spreadMethod",
"font-size",
"font-weight",
"font-family",
"letter-spacing",
"text-anchor",
"dominant-baseline",
"attributeName",
"attributeType",
"values",
"from",
"to",
"by",
"dur",
"begin",
"end",
"repeatCount",
"repeatDur",
"keyTimes",
"keySplines",
"keyPoints",
"calcMode",
"additive",
"accumulate",
"restart",
"type",
"rotate",
"path",
])
/** Where text may appear; elsewhere it is formatting whitespace. */
const TEXT = new Set(["text", "tspan", "title", "desc", "style"])
type Node = ReturnType<typeof parseDocument>["children"][number]
/**
* The visual rebuilt twice: `animated` as drawn, and `still` with its SMIL elements, keyframes and animation and
* transition declarations removed, for reduced motion. None when it is not one <svg> root, uses anything outside the
* allowlist that can reach outside the document (a CSS escape, `@import`, a `url()` or `href` that is not `#id`, a
* doctype), or is over 8 KB.
*/
export function sanitize(input: string) {
if (input.length > LIMIT * 4 || /<!(doctype|entity|element|attlist)/i.test(input)) return undefined
const document = parseDocument(input.replace(/^\s*<\?xml[^>]*\?>/, ""), {
xmlMode: true,
recognizeCDATA: true,
recognizeSelfClosing: true,
})
const roots = document.children.filter(
(node) => !DomUtils.isComment(node) && !(DomUtils.isText(node) && !node.data.trim()),
)
const root = roots[0]
if (roots.length !== 1 || !root || !DomUtils.isTag(root) || root.name !== "svg") return undefined
const animated = serialize(root, false)
const still = serialize(root, true)
if (animated === undefined || still === undefined || new TextEncoder().encode(animated).length > LIMIT)
return undefined
return { animated, still }
}
/** One element and its allowed children as markup; undefined anywhere means the whole visual is refused. */
function serialize(node: Node, still: boolean): string | undefined {
if (!DomUtils.isTag(node) || !ELEMENTS.has(node.name)) return ""
const name = node.name
if (SMIL.has(name) && (still || /href$/i.test(node.attribs.attributeName ?? ""))) return ""
const attributes = Object.entries(node.attribs).flatMap(([key, value]): [string, string | undefined][] => {
const attribute = key === "xlink:href" ? "href" : key
if (!ATTRIBUTES.has(attribute)) return []
if (attribute === "href" && !/^#[\w-]+$/.test(value)) return []
if (attribute === "style") return [[attribute, declarations(value, still)]]
return [[attribute, safe(value) ? value : undefined]]
})
// A CSS escape or an outside reference in any value refuses the whole visual, rather than drawing part of it.
if (attributes.some(([, value]) => value === undefined)) return undefined
const children = node.children.map((child) => {
if (DomUtils.isTag(child)) return serialize(child, still)
if (DomUtils.isCDATA(child) || DomUtils.isText(child)) return text(name, DomUtils.textContent(child), still)
return ""
})
if (children.includes(undefined)) return undefined
const open = [
name,
...(name === "svg" ? [`xmlns="http://www.w3.org/2000/svg"`] : []),
...attributes.map(([key, value]) => `${key}="${escape(value)}"`),
].join(" ")
const body = children.join("")
return body ? `<${open}>${body}</${name}>` : `<${open}/>`
}
/** An element's text, kept only where text means something; a stylesheet must pass `stylesheet`. */
function text(element: string, value: string, still: boolean) {
if (!TEXT.has(element)) return ""
if (element !== "style") return escape(value)
const css = stylesheet(value, still)
return css === undefined ? undefined : escape(css)
}
/** A stylesheet that only styles this document: rules, `@keyframes` and `@media`, no escapes or outside urls. */
function stylesheet(value: string, still: boolean) {
const css = value.replace(/\/\*[\s\S]*?\*\//g, "")
if (!safe(css) || /@(?!(-webkit-)?keyframes\b|media\b)/i.test(css)) return undefined
if (!still) return css.trim()
return withoutKeyframes(css)
.replace(/\{([^{}]*)\}/g, (_, body: string) => `{${declarations(body, true)}}`)
.trim()
}
/** A declaration list, without its animations and transitions for the still. */
function declarations(value: string, still: boolean) {
const css = value.replace(/\/\*[\s\S]*?\*\//g, "")
if (!safe(css)) return undefined
if (!still) return css.trim()
return css
.split(";")
.filter((declaration) => !/^\s*(-webkit-)?(animation|transition)(-[\w-]+)?\s*:/i.test(declaration))
.join(";")
.trim()
}
/** The stylesheet without its `@keyframes` blocks, braces balanced. */
function withoutKeyframes(css: string) {
const start = css.search(/@(-webkit-)?keyframes\b/i)
if (start === -1) return css
const open = css.indexOf("{", start)
if (open === -1) return css.slice(0, start)
const end = Array.from(css.slice(open)).reduce(
(state, char, index) => {
if (state.end !== -1) return state
const depth = state.depth + ({ "{": 1, "}": -1 }[char] ?? 0)
return { depth, end: depth === 0 ? open + index + 1 : -1 }
},
{ depth: 0, end: -1 },
).end
return css.slice(0, start) + (end === -1 ? "" : withoutKeyframes(css.slice(end)))
}
/** No CSS escapes, no `@import`, and every `url()` points into the document. */
function safe(value: string) {
return !value.includes("\\") && !/@import/i.test(value) && !/url\(\s*(?!['"]?#)/i.test(value)
}
function escape(value: string) {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
}