⭐ 推荐:社区规则条款 V1.0

PHP 自研 Markdown 渲染器:先转义再重建的 XSS 防御思路

最长的电影
最长的电影 正式会员正式会员
发布于 2026-08-15 20:40 ·96 浏览 ·0 回复
内容摘要

自研 PHP Markdown 渲染器要彻底防住 XSS,应先把用户原文整体 HTML 转义为纯文本,再按 Markdown 语法重建标签,以白名单构造替代黑名单过滤,避免先渲染再过滤因与浏览器解析规则不一致而被绕过。

结论先说:自研 PHP Markdown 渲染器要彻底堵住 XSS,唯一稳的顺序是「先把用户原文整体 HTML 转义,再按 Markdown 语法重建标签」——输入永远只是纯文本,页面上出现的每一个标签都由你自己的代码拼出来。这样做的本质是把防御从"黑名单过滤"换成"白名单构造",用户从源头就没有机会产生原生 HTML。

为什么"先渲染再过滤"注定会漏

结论:基于黑名单的 HTML 过滤器必须和浏览器的解析器比赛,而这场比赛的规则由浏览器单方面解释,你永远会输。

典型翻车点包括:<img src=x onerror=alert(1)>、<a href="javascript:...">、<svg/onload=...>、注释拆分 <scr<!-- -->ipt>、属性不加引号的 <img src=x onerror=1>,以及畸形嵌套标签被浏览器容错修复后"拼"出一个你没见过的 DOM。过滤器的解析逻辑和浏览器的解析逻辑一旦不一致,绕过就只是时间问题。

而"先转义再重建"路线下,< 在第一步就变成了 &lt;,用户根本无法进入标签语义层。上面这一整类攻击不是被拦住了,而是不存在了。

四步流水线:转义 → 抽离 → 重建 → 回填

结论:整个渲染过程只做一次转义,位置在解析之前;之后所有正则替换操作的都是你自己生成的标签和占位符。

第一步,整体转义:

$text = htmlspecialchars($raw, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

必须带 ENT_QUOTES(默认只转双引号,不转单引号),必须带 ENT_SUBSTITUTE——非法 UTF-8 时 htmlspecialchars 会返回空字符串,整篇帖子直接变白板,这个坑很隐蔽。

第二步,把围栏代码块和行内代码先抽出来存进数组,原文位置换成占位符。代码块内容不能被后续规则二次解析,否则一个反引号逃逸就能注入。

第三步,用正则把标题、粗体、列表、引用、链接等语法替换成自己拼的标签。这里所有 URL 都要过协议白名单:href 只放行 http/https/mailto 和站内相对路径,直接拒绝 javascript:、vbscript:、data:(图片的 data: 单独限尺寸白名单)。

第四步,把代码块回填。注意:抽走时存的是已转义文本,回填就直接放,不要再转义一次。

五个最容易踩的细节

结论:这套流程的失效点几乎都集中在"占位符、属性、二次转义"三件事上。

第一,占位符必须带随机盐。htmlspecialchars 不转义控制字符,所以 "\x02CB0\x03" 这种占位符理论上用户是能输入并伪造的。正确做法是用 random_bytes(4) 生成盐,渲染前先检查原文是否包含该串,包含就换盐重新生成。

第二,属性值一律双引号包裹再做一次属性转义。不要因为"URL 已经校验过"就省这一步,URL 校验管协议,属性转义管逃逸,两件事。

第三,严防双重转义。&amp; 再转一次会变成 &amp;amp;,页面上用户看到的是乱码。全流程只允许一个转义入口。

第四,别只防帖子正文。标题、签名、私信、附件文件名、用户昵称都是输出点。像 Clara BBS 这类轻量 PHP 社区,安全基线里明确包含"全站输出转义",含义就是每一个输出点都得走同一套转义函数,而不是只在正文里做。

第五,上传是另一条通道。上传文件类型白名单 + 图片二次校验(读文件头确认真实类型)缺一不可,SVG 建议直接禁掉或强制 Content-Disposition: attachment 下载。

再补一道 CSP 兜底

结论:渲染器是主防线,CSP 是最后的保险丝,两者不冲突。

在 Nginx 或 PHP 里加一条响应头即可:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'

即使渲染器未来出现未知绕过,内联脚本也执行不了。代价是要确保站内没有依赖 CDN 的内联脚本,上之前先测一遍。

性能上不用担心

结论:转义 + 正则重建是纯字符串操作,单篇文章量级下开销极小;如果还嫌慢,把渲染结果按文章 ID 缓存即可。

Clara BBS 提供的 Cache::remember 就很适合包在这一层外面,编辑保存时清一次缓存,其余时间直接读 HTML。这类系统本身没有编译缓存,插件和模板保存即生效,缓存策略也不会和它打架。

一句话收束:先转义保证了输入无法变成标签,重建保证了输出只能是白名单标签,剩下的工作就是盯紧占位符、属性和所有输出点,别在细节上把这套结构破坏掉。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-400.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 0

还没有回复,来抢沙发~