Skip to content

安全概述

HTML 库处理的是来自互联网的不可信输入,因此在设计上将安全作为第一优先级。库内置多层独立防护机制,遵循纵深防御理念:每一层都假设其他层可能失效,单层被绕过不会导致整体沦陷。

本页是安全功能的总览。若你想直接动手配置审计管道或核对上线清单,跳到末尾的下一步

深度防御架构

库的安全防护分布在三个独立层次,每层都有自己的失败模式与恢复策略:

text
输入层防护(拒绝前过滤)
├── MaxInputSize        字节级大小上限(默认 50MB)
├── MaxDepth            DOM 嵌套深度上限(默认 500,防栈溢出)
├── ProcessingTimeout   单文档处理超时(默认 30s)
├── 路径遍历检测         文件路径中的 .. 组件
└── AllowedBaseDir      文件读取沙箱(OS 句柄解析,防 symlink/junction)

处理层防护(清洗与取消)
├── HTML 清洗           标签 / 属性 / URL / CSS 多维过滤
├── 协作式 context 取消  ExtractWithContext 响应 ctx.Done()
├── Panic 恢复          recoverPanic 泛型包装,返回 ErrInternalPanic
└── Goroutine 泄漏防护   maxTimeoutGoroutines(上限 1000)

审计层防护(可观测 + 隔离)
├── AuditSink panic 隔离 SEC-003(审计子系统是 best-effort)
├── 8 种审计事件         blocked_tag/blocked_attr/blocked_url/...
└── 原始值 HTML 转义      防审计日志在 SIEM 仪表盘中触发 XSS

为什么强调「独立」

纵深防御的价值在于层间无耦合假设。例如即便清洗层漏过某个恶意 URL(处理层失效),输入层的 MaxInputSize 仍能挡住超长 payload;即便 AuditSink 自己 panic(审计层失效),recoverPanic 仍保证主流程不崩溃。

输入边界防护

输入大小限制

默认最大输入 50MB(DefaultMaxInputSize),防止内存耗尽攻击。配置上限同时受 maxConfigInputSize 约束(同样 50MB),即无法通过配置放大到不安全值:

go
cfg := html.DefaultConfig()
cfg.MaxInputSize = 10 * 1024 * 1024 // 收紧到 10MB

文件路径还有一道预检查Stat 取得大小后,在 ReadAll 将内容载入内存前就拒绝超限文件,关闭「先读完再发现超限」的内存峰值窗口。

DOM 深度限制

默认最大深度 500(DefaultMaxDepth),防止递归嵌套构造的栈溢出炸弹:

go
cfg.MaxDepth = 200 // 更严格

深度校验采用迭代式遍历而非递归,本身不会因输入嵌套过深而爆栈。

处理超时

可配置的处理超时,防止恶意 HTML 触发指数级处理时间:

go
cfg.ProcessingTimeout = 10 * time.Second

超时机制通过独立 goroutine + context deadline 实现,并受 maxTimeoutGoroutines(1000)上限保护,防止高并发下 goroutine 失控。配合 ExtractWithContext 可叠加调用方的取消信号。

内容清洗机制详解

清洗功能由 EnableSanitization(默认 true)控制,对已解析的 DOM 树做就地修改,移除潜在恶意内容。整个流程由 internal/sanitize.go 实现,每一步拦截都会写入审计日志。

何时关闭清洗

仅当输入是完全可信的内部数据时才考虑 EnableSanitization = false。处理任何来自用户上传、网页抓取、第三方 API 的 HTML 都应保持开启。

被移除的标签

以下标签连同其子树被整段移除:

标签移除原因
<script><style><noscript>脚本与样式容器,可执行代码或隐藏载荷
<iframe><embed><object>外部内容嵌入,经典 XSS 与钓鱼向量化
<input><button>表单控件,可用于 CSRF 或 UI 伪装
<svg>可内嵌 JavaScript 与事件处理器
<math>MathML,在部分浏览器中可被滥用执行脚本

为什么 <form> 不被移除

<form> 标签刻意保留。ASP.NET WebForms、JSF、JSP 等服务端框架会把整个 <body> 包在单个 <form> 中,移除 <form> 会连带丢弃页面全部可见内容。文本提取场景既不渲染也不提交表单,因此对 <input>/<button> 成立的 CSRF/UI 伪装理由并不适用于 <form> 容器本身。

被移除的属性

属性类别检测方式示例
事件处理器前缀匹配 on*onclickonerroronmouseover
表单 action 覆盖精确匹配formaction(可劫持表单提交目标)
自动聚焦精确匹配autofocus(可用于钓鱼诱导点击)

