你的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分钟,自己动手试:
- 用VS Code新建一个
poc.html,写个最简表单,action指向你网站的转账接口; - 用自己账号登录网站,打开开发者工具→Network,随便找一个带
csrf_token的POST请求,复制它的token值; - 把这个token硬编码进
poc.html的隐藏字段里; - 在另一个浏览器(已登录同一账号)打开
poc.html,点提交。
如果转账成功了——立刻停下手头所有需求,先修这里。
如果失败,再检查响应状态码:是403(校验拦截了),还是500(代码崩了)?后者更危险。
今天下班前就能完成的一个加固步骤
打开你正在维护的项目,找到处理登录成功的后端代码(比如auth/login.go 或 controllers/auth.py)。
在用户会话创建后、返回响应前,插入一行:生成并存入一个新CSRF令牌(用你语言的标准安全随机库)。
然后,打开全局HTML模板(比如base.html 或 layout.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,泡杯茶。今晚你写的代码,已经比昨天多了一层真实防护。