你的CSRF防护是不是只做了个样子?

“加了csrf_token,不就安全了吗?”
——很多开发者写完表单就关掉编辑器,结果上线三个月后,用户投诉“莫名其妙转走了500积分”。

别急着甩锅给测试。问题大概率出在:你加的不是令牌,是张过期的电影票。

为什么你的CSRF令牌总在关键时候失效?

CSRF攻击不黑密码,不爆数据库。它只做一件事:骗浏览器,用你自己的身份,帮你点下那个你不该点的按钮。

令牌失效?往往不是没生成,而是“太好猜”或“太耐放”。

比如:用户登录后,系统只生成一次令牌,之后开十个标签页、切三次微信再回来——令牌还是同一个。
这就像给整栋楼只配一把钥匙,谁拿到都能进所有房间。

更麻烦的是,有些框架默认把令牌存在内存里,重启服务就全丢了;有些又存得太久,用户早上登录,令牌到半夜还有效。
中间只要有一个页面被恶意诱导提交,防护就归零。

正确的CSRF令牌应该长什么样?

它得满足三件事:

  • 随机得像彩票:必须用crypto/rand(Go)、secrets(Python)或random_bytes()(PHP)这类真随机源,别用Math.random()
  • 一人一码,一码一用:每个会话有自己的令牌,但更理想的是——每个敏感操作前,临时生成一个新令牌。
  • 绑死动作和时间:修改邮箱的令牌,不能拿来删地址;10分钟前生成的,现在就不认。可以简单拼上时间戳+操作类型哈希,不用搞复杂加密。

说白了:它不该是个ID,而是一张带时效、带用途、带指纹的临时门禁卡。

5个方法,堵住CSRF令牌的常见泄漏点

1. Referer 和 Origin 不是备选,是必填项
对所有POST/PUT/DELETE请求,后端必须校验Origin头是否属于你的域名。如果没这个头,或者来自evil.com,直接403。别信“反正有令牌”,这是第一道筛子。

2. 双提交Cookie:让浏览器自己帮你防
把令牌同时写进表单隐藏域 Cookie(记得设SameSite=Lax)。后端比对两者是否一致。恶意网站能发请求,但读不到、改不了你的Cookie——这招成本低,见效快。

3. 表单渲染即生成,提交即作废
用户打开“修改手机号”页面时生成令牌A;提交成功后,立刻从会话里删掉A。下次再进这个页面,生成全新的B。别省那点CPU,重放攻击就靠这个“一次性”卡住。

4. 关键操作前强制刷新令牌
点击“确认支付”按钮时,前端先发个轻量API(比如/api/csrf-refresh),后端生成新令牌并返回;表单再用这个新令牌提交。这样即使旧令牌被截获,也只剩0.5秒窗口。

5. Web端和App端,别混着验证
如果你的API既给网页用,也给iOS/Android App调用:网页走CSRF令牌+Cookie;App走Bearer Token + 签名。后端中间件里明确分两路处理,别让App请求意外触发CSRF校验逻辑——那只会导致App频繁报错。

实战案例:一个“安全”的转账功能是如何被攻破的?

去年帮一家本地生活平台做安全复查,他们有个“余额赠送给好友”功能。
表单里确实有<input type="hidden" name="csrf_token" value="...">,POST提交,后端也校验了。

但问题藏在细节里:

  • 令牌在用户登录时生成,30天不变;
  • 分享链接是GET请求:/share?to_user_id=888&amount=100
  • 后端没拦GET改数据,直接执行了转账;
  • 攻击者伪造一个“查看优惠券”的分享页,URL里悄悄把/share换成/api/transfer,参数照填。用户一点,钱就没了。

根本原因就两条:

  • 把GET当成了“只读”,结果它干了转账的事;
  • 令牌没绑定“转账”这个动作,也没限制只能用于POST。

如何验证你的CSRF防护真的有效?

别等渗透测试报告。今天下午花10分钟,自己动手试:

  1. 用VS Code新建一个poc.html,写个最简表单,action指向你网站的转账接口;
  2. 用自己账号登录网站,打开开发者工具→Network,随便找一个带csrf_token的POST请求,复制它的token值;
  3. 把这个token硬编码进poc.html的隐藏字段里;
  4. 在另一个浏览器(已登录同一账号)打开poc.html,点提交。

如果转账成功了——立刻停下手头所有需求,先修这里。
如果失败,再检查响应状态码:是403(校验拦截了),还是500(代码崩了)?后者更危险。

今天下班前就能完成的一个加固步骤

打开你正在维护的项目,找到处理登录成功的后端代码(比如auth/login.gocontrollers/auth.py)。
在用户会话创建后、返回响应前,插入一行:生成并存入一个新CSRF令牌(用你语言的标准安全随机库)。

然后,打开全局HTML模板(比如base.htmllayout.blade.php),在<form>开头处统一加上:

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

最后,定位到全局中间件(如Express的app.use()、Django的MIDDLEWARE、Laravel的app/Http/Middleware/VerifyCsrfToken.php),确保它对所有非GET/HEAD/OPTIONS请求,都校验csrf_token字段是否匹配会话中存储的值。不匹配?直接return 403

三步做完,关掉IDE,泡杯茶。今晚你写的代码,已经比昨天多了一层真实防护。