XSS与CSRF防线:从输出编码到token校验

一条留言里的 img 标签差点让我背锅

站上的评论区一直是我自己写的,图省事,提交之后直接把内容 echo 出来。这个状态持续了大半年,直到有一天一个读者发消息说:你这站的留言提交后刷新一下,会弹窗。我一开始以为是他浏览器的问题,后来自测才发现——有人留了一条带 <script> 的评论,页面一加载就执行了。虽然我没看到它做了什么坏事,但这条链路已经通了,只是还没人利用它。

顺着这条线往下查,我发现问题不止一处。还有一个表单,只要构造一个外部页面,让已登录的我在浏览器里打开它,就能悄悄提交一个删除请求。前者是 XSS,后者是 CSRF,两个问题经常被放在一起说,但它们的成因、修法和检查方式完全不同,混着讲最容易修错。

这篇记录我从发现到完成的整个过程,重点是把这两类问题的边界划清楚,以及给出真正能防住的写法,而不是只加一层「看起来安全」的过滤。

第一步:先分清它们各自在攻击什么

简单但准确的说法:XSS 攻击的是「你的用户对页面的信任」,CSRF 攻击的是「服务器对浏览器的请求的信任」。

XSS 的原理是:你把用户输入原样输出到 HTML 里,浏览器把它当成代码执行。攻击者能做的包括读取 Cookie、改写页面、发请求,本质上是「借你的页面执行攻击者的代码」,执行环境是目标站点。

CSRF 的原理是:浏览器会自动带上目标站点的 Cookie 去发请求,不管你从哪个页面发起的。攻击者在自己站上放一个表单,action 指向你的删除接口,受害者只要访问那个页面,删除请求就带上了他的登录态。攻击者看不到响应,但只要请求生效目的就达到了。

这个区别决定了修法:XSS 的修法核心是「输出时编码」,CSRF 的修法核心是「请求里必须带一个攻击者猜不到的凭证」。如果只做输入过滤,两个都防不住;如果只做输出编码,CSRF 依然有效。

第二步:XSS 的三种形态,别只防一种

很多人以为 XSS 就是「在输入框里写 script 标签」,这只对应最基础的反射型。实际上有三种形态,防护位置完全不同。

存储型是我遇到的这种:恶意内容被存进数据库,之后每个访问页面的用户都会中招。危害最大,因为一次注入影响所有人,而且会持久存在。防护点在于「从数据库读出来输出到 HTML 时编码」。

反射型是恶意内容在 URL 参数里,被页面原样输出,比如搜索页把关键词回显出来。攻击方式是诱导受害者点击一个构造好的链接。防护点在于同样的地方——输出编码。

DOM 型最容易被忽略,因为服务端完全看不到问题。当前端 JavaScript 把 location.hash 或者 location.search 的值用 innerHTML 塞进页面时,就是这个洞。这种情况下你在 PHP 里做多少 htmlspecialchars 都没用,因为数据根本没经过服务端。防护点在 JavaScript 本身。

我站上的搜索页就有一个 DOM 型的隐患:前端为了高亮关键词,把 URL 里的 q 参数直接拼进 innerHTML。这个代码在服务端完全看不出问题,只有读前端源码才能发现。所以检查 XSS 必须前后端一起看。

第三步:输出编码——记住「按上下文编码」这一条

这是防 XSS 最根本的手段,也是最容易做错的地方。因为「编码」不是一个统一的动作,它取决于你要插入的位置。

插入到 HTML 文本或属性里,用 HTML 实体编码,PHP 里就是 htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')。三个参数都不能省:ENT_QUOTES 让单双引号都转义(默认只转双引号,属性用单引号包的时候会漏),ENT_SUBSTITUTE 让非法 UTF-8 序列变成替换字符而不是返回空串(否则可能造成意外的截断),'UTF-8' 显式指定字符集(历史上有个经典的宽字节绕过,就是依赖默认字符集)。

插入到 JavaScript 变量里,不能用 HTML 实体编码——那在 JS 里是无效的,会直接语法错误或者被当作字符串内容。正确做法是 json_encode($s, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT),然后整体作为 JS 字面量。这几个 flag 的作用是把 <、>、&、引号都转成 \u 形式,这样即使内容里带 </script> 也没法提前闭合标签。

插入到 URL 参数里,用 rawurlencode()。插入到 CSS 里(比较少见),用 CSS 的转义序列。插入到 HTML 注释里,最省事的做法是——不要往注释里插用户输入,因为注释的转义规则非常绕。

这里我要强调一个反直觉的点:不要用一个「万能过滤函数」处理所有输出。 常见做法是写一个 clean() 函数,内部干脆把 < > " ' & 全部替换掉,然后所有地方都调它。这样做在 HTML 上下文里勉强能用,但一旦输出到 JS 或 JSON 里,就会把数据本身弄坏(比如一个正常的 JSON 字符串会变成非法)。按上下文编码、每个上下文用对应的函数,才是正确姿势。

