[ezyang/htmlpurifier]PHP 富文本安全过滤,白名单机制防 XSS

2026-05-26 奥古斯宏 #html-purifier #xss #security #html #filter
strip_tags() 挡不住 javascript: 协议和事件处理器里的 XSS。HTML Purifier 用白名单机制逐标签、逐属性验证,3 亿次下载不是白来的。

用户提交了一段 HTML,你要存进数据库然后在页面上展示。我见过太多项目用 strip_tags() 一把梭,以为万事大吉 -- 但它对 javascript: 协议、事件处理器、CSS 表达式这些攻击向量完全无感。HTML Purifier 用严格的白名单机制,只允许已知安全的标签和属性通过,从根源上堵死 XSS。

安装

composer require ezyang/htmlpurifier

支持 PHP 5.6 到 8.5,兼容性极广。LGPL-2.1 协议,商用没问题。说实话能兼容这么广的版本跨度,在 PHP 生态里不多见。

基本用法

核心 API 就两个类:HTMLPurifierHTMLPurifier_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";
}

输出:

版权声明:本文由phpreturn.com(PHP武器库官网)原创和首发,所有权利归phpreturn(PHP武器库)所有,本站允许任何形式的转载/引用文章,但必须同时注明出处。

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 最值得学习的地方。

版权声明:本文由phpreturn.com(PHP武器库官网)原创和首发,所有权利归phpreturn(PHP武器库)所有,本站允许任何形式的转载/引用文章,但必须同时注明出处。

自定义元素和属性

默认白名单没有 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 调用:

版权声明:本文由phpreturn.com(PHP武器库官网)原创和首发,所有权利归phpreturn(PHP武器库)所有,本站允许任何形式的转载/引用文章,但必须同时注明出处。

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 净化的事实标准。

最近浏览
IP用户:112.251.*.*
1 小时前 Firefox Windows 10
累计浏览次数:2
评论
点击登录
phpreturn,PHP武器库,专注PHP领域的项目和资讯,收录和介绍PHP相关项目。
最近浏览 点击登录
累计浏览次数:329071
一周浏览次数:2268
今日浏览次数:94

本站所有权利归 phpreturn.com 所有

举报/反馈/投稿邮箱:phpreturn@ulthon.com

鲁ICP备19027671号-2