事件处理器的检测采用前缀匹配(属性名以 on 开头),因此能覆盖所有现行与未来的 on* 变体,而非依赖固定黑名单。

CSS 危险模式

style 属性的值会检测以下危险子串,命中则整段 style 被剥离(保留安全的 CSS 属性用于元数据提取需要单独评估,当前策略是一旦危险即整体丢弃):

  • expression( — 旧版 IE 动态表达式
  • behavior: — IE 行为绑定
  • -moz-binding: — 旧版 Firefox XBL 绑定
  • javascript: — 协议注入
  • vbscript: — 协议注入

受检的 URI 属性

下列属性的值会进入 URL 安全防护管道做协议与 data URL 校验:

text
href  src  cite  action  data  poster  background
longdesc  usemap  profile  xlink:href

已被完全阻止的属性不再重复校验

formaction 因在「被移除的属性」中已被整体屏蔽,不再进入 URI 校验管道——避免对同一属性做冗余检查。

URL 安全防护深度

URI 属性的值经由 isSafeURIWithAudit 的多层管道校验。每一层都对应一类已知的浏览器解析行为或攻击绕过手法,缺一不可。

多层校验管道

text
原始 URI

  ├─ 1. NFC 规范化           归一全角/组合字符
  ├─ 2. TrimSpace            去首尾空白
  ├─ 3. 剥离 C0 控制符+空格   去首尾 U+0000–U+001F 及空格
  ├─ 4. 剥离 tab/LF/CR       去 URL 内部的 \t \n \r
  ├─ 5. ToLower              大小写归一
  ├─ 6. 长度上限             非 data URL 受 MaxURLLength(2000) 约束
  ├─ 7. 危险协议检测          javascript: / vbscript: / file: + 全角变形
  ├─ 8. 协议相对 URL 检测     //javascript: 等
  └─ 9. data URL 白名单       仅图片/字体/PDF,阻 svg 与空 MIME

Unicode 规范化(NFC)

第一步对 URI 做 NFC 规范化(normalizeURIForSecurity),防止用 Unicode 变形伪装危险协议:

  • 全角字符 javascript: 映射回 ASCII
  • 组合字符、跨脚本相似字符归一为单一表示

在此之上,isDangerousScheme 还会对全角拉丁字符(U+FF01–U+FF5E)做一次专门的 ASCII 折叠(normalizeFullwidthToASCII),双重保险——即便某些浏览器/解析器把全角形式当 ASCII 处理,库也能识别。

空白与控制字符剥离

浏览器在解析 URL 时会剥离特定的控制字符(遵循 WHATWG URL 标准),库必须在协议检测之前模拟同样的剥离,否则攻击者能用这些字符拆散危险协议名绕过检测:

  • tab / LF / CRjava\tscript: 会被浏览器重新拼成 javascript: 执行。库用 stripURLWhitespace 在协议检测前移除这三个字节。
  • C0 控制符(U+0000–U+001F)+ ASCII 空格:浏览器在 scheme 解析前剥离首尾的这些字节。strings.TrimSpace 只覆盖 Unicode 空白,不覆盖多数 C0 控制符,所以库用专门的 c0ControlOrSpace 集合显式剥离。否则 \x01javascript:… 能骗过所有 HasPrefix 检测。

危险协议检测

协议阻止方式
javascript:直接匹配 + 全角字符归一
vbscript:同上
file:同上(访问本地文件系统)
//javascript://vbscript://data://file:协议相对形式单独检测

协议相对形式(以 // 开头)的检测会先剥离 // 后的前导空白,再套用同样的危险协议判定,确保 // javascript: 这类变体无法绕过。

data URL 白名单

data URL 仅允许下列显式声明的 MIME 类型通过:

类别允许的 MIME 类型
图片image/gifimage/jpegimage/jpgimage/pngimage/webpimage/bmpimage/x-iconimage/vnd.microsoft.iconimage/avifimage/apng
字体font/wofffont/woff2font/ttffont/otfapplication/font-woffapplication/font-woff2
文档application/pdf

明确阻止:

  • image/svg+xml:SVG 可内嵌 JavaScript,作为标签移除后的纵深防御补丁。
  • 空媒体类型:如 data:;base64,<payload>data:;,...。这类形式曾经能绕过白名单,现在直接拒绝。
  • 超长 data URL:受 MaxDataURILength(100KB)约束,防止 base64 大块内容耗尽内存。
  • 非法 base64 字符:base64 部分会逐字节校验字符集合法性。

审计日志会截断 data URL

data URL 可能含大段 base64,完整写入审计日志既浪费空间又可能泄露嵌入的敏感内容。审计记录会通过 truncateAuditURL 截断到 256 字符。

绕过清洗器的路径

少数代码路径(如 ExtractAllLinks、原始 HTML 中的 video/audio 扫描)会读取未清洗的 HTML。这些路径通过 containsDangerousScheme 守卫——它复用与清洗器完全相同的归一管道(NFC、trim、C0 剥离、tab/LF/CR 剥离、全角折叠),确保两条路径执行的是同一套协议策略,不会出现清洗器拦截、而此处放行的不一致。

例如 javascript:alert(1).mp4 这类伪装成媒体 URL 的载荷,因为首字符 j 是字母曾能混过简单校验,现在会被 containsDangerousScheme 拦下。

AllowedBaseDir 沙箱机制

当通过 ExtractFromFile 处理来自不可信来源的文件路径时,AllowedBaseDir 把可读范围限制在指定目录内。该机制由 processor.goreadContained / realPath / pathWithin 实现。

为什么需要 OS 文件句柄解析

普通的 filepath.EvalSymlinks 无法解析 Windows 目录 junction 与 reparse points——而这两者无需任何特权即可创建,是 Windows 上绕过路径限制的主要手段。库的做法是:

  1. os.Open() 打开目标文件,拿到 OS 文件句柄
  2. realPath(f)已打开的句柄解析真实磁盘路径
  3. pathWithin(realBase, realTarget) 判定真实路径是否落在允许目录内
  4. 同一个已验证句柄 io.ReadAll 读取内容

从同一句柄做校验和读取,关闭了 TOCTOU(check-time-to-use-time)竞态窗口——校验之后、读取之前路径被替换成符号链接已不可能影响结果,因为读的是当初校验的那个 inode。

跨平台真实路径解析

平台解析方式覆盖的重定向类型
Linux/proc/self/fd/<fd> 的 link符号链接(race-free)
macOS / BSD/dev/fd/<fd> 的 link符号链接(race-free)
其他 Unix回退 filepath.EvalSymlinks符号链接(残留轻微 TOCTOU)
WindowsGetFinalPathNameByHandleW符号链接 + junction + 所有 reparse points

Windows 路径返回后会剥除 \\?\ 扩展长度前缀并 Clean,使其与 filepath.Abs 的输出形式一致,确保后续包含性比较精确。

防护层次

AllowedBaseDir 模式下的文件读取叠加了四道独立检查:

  1. 路径遍历检测filepath.Clean 后检查是否含 .. 组件
  2. OS 句柄沙箱realPath 解析真实路径,pathWithin 判定包含关系
  3. 大小预检查:在已验证句柄上 Stat 检查大小,超 MaxInputSizeReadAll 前拒绝
  4. 字节级上限:读取后仍由 validateInput 复检字节数

即便文件位于允许目录内,AllowedBaseDir 约束的是「能读哪个文件」,MaxInputSize 约束的是「文件能多大」,两者正交、互不替代。

AllowedBaseDir 留空 = 不启用沙箱

AllowedBaseDir 默认为空字符串,表示不做目录限制(仅保留 .. 遍历检测)。只要你的文件路径来自用户输入,就应当显式设置它。

配置示例

go
cfg := html.DefaultConfig()
// 只允许读取 /var/app/uploads 及其子目录下的文件
cfg.AllowedBaseDir = "/var/app/uploads"

p, err := html.New(cfg)
if err != nil {
    log.Fatal(err)
}
defer p.Close()

// 正常:文件在允许目录内
result, err := p.ExtractFromFile("/var/app/uploads/page.html")

// 被拒绝:通过 symlink/junction 指向外部
_, err = p.ExtractFromFile("/var/app/uploads/escape.txt") // 若它是 junction → /etc

被拒绝的越界访问会记录 AuditEventPathTraversal 审计事件,并返回包装了「path outside allowed directory」的 *FileError,错误中不包含解析出的真实路径(避免泄露文件系统布局)。

恐慌恢复与隔离

所有公开提取方法都经 recoverPanic[T] 泛型函数包装,panic 被捕获并转为 ErrInternalPanic 错误返回,保证恶意输入不会崩溃调用方进程。

go
func recoverPanic[T any](fn func() (T, error)) (result T, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("%w: %v", ErrInternalPanic, r)
        }
    }()
    return fn()
}

