接口在浏览器里能用,复制成 curl 就 403:一次 CSRF 签名头的排查记录
接口在浏览器里能用,复制成 curl 就 403:一次 CSRF 签名头的排查记录
现象:同一个接口,两种命运
做浏览器自动化时,很容易产生一个念头:页面上的操作,我干脆不点 UI 了,直接把接口调一遍不就行了?
于是我打开开发者工具,找到页面刚发出的那个请求,右键 “Copy as cURL”,扔进终端,回车。
HTTP/1.1 403 Forbidden
{"error":"no request_csrf found"}
同一个 URL、同样的 cookie、同样的请求体,在页面里是 200,在终端里是 403。
退一步,我回到浏览器控制台,用最"原始"的方式发一次:
await fetch('/rest/xxx/growth/bonus-beans', {
credentials: 'include',
headers: { accept: 'application/json' }
});
// 403 {"error":"no request_csrf found"}
仍然是 403。而就在同一时刻,页面上点一下按钮,这个接口返回 200。
这说明问题既不在登录态,也不在跨域,而是这个请求本身带了某些我没带上的东西。
第一个坑:Copy as cURL 并不复制全部
devtools 的 “Copy as cURL” 只复制它认为重要的头,而且很多前端会把这些头在发出前一刻动态注入,复制出来的快照经常缺东西。
正确做法是直接对比真实请求头。用 CDP 抓一次页面自己发的请求:
await send('Network.enable');
// 触发页面操作后,从事件里拿到真实请求头
on('Network.requestWillBeSent', e => {
if (/rest/.test(e.request.url)) console.log(e.request.headers);
});
把这份头和我的 fetch 头逐项比对,差异就出来了:页面请求里多出好几个 x- 开头的自定义头,其中一个是 CSRF token,另一个是请求签名,还有一个时间戳。
根因:服务端校验的不是 cookie,是签名头
很多网关(WAF、API 网关)的校验链路是分层的:
- 先看 cookie 里的会话,判断"你是谁"——这一层我从来都是通过的;
- 再看请求头里的一组自定义头,判断"这个请求是不是由本站页面发出的"——这一层我全军覆没。
第二层之所以存在,是因为它要防 CSRF:攻击者能带上用户的 cookie,但读不到页面里的 token,也算不出正确的签名,所以伪造不了这组头。
关键点在于:这组头不是静态的。它是前端在发请求前,用 token 加时间戳现算出来的。也就是说:
它不是"一个可以复制的头",而是一整套"每次都要重算的协议"。
所以把它复制下来也没用——过了时间窗口就失效。这也是为什么重新登录之后,同样的代码偶尔又能跑通一小会儿。
诊断顺序
再遇到"页面能用、脚本不行",我现在的顺序是:
- 看状态码含义。401 是身份问题,403 是授权/校验问题。看到 403 先去想"缺了什么校验凭证",而不是"权限不够"。
- 比对真实请求头。不要相信 Copy as cURL,用 Network 面板或 CDP 拿页面自己发出的那份。
- 确认是不是动态值。把头里的可疑项连续抓两次,如果两次不一样,说明是时间戳或签名,复制方案直接出局。
- 看错误文案。
no request_csrf found这类文案其实已经把答案写在脸上了,比猜半天强。
三条出路
第一条:驱动 UI,而不是重放接口。
既然服务端要求"必须是页面发的请求",那就老老实实让页面来发。用浏览器调试协议派发真实点击:
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1, buttons: 1 });
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
这条路慢,但它是唯一不需要逆向签名的方案,也最稳定——页面改版时,接口签名往往跟着变,但"点按钮"这个动作的含义不变。
第二条:复用页面自己的请求封装。
如果非要在页面上下文里发请求,就别手搓头。页面里一定有一个统一的请求函数(axios 实例、fetch 包装),拦截器已经把签名挂好了。找到它、调用它,让拦截器继续干活:
// 找到页面已有的封装,而不是自己 new 一个请求
const res = await window.__http.get('/rest/xxx/bonus-beans');
缺点是这种全局变量往往是内部实现,随时可能改名,属于能用但不该依赖的路径。
第三条:真的去实现签名。
对着压缩后的 JS 逆出签名算法。除非你在做压测或者正经的接口测试,否则性价比极低,而且前端一升级就白干。
一个容易被忽略的细节
我一开始把 credentials: 'include' 当作万能钥匙,觉得"cookie 带上就行了"。实际上它只解决"带上身份",不解决"证明这个请求合法"。
这两件事经常被混为一谈。跨域调试里它们确实是同一套机制,但到了网关这一层,身份和合法性是两个独立的校验项。
小结
- Copy as cURL 只能复制快照,复制不了协议。带签名的请求,快照天然不完整。
- 403 不等于没权限,先怀疑校验凭证缺失,再怀疑授权。
- 排查的锚点是页面自己发出的那份请求头,不是你的猜测。
- 自动化的默认方案应该是驱动 UI,重放接口只是在你确认没有动态校验时才成立的优化。
顺带一提,这套机制的副作用是:它让"网页能做的事,脚本才做得到"这件事变得更加成立。对你的自动化脚本而言,这不是坏事——页面上能点的东西,你就一定能点。
- 点赞
- 收藏
- 关注作者
评论(0)