用户提交了一段 HTML,你要存进数据库然后在页面上展示。我见过太多项目用 strip_tags() 一把梭,以为万事大吉 -- 但它对 javascript: 协议、事件处理器、CSS 表达式这些攻击向量完全无感。HTML Purifier 用严格的白名单机制,只允许已知安全的标签和属性通过,从根源上堵死 XSS。
安装
composer require ezyang/htmlpurifier
支持 PHP 5.6 到 8.5,兼容性极广。LGPL-2.1 协议,商用没问题。说实话能兼容这么广的版本跨度,在 PHP 生态里不多见。
基本用法
核心 API 就两个类:HTMLPurifier 和 HTMLPurifier_Config。
require_once 'vendor/autoload.php';
$config = HTMLPurifier_Config::createDefault();
$purifier = new HTMLPurifier($config);
// 净化一段包含恶意脚本的 HTML
$dirty = '<script>alert("xss")</script><p>Hello <b>World</b></p>';
$clean = $purifier->purify($dirty);
// 结果: <p>Hello <b>World</b></p>
<script> 被直接移除,<p> 和 <b> 保留。默认配置允许常见 HTML 标签通过,但会拦截所有不在白名单中的元素。
批量净化用 purifyArray():
$cleanArray = $purifier->purifyArray([
'<p>段落一</p>',
'<img src=x onerror=alert(1)>',
]);
// 结果: ['<p>段落一</p>', '<img src="x" alt="x" />']
// 注意: onerror 不在白名单属性里,被移除;alt 被自动补上
为什么 strip_tags() 不够用
这是最关键的问题。很多开发者以为 strip_tags() 就够了,我之前也这么想,但它只做标签级别的过滤,对属性中的恶意内容完全无能为力:
$attacks = [
// 攻击 1: javascript: 协议藏在允许的 <a> 标签里
'<a href="javascript:alert(1)">点击领奖</a>',
// 攻击 2: 事件处理器
'<img src=x onerror=alert(1)>',
// 攻击 3: CSS 表达式注入
'<div style="background:url(javascript:alert(1))">内容</div>',
// 攻击 4: 利用 style 中的 expression()(IE 浏览器)
'<div style="width:expression(alert(1))">内容</div>',
];
$config = HTMLPurifier_Config::createDefault();
$purifier = new HTMLPurifier($config);
foreach ($attacks as $attack) {
echo "strip_tags: " . strip_tags($attack, '<a><img><div>') . "\n";
echo "Purifier: " . $purifier->purify($attack) . "\n\n";
}
输出:
strip_tags: <a href="javascript:alert(1)">点击领奖</a> <-- 危险!
Purifier: <a>点击领奖</a> <-- href 被移除
strip_tags: <img src=x onerror=alert(1)> <-- 危险! onerror 保留了
Purifier: <img src="x" /> <-- onerror 不在白名单,移除
strip_tags: <div style="background:url(javascript:alert(1))">内容</div> <-- 危险!
Purifier: <div>内容</div> <-- style 被移除
strip_tags: <div style="width:expression(alert(1))">内容</div> <-- 危险!
Purifier: <div>内容</div>
strip_tags() 对属性里的恶意代码视而不见。HTML Purifier 不一样,它把 HTML 完整解析成 token 树,对每个标签、每个属性、每个值都做验证,只有明确在白名单里的才会放行。我认为这是处理用户 HTML 的正确思路 -- 不是"去掉危险的",而是"只允许安全的"。
配置白名单
HTML.Allowed 是最常用的配置项,TinyMCE 风格的字符串:
$config = HTMLPurifier_Config::createDefault();
// 只允许基本排版标签
$config->set('HTML.Allowed', 'p,b,strong,i,em,u,a[href|title],ul,ol,li,br');
$purifier = new HTMLPurifier($config);
$dirty = '<p>正文</p><script>alert(1)</script><table><tr><td>表格</td></tr></table>';
$clean = $purifier->purify($dirty);
// 结果: <p>正文</p>表格 <-- script 移除,table 标签移除但文本内容保留
不同场景的白名单差异很大:
// 评论场景:只保留基本格式
$config->set('HTML.Allowed', 'p,b,strong,i,em,a[href],ul,ol,li,br');
// 富文本编辑器场景:保留表格、图片、标题
$config->set('HTML.Allowed',
'div,b,strong,i,em,u,a[href|title|target],'
. 'ul,ol,li,p[style],br,span[style],'
. 'img[src|alt|width|height],'
. 'table,thead,tbody,tr,th,td,'
. 'h1,h2,h3,h4,h5,h6,blockquote,pre,code'
);
// API 输入:只允许段落和换行
$config->set('HTML.Allowed', 'p,br');
等价写法,用数组代替字符串:
// 这两种写法效果一样
$config->set('HTML.Allowed', 'b,i,p,a[href],*[id]');
// 等价于:
$config->set('HTML.AllowedElements', ['b', 'i', 'p', 'a']);
$config->set('HTML.AllowedAttributes', ['a@href', '*@id']);
URI 和 CSS 过滤
光控制标签还不够,href 里的 javascript: 协议、style 里的 expression() 都是攻击向量。HTML Purifier 对 URI 和 CSS 有独立的过滤层:
$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'a[href],div[style],img[src]');
// 限制允许的 URI 协议
$config->set('URI.AllowedSchemes', ['http' => true, 'https' => true, 'mailto' => true]);
// 限制允许的 CSS 属性
$config->set('CSS.AllowedProperties', 'font-size,color,text-align,font-weight');
$purifier = new HTMLPurifier($config);
// javascript: 协议被拦截
$purifier->purify('<a href="javascript:alert(1)">链接</a>');
// 结果: <a>链接</a> href 被移除
// 不在白名单的 CSS 属性被移除
$purifier->purify('<div style="font-size:14px;background:url(evil)">文字</div>');
// 结果: <div style="font-size:14px;">文字</div>
三层过滤叠加:标签白名单 + URI 协议限制 + CSS 属性限制,形成纵深防御。我觉得这套分层设计是 HTML Purifier 最值得学习的地方。
自定义元素和属性
默认白名单没有 target="_blank",也没有 HTML5 新标签。需要自定义时用 addAttribute() 和 addElement():
$config = HTMLPurifier_Config::createDefault();
// 必须设置 DefinitionID 和 DefinitionRev,否则缓存不生效
$config->set('HTML.DefinitionID', 'my-custom-purifier');
$config->set('HTML.DefinitionRev', 1);
// 用 maybeGetRawHTMLDefinition,必须放在 if 块里
if ($def = $config->maybeGetRawHTMLDefinition()) {
// 给 <a> 标签添加 target 属性
$def->addAttribute('a', 'target', 'Enum#_blank,_self');
// 添加完整的 <form> 元素
$form = $def->addElement(
'form', // 元素名
'Block', // 内容集
'Flow', // 允许的子元素
'Common', // 通用属性
[ // 特定属性
'action*' => 'URI', // * 表示必需
'method' => 'Enum#get|post',
]
);
$form->excludes = ['form' => true]; // 禁止嵌套 form
}
$purifier = new HTMLPurifier($config);
属性类型速查:
| 类型 | 说明 | 示例 |
|---|---|---|
Enum#v1,v2 |
枚举值 | Enum#_blank,_self |
Text |
任意文本 | Text |
URI |
URI 地址 | URI |
Bool#attr |
布尔属性 | Bool#checked |
Number |
正整数 | Number |
Length |
像素或百分比 | Length |
缓存和性能
HTML Purifier 性能不算快,因为它是完整解析 HTML 再重组,不是简单的字符串替换。但这是安全性的代价 -- 轻描淡写地扫一遍 HTML 是挡不住 XSS 的。我测试过,一次净化 1KB 的 HTML 不到 1ms,对普通请求来说完全无感。
生产环境必须做两件事:
// 1. 设置缓存目录(必须可写)
$config->set('Cache.SerializerPath', '/path/to/writable/cache');
// 2. 单例模式,不要每次请求都 new
class PurifierFactory
{
private static ?HTMLPurifier $instance = null;
public static function getInstance(): HTMLPurifier
{
if (self::$instance === null) {
$config = HTMLPurifier_Config::createDefault();
$config->set('Cache.SerializerPath', storage_path('app/purifier'));
$config->set('HTML.Allowed', 'p,b,strong,i,em,a[href],ul,ol,li,br,img[src|alt]');
$config->set('URI.AllowedSchemes', ['http' => true, 'https' => true]);
self::$instance = new HTMLPurifier($config);
}
return self::$instance;
}
}
// 使用
$clean = PurifierFactory::getInstance()->purify($dirtyHtml);
Cache.SerializerPath 让 HTML Purifier 把解析后的定义对象序列化到磁盘,下次请求直接加载。单例模式确保一个请求周期内只创建一次实例。这两招加上,性能完全不是问题。
Laravel 集成
Laravel 有封装包 mews/purifier,直接 Facade 调用:
composer require mews/purifier
use Mews\Purifier\Facades\Purifier;
// 基本用法
$clean = Purifier::clean($request->input('content'));
// 使用命名配置(在 config/purifier.php 中预定义)
$clean = Purifier::clean($request->input('content'), 'comment');
// 临时配置
$clean = Purifier::clean($input, ['HTML.Allowed' => 'p,b,a[href]']);
// Eloquent 模型 Cast(Laravel 7+)
class Article extends Model
{
protected $casts = [
'body' => CleanHtml::class, // 写入时自动净化
];
}
需要注意的坑
HTML5 支持不完整。 HTML Purifier 主要面向 XHTML 1.0 和 HTML 4.01,<article>、<section>、<video> 这些标签默认不支持。需要装扩展包 xemlock/htmlpurifier-html5,或者用自定义元素 API 手动添加。
maybeGetRawHTMLDefinition 必须放在 if 块里。 缓存命中时调用 getHTMLDefinition(true) 会报错。正确写法是 $def = $config->maybeGetRawHTMLDefinition(),只有返回非 null 时才添加自定义规则。
自定义定义必须设 DefinitionID。 不设置的话缓存无法生效,每次请求都重新构建定义对象,白白浪费性能。修改规则后记得递增 DefinitionRev。
缓存目录必须可写。 默认缓存到库自身目录下,很多部署环境这个目录不可写。生产环境务必设置 Cache.SerializerPath 指向一个可写目录。
它依然值得用
HTML Purifier 下载量接近 3 亿次,v4.19.0 发布于 2025 年 10 月,社区仍在持续维护。白名单机制的安全性不依赖频繁更新,只要白名单没问题,老版本也一样安全。PHP 8.1+ 的新项目可以考虑 symfony/html-sanitizer 作为现代替代,但就成熟度和兼容性而言,HTML Purifier 依然是 PHP 生态里 HTML 净化的事实标准。
原文标题: [ezyang/htmlpurifier]PHP 富文本安全过滤,白名单机制防 XSS
原文地址: https://phpreturn.com/index/a6a14fc2fa0589.html
原文平台: PHP武器库
版权声明: 本文由phpreturn.com(PHP武器库官网)原创和首发,所有权利归phpreturn(PHP武器库)所有,本站允许任何形式的转载/引用文章,但必须同时注明出处。