← 中文资源

简体中文版本

HTML 消毒

HTML 消毒以白名单解析的方式剥除不可信标记中的可执行内容——只保留安全标签与属性——因为对 HTML 做黑名单字符串过滤是必输的游戏。

阅读英文原文

概念定义

富文本输入里既有作者想要的标记,也有攻击者注入的标记。消毒器通过解析成 DOM、遍历、只保留已知安全构造来区分两者——输出仍是能渲染排版的 HTML,而 script、事件处理器、危险 URL 等一切可执行路径都被结构性移除。

工作机制

流水线:用与渲染侧同类的引擎解析、白名单元素与属性、校验 URL 方案与安全取值、丢弃或中和其余内容、再序列化。DOMPurify 是维护良好的参考实现,各语言也有服务端等价物。配置决定安全包络:每加一个标签或属性都是一次经评审的决策。

使用场景

一切用户标记要按 HTML 渲染的地方:评论、个人简介、邮件正文、CMS 导入。不要求全保真时,“禁原生 HTML 的 Markdown”或“纯文本加转义”更简单也更安全。

常见误区

消毒与编码不等价:编码让数据按文本显示,消毒让安全标记按标记渲染,按需求选。必须在摄入端消毒成规范安全 HTML 并存储该结果,渲染端不再二次处理;只做客户端消毒会被直接调 API 绕过。mXSS 攻击针对解析器与序列化器之间的往返——消毒库要保持更新并用变异语料测试。

代码示例

package main

import (
	"bytes"
	"fmt"
	"html/template"
	"strings"
)

type Comment struct {
	Author string
	Body   string
	Link   string
}

func main() {
	page := template.Must(template.New("comment").Parse(
		`<article><h2>{{.Author}}</h2><p>{{.Body}}</p><a href="{{.Link}}">profile</a></article>`,
	))
	comment := Comment{
		Author: "<b>Mallory</b>",
		Body:   `<script>alert("x")</script>`,
		Link:   "javascript:alert(1)",
	}

	var output bytes.Buffer
	if err := page.Execute(&output, comment); err != nil {
		panic(err)
	}
	rendered := output.String()
	fmt.Println(rendered)

	if strings.Contains(rendered, "<script>") {
		panic("script tag was not escaped")
	}
	if !strings.Contains(rendered, "&lt;script&gt;") {
		panic("expected escaped body text")
	}
	if !strings.Contains(rendered, "#ZgotmplZ") {
		panic("expected dangerous URL to be blocked")
	}
}
import re
from pathlib import PurePosixPath
from urllib.parse import unquote

NAME_STAR = re.compile(r"filename\*\s*=\s*([^;]+)", re.IGNORECASE)
NAME = re.compile(r'filename\s*=\s*(?:"([^"]*)"|([^;]+))', re.IGNORECASE)


def safe_basename(value: str) -> str | None:
    candidate = PurePosixPath(value.replace("\\", "/")).name.strip()
    if candidate in {"", ".", ".."} or "\x00" in candidate:
        return None
    return candidate


def attachment_filename(header: str) -> str | None:
    match = NAME_STAR.search(header)
    if match:
        value = match.group(1).strip().strip('"')
        charset, _, remainder = value.partition("'")
        _, _, encoded = remainder.partition("'")
        try:
            decoded = unquote(
                encoded or remainder,
                encoding=charset or "utf-8",
                errors="strict",
            )
        except (LookupError, UnicodeDecodeError):
            decoded = unquote(encoded or remainder)
        return safe_basename(decoded)
    match = NAME.search(header)
    if not match:
        return None
    value = match.group(1) if match.group(1) is not None else match.group(2)
    return safe_basename(unquote(value.strip()))


def main() -> None:
    headers = [
        'attachment; filename="report final.pdf"',
        "attachment; filename=../../etc/passwd",
        "attachment; filename*=UTF-8''%E5%B9%B4%E6%8A%A5.pdf",
        'attachment; filename=".."',
    ]
    for header in headers:
        print(attachment_filename(header))


if __name__ == "__main__":
    main()