还有一点:编码必须在「输出的时候」做,而不是「存库的时候」做。如果存进去的时候已经转义了,那么你在后台编辑、导出 CSV、发邮件通知的时候都会拿到转义后的内容,变成一团乱码,而且想再放进别的上下文就没法处理了。数据入库保持原样,出口按上下文编码,这是唯一能长期维护的方式。

第四步:富文本怎么办——白名单比黑名单可靠得多

评论区如果只允许纯文本,上面这些就够了。但我站上允许一些简单格式,这就复杂了:你不能把用户输入的 HTML 全转义,否则标签会显示成文字。

这时候唯一可靠的做法是「白名单解析」,也就是把用户输入当作 HTML 解析成树,然后只保留允许的标签和属性,其余全部丢弃或者转义成文本,最后重新序列化。绝对不要用正则去删 <script> 标签——这个路子一定有洞,因为 HTML 的解析规则比正则能表达的复杂得多,比如 <scr<script>ipt> 这种嵌套、属性里带换行的畸形标签,正则处理不干净,但浏览器会正常解析。

PHP 里最省事的是用现成的 HTML Purifier。它比较重,但配置项清晰,把允许的标签列出来就行:

$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,br,strong,em,code,pre,blockquote,a[href|title],ul,ol,li');
$config->set('URI.AllowedSchemes', ['http' => true, 'https' => true]);
$config->set('HTML.TargetBlank', true);
$config->set('Cache.DefinitionImpl', null);
$purifier = new HTMLPurifier($config);
$safe = $purifier->purify($input);

这里 URI.AllowedSchemes 是关键的一行。如果不设,<a href="javascript:alert(1)"> 这种链接是可以通过标签白名单的——标签和属性都合法,但协议是危险的。这类问题叫「协议型 XSS」,是富文本过滤最常见的漏点。除了 javascript:,还要考虑 data:(可以塞 SVG 或者 HTML)和 vbscript:。

我把缓存目录设成了 null 是因为缓存目录需要可写,而 display_errors 在某些情况下会把 cachedir 的路径暴露在页面里。这个信息泄漏不致命,但没必要。代价是每次都要重新解析定义,性能略有损失;对于访问量不大的站完全可以接受,如果评论量大,还是应该给它一个可写目录并确保不在 web 根下。

第五步:CSRF 的修法——token 要绑定、要一次性、要校验来源

CSRF 的防护思路是让每个敏感请求都带一个攻击者拿不到的凭证。攻击者可以构造请求,但读不到你页面里的 token,所以伪造的请求会被拒。

实现上有两个层次,我建议都用上。

第一个层次是同一个会话内有效的 CSRF token。生成方式要满足「不可预测」:用 random_bytes(32) 然后 bin2hex,不要用 rand、mt_rand,更不要用时间戳或 uniqid。token 存在 session 里,表单提交时作为隐藏字段带上,服务端用 hash_equals() 比较。

hash_equals 这个函数不能换成 ==。原因是字符串比较在 PHP 里是短路的,理论上可以通过响应时间差异逐字节猜出 token。这个攻击在真实网络环境里很难实现,但换成 hash_equals 几乎没有成本,没理由不用。

第二个层次是 SameSite Cookie 属性。这个属性从浏览器层面阻止「跨站请求带上 Cookie」,成本极低:

session_set_cookie_params([
    'lifetime' => 0,
    'path'     => '/',
    'domain'   => '.zz1984.com',
    'secure'   => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);
session_start();

Lax 的含义是「从外部站点跳转过来的 GET 请求会带上 Cookie,但 POST 之类的跨站请求不带」。这一条就挡住了绝大多数 CSRF,因为 CSRF 攻击基本都是跨站的 POST。Strict 更安全,但会导致「从搜索引擎或者别人链接点进来时,如果本来已登录,会显示未登录状态」,对体验有影响。所以我把大部分站设成 Lax,只有涉及资金或者删除的路径才用 Strict。

有一点必须注意:session_set_cookie_params 要在 session_start 之前调用,顺序错了完全无效,而且不会报任何错。这个坑我踩过,改完之后测了很久才发现是顺序问题。

另外,httponly 是防 XSS 偷 Cookie 的,和 CSRF 没有直接关系,但应该一起设。开了之后 JS 读不到 document.cookie,配合服务端的 HttpOnly 头,即使有 XSS 也能限制一部分危害。它不是弥补 XSS 的方案,但能降低损失。

第六步:让 token 校验真正覆盖所有入口

token 生成很简单,难的是「不漏入口」。我第一版只给评论表单加了,第二天想起来还有一个 AJAX 的「点赞」接口也没加——因为点赞不涉及敏感数据,我觉得无所谓。但事实上点赞接口可以被 CSRF 用来刷量,虽然危害小,逻辑是一样的。

比较稳的做法是在请求分发的最前面统一校验,而不是每个处理方法自己调。我的路由分发处加了这样一段逻辑:

$method = $_SERVER['REQUEST_METHOD'];
$isMutating = in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true);
$safePrefixes = ['/pay/notify'];   // 第三方回调,无 session,单独校验签名

