
一个表单提交接口在网关后面变成了 GET,body 里的字段全空,日志上只留一行 200。改写发生在跳转的第二步:客户端收到 3xx 之后要不要把原方法带过去,由状态码以及这一次跳转是否同源共同决定。本机起了两个 node:http 服务端,跳转端占一个端口,终点端占另一个端口,每个到达的请求都记下方法,以及请求体字节数和两个敏感头在不在。客户端用 Node v26.8.1 自带的 fetch,也就是 undici。文章里的数字都出自同一次运行。
方法在同源跳转的第几跳被换掉
先把五种状态码各跑一遍,请求都是 POST,带 4 字节体,Authorization 与 Cookie 各带一个。
== 1. 同源跳转:POST 带 4 字节体,Authorization 与 Cookie 各带一个 ==
状态码 跳转端首跳收到 终点端最终收到 终点端体字节 Authorization Cookie
301 POST(4B) GET(0B) 0B 带 带
302 POST(4B) GET(0B) 0B 带 带
303 POST(4B) GET(0B) 0B 带 带
307 POST(4B) POST(4B) 4B 带 带
308 POST(4B) POST(4B) 4B 带 带
== 1 结束 ==
301、302、303 三行的终点端收到 GET,体 0 字节;307 与 308 两行原样是 POST,体还是 4 字节。第一跳收到的报文五种状态码一模一样,改写只发生在客户端决定发第二次请求的那一刻,服务端没有参与。301 与 302 的历史实现里有「允许保留 POST」的说法,undici 这个实现一律换成 GET。
体掉到 0 字节还有一层影响:终点端收不到任何正文,Content-Length 那一格直接空掉。挂在体上的东西跟着一起消失,表单字段与 JSON payload 都算在内。拼在 URL 上的 query 不受影响,仍能到达终点。这也是「参数全空」这类问题最常见的成因。
跨源之后方法还在,两个敏感头没了
终点端换到同一台机器的另一个端口,在 fetch 眼里这已经是另一个源。
== 2. 跨源跳转:同一台机器换一个端口,终点端看到什么 ==
状态码 终点端最终收到 终点端体字节 Authorization Cookie 最终状态
301 GET(0B) 0B 没带 没带 200
302 GET(0B) 0B 没带 没带 200
303 GET(0B) 0B 没带 没带 200
307 POST(4B) 4B 没带 没带 200
308 POST(4B) 4B 没带 没带 200
== 2 结束 ==
方法那一列按状态码各走各的:301、302、303 到终点是 GET,307 与 308 保住 POST 与那 4 字节体。Authorization 与 Cookie 那一列全是「没带」。同一个源上这两个头都还在,跨源之后一起消失,说明方法改写与头部剥离是两套独立规则,前者只看状态码,后者只看源。头被剥掉这件事与状态码无关,五种一视同仁。
落到真实链路上,网关 307 到一个独立域名的鉴权服务,方法与请求体都到了,令牌没了,终点回 401,调用方只见到一句 fetch failed。写接口在网关后面挂过多个域名的话,凡是要先跳一次再落地的调用,凭证都得自己补在第二次请求上。被剥掉的那个令牌不会出现在任何一个中间服务端的日志里,排查时只能从终点端的 401 往回推。
PUT 与 PATCH 过 302 时不会被换
改写不是针对「写操作」这一整类,它认的只是 POST 这个动词本身。
== 3. 写操作里被换掉的只有 POST 吗:同源跳转 302 与 303 逐方法试 ==
状态码 请求方法 跳转端首跳收到 终点端最终收到
302 POST POST(4B) GET(0B)
302 PUT PUT(4B) PUT(4B)
302 DELETE DELETE(4B) DELETE(4B)
302 PATCH PATCH(4B) PATCH(4B)
303 POST POST(4B) GET(0B)
303 PUT PUT(4B) GET(0B)
303 DELETE DELETE(4B) GET(0B)
303 PATCH PATCH(4B) GET(0B)
== 3 结束 ==
302 那一组里只有 POST 换成了 GET,PUT、DELETE、PATCH 三种原样到达;303 那一组四种全换成 GET,体一律清零。差别出在规范给 303 的定位:它的语义是「去别处看结果」,用什么方法去都行,一律降级成 GET。302 只对 POST 做这件事,PUT 与 PATCH 不动。上传接口用 PUT 做幂等写入,过了 302 还活着,过了 303 就变成 GET,体没了。这条边界比「301 到 303 都会把 POST 变 GET」那种记法细一层。留一半的话,302 对 PUT 与 PATCH 的可见影响只有一次额外的请求。
redirect 设成 manual 之后,302 原样回到手里
默认值 follow 把中间的 3xx 全部吞掉,返回的是终点那个 200。
== 4. fetch 的三个 redirect 模式,调用方各看到什么 ==
follow: 最终状态 200,最终落在 /t,中间那几跳调用方看不到
manual: 状态 302,type=basic,location=http://127.0.0.1:63158/t,终点端收到 0 次
error: TypeError: fetch failed / cause: unexpected redirect
error 之后终点端收到 0 次
== 4 结束 ==
manual 把 302 连同 Location 原样返回,type 是 basic,终点端一次请求都没收到。要自己决定第二跳怎么发、凭证带不带,用这个模式最省事。error 直接抛错,cause 里写着 unexpected redirect,同样没有第二跳。三种模式在抓包上的差别就落在这里:follow 只留一条 200,manual 摊开 302 与它的 Location,error 只留一段调用栈。
循环跳转在第二十一次被叫停
一个永远指向自己的 302,服务端收到 21 次请求,然后 fetch 抛出 fetch failed,cause 写的是 redirect count exceeded。
== 5. 一个自己指自己的循环,fetch 跟到第几跳停 ==
TypeError: fetch failed / cause: redirect count exceeded
循环服务端一共收到 21 次请求
== 5 结束 ==
上限写在客户端实现里,各家的数不一样,代理层写死 5 的更常见。把 20 当成规范里的数是个误会,规范没有这一条。爬虫或者转发服务把跳转按默认值跟到底,链路长度就不在自己手里了。
代码里能落的两件事
客户端发写操作时给 redirect 显式赋值:需要跳转就选 307 或 308,让方法与请求体活着过去;不打算跳转就 manual,自己读 Location 再拼第二条请求。凭证这一层,我到现在也只在 undici 上量过,浏览器里 Authorization 被剥掉的确切边界没在本机跑过,不敢直接搬过去用,要在目标浏览器上重测一轮。