跳到主要内容

SVG注入与消毒

用户上传的 SVG 与用户上传的 HTML 同一类 XSS。浏览器不会因为扩展名是 .svg 就当图片。能进 inline DOM 的东西,就能跑 script、事件属性、javascript: URL,以及 foreignObject 里的 HTML。

1. 两条产品线,不要混

表面脚本适用
<img src> / CSS background-image不执行。按 SVG 安全静态/动画模式 当图解码头像、图标、邮件里的装饰图
inline &lt;svg> / innerHTML 灌进文档会执行。节点进主文档,CSS、事件、脚本全通编辑器、可点热区、读屏树
&lt;object> / &lt;iframe> / &lt;embed>独立文档,脚本能否跑看 MIME 与 CSP很少该当默认上传预览

产品只需要「显示一张用户图」时,走 <img> 沙箱,不要消毒完再 inline——沙箱更短、更不容易漏。

产品需要选中、改色、title、命中:必须 inline,也就必须 白名单消毒,并且默认禁止下面这些宿主。

export type SvgDelivery = "img-sandbox" | "sanitized-inline";

export function chooseDelivery(needDom: boolean): SvgDelivery {
return needDom ? "sanitized-inline" : "img-sandbox";
}

<img> 不是「已经消毒」。它只是不跑脚本。外链、超大滤镜、billion laughs 仍然是资源与解析问题,用 CSP、尺寸上限、超时处理,不要假装沙箱等于安全扫描。

2. 白名单,不是黑名单

黑名单永远漏一个属性。生产只保留几何与结构节点:

const SVG_NS = "http://www.w3.org/2000/svg";

const ALLOWED_TAGS = new Set([
"svg", "g", "path", "circle", "rect", "ellipse", "line",
"polyline", "polygon", "text", "tspan", "defs", "clipPath",
"mask", "linearGradient", "radialGradient", "stop",
"title", "desc", "symbol", "use", "marker",
]);

/** 默认产品不需要这些;交互动画用 CSS / WAAPI,不要吃用户文件。 */
const FORBIDDEN_TAGS = new Set([
"script", "foreignObject", "iframe", "embed", "object",
"animate", "animateTransform", "animateMotion", "set",
"handler", "listener",
]);

const EVENT_ATTR = /^on/i;
const DANGEROUS_URL = /^\s*(javascript:|data:text\/html)/i;

function isSafeHref(value: string): boolean {
return !DANGEROUS_URL.test(value) && !value.trim().startsWith("data:image/svg");
}

export function sanitizeSvg(source: SVGElement): SVGElement {
const clone = source.cloneNode(true);
if (!(clone instanceof SVGElement)) {
throw new Error("clone is not SVGElement");
}
const walk = document.createTreeWalker(clone, NodeFilter.SHOW_ELEMENT);
const drop: Element[] = [];
let current: Node | null = walk.currentNode;
while (current) {
if (current instanceof Element) {
const tag = current.localName;
if (FORBIDDEN_TAGS.has(tag) || !ALLOWED_TAGS.has(tag)) {
drop.push(current);
} else {
for (const attr of [...current.attributes]) {
if (EVENT_ATTR.test(attr.name) || (attr.name.includes("href") && !isSafeHref(attr.value))) {
current.removeAttribute(attr.name);
}
}
}
}
current = walk.nextNode();
}
for (const node of drop) node.remove();
if (clone.namespaceURI !== SVG_NS) {
throw new Error("root is not in SVG namespace");
}
return clone;
}

usehref 只允许同文档 #id。跨文档 use 等于把别人的子树(含脚本)拉进来。

style 属性:能写 expression() 的引擎几乎没了,但 url() 仍能打外链。要么剥掉 style / &lt;style>,要么只允许颜色与线宽 token。

3. 明确禁止,除非产品就是要

宿主为什么
script主文档脚本
onload / 一切 on*属性即处理器
foreignObject里面是 HTML,XSS 面回到 HTML
javascript: / data:text/htmlaimageuse、动画的 URL
animate* / set历史上的脚本与事件宿主;用户文件里的 SMIL 也不该进编辑器

产品自己的进度环、插画用 CSS / WAAPI,见 SMIL。不要为了「用户 SVG 要动」把 SMIL 放进白名单。

4. 失败形态

症状原因
预览用 <img> 没事,点「编辑」就弹脚本同一份文件改 inline 且没消毒
消毒后仍 XSS漏了 foreignObjectxlink:href
图标变空白白名单杀了 use / clipPath,或剥了必要 href
用 DOMParser 后又 innerHTML 写回等于没消毒

不要把未消毒字符串写回。解析用 DOMParser + image/svg+xml,输出用下一篇的 XMLSerializer

权威资料

核对日期:2026-08-26