if ($isMutating && !in_array($path, $safePrefixes, true)) {
    $sent = $_POST['_csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
    if (!is_string($sent) || $sent === '' || !hash_equals($_SESSION['csrf'] ?? '', $sent)) {
        http_response_code(419);
        exit('invalid csrf token');
    }
}

这里用 $_SERVER['HTTP_X_CSRF_TOKEN'] 是为了支持 AJAX 请求,前端在请求头里带 token 比塞在 body 里更自然。两条路径都读,方便不同客户端。

白名单里那几个第三方回调必须单独处理,而且要校验签名。它们的请求来自外部系统,没有 session,自然没有 token;如果不放进白名单,正常业务会被拦掉;如果放进去但忘了验签名,那就等于开了一个无鉴权的入口,比不做 CSRF 还危险。

还有一个细节:token 在用户登录状态改变(登录、登出、改密码)时应该重新生成。 否则存在一个叫 session fixation 的问题:攻击者先拿到一个会话 ID,诱导受害者使用,登录之后攻击者仍然持有着同一个会话。同时重新生成 sid 和 csrf token,能把这个洞堵住。

第七步:检查清单——怎么验证你真的防住了

修完之后必须验证,而且要按攻击者的视角验证,不是按开发者的视角自测。

XSS 的验证:准备一组 payload 逐个试。最基本的是 <script>alert(1)</script>,但这只是入门。真正应该试的包括属性里的 "><svg onload=alert(1)>、<img src=x onerror=alert(1)>、<a href="javascript:alert(1)">x</a>、<iframe srcdoc="<script>...">。把它们分别提交到每一个会显示用户内容的位置:评论、昵称、签名、搜索关键词回显、错误提示。看渲染出来的 HTML 源码里,这些内容是不是变成了 &lt; 开头的实体。

这里有一个判断技巧:看源码而不是看页面。 页面上看起来「没有弹窗」不代表安全,可能是 paylaod 格式恰好不执行。看源码能确认它是被编码了还是被当成标签解析了。

CSRF 的验证:在自己本地写一个 html 文件,用 file:// 打开,页面里有个 form 的 action 指向站上的删除接口,method 是 post。保持站上已登录状态,点提交。正确的表现应该是返回 419 或者跳转到错误页,而不是真的删掉了。这个测试必须在真实浏览器里做,用 curl 测试是无效的——因为 curl 不会自动带 Cookie,测不出 CSRF 的问题。

再补一个检查:把表单里的 _csrf 字段删掉,或者改成一个随机值,提交。同样应该被拒。这说明校验是真的在生效,而不是走了一个后端没读的字段。

几个我认为值得单独强调的边界

第一,htmlspecialchars 不是万能的,它只适用于 HTML 上下文。前面说过按上下文编码,这里再强调一次:很多人把「输出全部 htmlspecialchars」当成安全规范,结果在 JSON 接口里把内容弄坏了,或者在 <script> 里埋下了新的洞。规范应该是「每个输出点都用对应上下文的编码」,而不是「全部转义」。

第二,CSP 是纵深防御,不是替代品。设一个合理的 Content-Security-Policy,可以在 XSS 已经存在的情况下阻止脚本执行(比如禁止内联脚本、限制 script-src 到自己的域名)。但 CSP 配置很复杂,稍不留神就会把正常的页面功能全弄坏(比如内联的事件处理器、CDN 上的脚本)。我的建议是先从 Content-Security-Policy-Report-Only 开始,收集一段时间的违规报告,再决定怎么收紧。直接上强制策略,大概率会把站点搞挂。

第三,CSRF token 不能防止「同一个站内的越权」。如果你的删除接口只检查了「已登录」,没有检查「这篇评论是不是你写的」,那么攻击者用任何方式拿到 token(比如从自己的账号会话里)之后,仍然能删别人的东西。这是另一个问题,叫越权访问或者 IDOR,修法是每个操作都要校验对象归属,而不是只校验身份。这两类问题经常被搞混,值得单独排查一遍。

第四,日志里记录攻击尝试是有价值的,但要注意别把 payload 原样写进日志。前面那篇关于日志脱敏的笔记里提到过:日志里出现 <script> 这些内容,如果日志查看页面本身没有正确编码,那么看日志的人反而是被攻击的对象。这是一个典型的「安全措施的副作用」,值得警惕。

最后一点感受:这两个问题都属于「不难,但极容易做一半」的类型。做一半的意思是——主表单加了 token,AJAX 接口没加;评论内容编码了,昵称没编码;HTML 上下文转了,JSON 输出忘了。之所以会这样,是因为它们散落在很多地方,靠记忆一定会漏。所以最终的解决方案不是把每一处都修好,而是把校验和编码收口到统一的出入口:所有输出走一个模板函数,所有写操作走一个统一的前置检查。这样即使后来加了新功能,只要走的是同一条路,就不可能漏掉。

Last modification:October 25th, 2026 at 07:26 pm

Leave a Comment