多层隔离边界

隔离边界行为位置
单次提取panic → ErrInternalPanicextract.go recoverPanic
批量提取单项每项独立 recover,一项 panic 不影响其他项batch.go
超时 goroutinewithTimeout 的 worker goroutine 独立 recoverextract.go
AuditSink 写入sink 自身 panic 被吞掉(SEC-003)audit.go Record
AuditSink 关闭sink.Close panic 转为 ErrInternalPanic 经 error 返回audit.go Close
池化 Processor 创建sync.Pool.New panic → ErrInternalPanicprocessor_pool.go

SEC-003:审计子系统是 best-effort

审计子系统绝不能把 panic 传播给公开 API 调用方。用户传入的 AuditSink(可能是手写的、可能套了过滤/多路扇出器)其 Write() 可能在任意路径 panic。Recorddefer recover() 吞掉这类 panic(恢复值被丢弃——审计路径自身没有安全的上报通道);Close 因为有 error 返回值,会把恢复值包进 ErrInternalPanic 返回。

这意味着即便你的自定义 Sink 有 bug,defer processor.Close() 这一惯用法也不会引爆进程。

Goroutine 泄漏防护

withTimeout 每次调用会起一个 worker goroutine 等待 deadline。为防止高并发下 goroutine 失控,全局计数器 activeTimeoutGoroutines 限制并发上限为 maxTimeoutGoroutines(1000)。超限时新请求直接返回 ErrProcessingTimeout,而不是无限堆积 goroutine(按每 goroutine ~1MB 栈估算,1000 ≈ 1GB 上限)。

