SVG注入与消毒
用户上传的 SVG 与用户上传的 HTML 同一类 XSS。浏览器不会因为扩展名是 .svg 就当图片。能进 inline DOM 的东西,就能跑 script、事件属性、javascript: URL,以及 foreignObject 里的 HTML。
1. 两条产品线,不要混
| 表面 | 脚本 | 适用 |
|---|---|---|
<img src> / CSS background-image | 不执行。按 SVG 安全静态/动画模式 当图解码 | 头像、图标、邮件里的装饰图 |
inline <svg> / innerHTML 灌进文档 | 会执行。节点进主文档,CSS、事件、脚本全通 | 编辑器、可点热区、读屏树 |
<object> / <iframe> / <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;
}
use 的 href 只允许同文档 #id。跨文档 use 等于把别人的子树(含脚本)拉进来。
style 属性:能写 expression() 的引擎几乎没了,但 url() 仍能打外链。要么剥掉 style / <style>,要么只允许颜色与线宽 token。
3. 明确禁止,除非产品就是要
| 宿主 | 为什么 |
|---|---|
script | 主文档脚本 |
onload / 一切 on* | 属性即处理器 |
foreignObject | 里面是 HTML,XSS 面回到 HTML |
javascript: / data:text/html | a、image、use、动画的 URL |
animate* / set | 历史上的脚本与事件宿主;用户文件里的 SMIL 也不该进编辑器 |
产品自己的进度环、插画用 CSS / WAAPI,见 SMIL。不要为了「用户 SVG 要动」把 SMIL 放进白名单。
4. 失败形态
| 症状 | 原因 |
|---|---|
预览用 <img> 没事,点「编辑」就弹脚本 | 同一份文件改 inline 且没消毒 |
| 消毒后仍 XSS | 漏了 foreignObject 或 xlink:href |
| 图标变空白 | 白名单杀了 use / clipPath,或剥了必要 href |
用 DOMParser 后又 innerHTML 写回 | 等于没消毒 |
不要把未消毒字符串写回。解析用 DOMParser + image/svg+xml,输出用下一篇的 XMLSerializer。
权威资料
- SVG 2 — Conformance: secure static / animated mode
- HTML — The img element(图像不执行脚本)
- OWASP — XSS Filter Evasion(SVG 段)
核对日期:2026-08-26