几个月前,我在预发布环境上亲眼看着一个机器人向 /admin/videos/delete 发起 POST,把测试播放列表清了个干净。那个请求没经过任何表单认证,却带着一个合法的会话凭据——浏览器对所有同源请求都会自动附上该凭据,我的后台也无条件信任了那个会话。
这是最纯粹的跨站请求伪造(CSRF),而且对于 PHP 视频管理面板来说,触发起来难堪地容易:所有“审核通过”“删除”“推上首页”的操作,都是只靠登录会话保护的状态变更 POST。
我负责 ViralVidVault 的后端。这是一家欧洲病毒视频发现服务,我们的管理后台就是编辑团队审核爆款片段、调整分类权重、刷新缓存的地方。这些端点正是那种高价值、基于会话认证,CSRF 首当其冲的目标。
这篇文章会讲清楚,我们如何在 PHP 8.4 上用双重提交令牌模式保护它们——为什么没选服务器端的同步令牌、那个签名细节几乎每个教程都搞错,以及跑在 LiteSpeed 和 Cloudflare 背后的生产代码思路。
为什么不选经典同步令牌?那种模式在服务器端会话中存一个随机令牌,再比对隐藏表单域里的值。这能工作,但到我们这种规模,运维代价就上来了。每渲染一个表单都要读写会话状态——这意味会话文件(或 Redis 键)在请求完成前被锁定,对并发 AJAX 批量操作的管理后台来说,等于把同一用户的请求都串行化了。我们的会话存在 WAL 模式的 SQLite 里,每次渲染表单都写令牌,会带来我们极力想避免的写放大。而且在 Cloudflare 和 LiteSpeed 边缘缓存体系下,任何强制每次请求都写会话的东西,往往会迫出 Cache-Control: private,这种头对管理后台虽没问题,但稍不留神就会泄漏到共享代码路径里。
双重提交令牌模式完全绕开了服务器端存储。思路很简单:先发一个随机令牌到客户端(通过 Set-Cookie),再把这个令牌作为隐藏域(或 AJAX 的 X-CSRF-Token 头)塞进每个表单。收到状态变更请求时,就拿客户端发来的令牌和请求体里的令牌比较;一致就放行,否则拒绝。这个安全特性来自同源策略:攻击者架在 evil.example 上的页面可以强迫受害浏览器带上我们的客户端令牌,但 evil.example 上的 JavaScript 根本读不到该令牌,自然没法把它的值复制到请求体里。所以攻击者顶多能满足客户端那一半,永远凑不齐请求体那一半。
问题在于,一个天真的双提交实现是完全能被绕过的——只要攻击者能在你域名上种一个客户端令牌(比如借助被攻破的子域、未加密兄弟站点上的中间人,或者令牌注入漏洞)。修复办法是对令牌做签名,让服务器能验证这个令牌是自己签发的。这就是 OWASP 所称的“签名双重提交令牌”,也正是我们要走的路。而且这个签名细节,看过的教程里几乎没有一个做对的。
热门跟贴