审计原始值的日志注入防护

AuditConfig.IncludeRawValues = true 时,审计条目会带上被拦截属性/URL 的原始值。这些值经 sanitizeRawValue 做 HTML 转义(& < > " '),防止当审计日志被渲染到浏览器或 SIEM 仪表盘时触发存储型 XSS。

审计系统

安全事件通过审计系统记录,支持 8 种事件类型、多个内置 Sink 与级别过滤。完整配置见审计系统实战

事件说明
AuditEventBlockedTag被阻止的 HTML 标签
AuditEventBlockedAttr被阻止的属性
AuditEventBlockedURL被阻止的 URL
AuditEventInputViolation输入大小违规
AuditEventDepthViolationDOM 深度违规
AuditEventPathTraversal路径遍历尝试(含 AllowedBaseDir 越界)
AuditEventTimeout处理超时
AuditEventEncodingIssue编码异常

安全配置决策表

场景推荐配置说明
完全可信内部数据DefaultConfig() + 可选 EnableSanitization = false性能优先;仅在确认无外部输入时关闭清洗
用户上传 HTMLHighSecurityConfig()全面防护:收紧限制 + 完整审计
处理外部网页DefaultConfig()默认清洗已覆盖常见威胁
处理用户提供的文件路径AllowedBaseDir启用 OS 句柄沙箱,防 symlink/junction 逃逸
高吞吐爬虫缩减 MaxInputSize + 收紧 ProcessingTimeout防恶意页面拖垮 Worker

高安全配置

HighSecurityConfig() 是一个预设,一次性收紧所有限制并启用完整审计:

go
cfg := html.HighSecurityConfig()
// 自动设置:
//   MaxInputSize      = 10MB(默认 50MB)
//   MaxDepth          = 100(默认 500)
//   ProcessingTimeout = 10s(默认 30s)
//   WorkerPoolSize    = 2(默认 4)
//   Audit             = HighSecurityAuditConfig()(启用 + 含原始值)

错误处理

所有安全违规都返回明确的哨兵错误,支持 errors.Is 判定类别:

go
package main

import (
	"errors"
	"fmt"

	"github.com/cybergodev/html"
)

func main() {
	data := []byte(`<html><body>恶意构造的超深嵌套</body></html>`)
	_, err := html.Extract(data)
	if err != nil {
		switch {
		case errors.Is(err, html.ErrInputTooLarge):
			// 输入超限,记录并拒绝
			fmt.Println("输入过大")
		case errors.Is(err, html.ErrMaxDepthExceeded):
			// 可能是递归炸弹
			fmt.Println("深度违规")
		case errors.Is(err, html.ErrInternalPanic):
			// panic 已恢复,应排查输入与上报
			fmt.Println("内部 panic 已恢复")
		}
	}
	// 输出:深度违规(示例,实际取决于输入)
}

文件错误用 SafePath

*FileError,用 SafePath() 取得已脱敏的路径字符串,而非直接打印原始 error——避免把解析出的真实路径泄露到日志。

下一步