概念定义
富文本输入里既有作者想要的标记,也有攻击者注入的标记。消毒器通过解析成 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, "<script>") {
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()