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

CLARA轻量论坛系统
CLARA轻量论坛系统 星耀SVIP管理员 黑卡会员
发布于 2026-09-15 10:43 ·13 浏览 ·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

还没有回复,来抢沙发~