Clara轻量论坛系统

PHP 防 XSS 的三层防线:转义时机比转义函数更重要

东来东往
东来东往 正式会员正式会员认证极客认证极客
发布于 2026-08-06 14:50 ·101 浏览 ·0 回复

XSS 防线真正决定成败的不是你用了哪个转义函数,而是你在什么时候转义:正确姿势是「输入只校验、存储保原文、输出按上下文转义」,把转义全部推迟到数据进入 HTML 之前的最后一刻。提前转义、全局转义、只转一次就到处复用,才是绝大多数 XSS 漏洞和"转义后页面全是 & 乱码"的共同根源。

第一层:输入层只做校验和收敛,不做转义

结论:输入层的职责是判断"这份数据允不允许进来、是什么类型",而不是把它改造成看起来安全的字符串。

校验要做的是白名单化:ID 必须是整数就用 filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);邮箱、URL、日期各自用对应过滤器;枚举字段比对预设数组。校验失败的请求直接拒绝,而不是"净化后放行"。

这一层不要做的事:不要 strip_tags() 全量清标签,不要 addslashes() 当成防 XSS(它只对 SQL 场景有点历史意义,对 HTML 上下文完全无效),更不要在这里 htmlspecialchars()——那是在替输出层做决定,而输入层根本不知道自己将来会被渲染到 <div>、<input value=""> 还是 <script> 里。

唯一例外是富文本:如果业务允许用户提交 HTML(发帖、评论带格式),输入层就要用 HTMLPurifier 之类的白名单过滤,只保留允许的标签和属性。这是"过滤"而不是"转义",两者别混为一谈。

第二层:存储层保真,别把转义后的脏数据写进库

结论:入库前转义是 XSS 防护里最经典的反模式,它会造成二次转义和上下文错配。

假设你在存库时把 Tom & Jerry 变成 Tom &amp; Jerry。三年后你要输出一份 JSON API 给 App 用,App 端不需要 HTML 转义,于是用户看到的是 Tom &amp; Jerry;再被前端框架转义一次,页面上就是 Tom &amp;amp; Jerry。要修复只能全库洗数据,这就是提前转义的长期代价。

数据库里永远存用户输入的原始语义。真正的转义发生在渲染的那一刻,因为只有那一刻你才知道输出上下文。

顺带一提:无框架系统(比如 Clara BBS)把"输出转义"写进安全基线,但基线是给代码执行的约定,不是自动兜底——你自己新写的模板或插件里 echo 出去的内容,仍然要自己按上下文处理,别指望别人替你转。

第三层:输出层按上下文选函数,这是唯一真正的防线

结论:转义函数必须跟着输出位置走,HTML 正文、属性、URL、JS 四种上下文没有万能函数。

HTML 文本和属性上下文,统一用 htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')。ENT_QUOTES 同时处理单双引号,这是属性里必须的;ENT_SUBSTITUTE 让非法 UTF-8 字节变成替代字符而不是返回空串(否则在某些编码下会直接漏掉整段内容)。

// 文本节点
echo htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

// 属性值(一定要带引号)
echo '<a title="' . htmlspecialchars($t, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '">';

URL 上下文不能只靠 htmlspecialchars。javascript: 协议本身是合法字符串,转义救不了你,必须先做协议白名单校验,再拼接:

$u = parse_url($raw, PHP_URL_SCHEME);
if (!in_array($u, ['http', 'https'], true)) { $raw = '#'; }
echo '<a href="' . htmlspecialchars($raw, ENT_QUOTES, 'UTF-8') . '">';

JS 上下文要用 json_encode 而不是手工拼引号,并且加 JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT,把 <、>、引号全部转成 \uXXXX,避免用户数据里的 </script> 提前闭合脚本块:

<script>var cfg = <?= json_encode($data, JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_UNESCAPED_UNICODE) ?>;</script>

前端侧同理:能用 textContent 就别用 innerHTML,能交给框架插值就别手动拼字符串。

兜底:CSP 加白名单,别指望单点防御

结论:输出转义管"数据",CSP 管"浏览器行为",两层叠加才算完整。

加一个响应头就能挡掉大量漏网之鱼:

header("Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'");

CSP 的价值在于:即使某处输出忘了转义,内联脚本和外部域脚本也被禁止执行,XSS 从"必然成功"降级成"大概率失败"。注意别为了图省事加 unsafe-inline,那等于把这道防线拆掉。

总结一下:输入层校验类型和权限,存储层保留原始语义,输出层按 HTML/属性/URL/JS 四种上下文分别转义,再用 CSP 兜底。转义函数的参数可以背下来,但"什么时候转义、转到哪个上下文"才是决定系统安不安全的那一步——这也是为什么你在任何一次代码审计里,都应该优先搜 echo、<?= 和模板插值点,而不是搜 htmlspecialchars 出现了几次。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-368.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~