重定向以后 Authorization 没了,x-api-key 还在
主线位置:请求主线里 tower 栈的
FollowRedirect一层。循环归 tower-http、判断归 reqwest 的分工,三种策略,方法和 body 怎么处理,见 重定向。这一页只看跟随时请求头的去留。
flowchart TB
req["带凭据的请求"] --> r3["307 Location"]
r3 --> cmp{"和上一跳同源"}
cmp -->|同源| keep["五个头都保留"]
cmp -->|不同源| strip["去掉五个头"]
strip --> s401["新地址 401"]
strip --> leak["x-api-key 照发"]
keep --> next["继续跟随"]
实验 crate:exp-reqwest/pita-lab,src/bin/p09_redirect.rs。两个本机 testsrv(A 和 B)。请求发到 A 的 /redirect-to?status=307&loc=...,带 bearer_auth("tok-123") 和自定义头 x-api-key: key-456;跳转目标是 /echo,它把收到的请求头原样写回 body。M2 Max、macOS 27、rustc 1.99.0,release。
现象
== default policy
same origin, other path 200 OK at http://127.0.0.1:54245/echo sees ["authorization: Bearer tok-123", "x-api-key: key-456"]
127.0.0.1 -> localhost, same port 200 OK at http://localhost:54245/echo sees ["x-api-key: key-456"]
other port 200 OK at http://127.0.0.1:54246/echo sees ["x-api-key: key-456"]
- 同一个 origin 里换路径,两个头都在。
- 换端口,或者把
127.0.0.1换成localhost(同一台机器、同一个端口),Authorization没了。真实的 API 服务在这里会回401,调用方看到的是「token 无效」,而 token 本身没有问题。 x-api-key每一次都被带到了新地址。不少 API 用它(或类似的自定义头)放密钥,跳转到哪里它就去哪里。
把 x-api-key 的值标成敏感也没用:
== set_sensitive(true) on x-api-key, other port: sees ["x-api-key: key-456"]
原因
每跟随一次,tower-http 调 reqwest 的 on_request,它对下一跳调 remove_sensitive_headers:
pub(crate) fn remove_sensitive_headers(headers: &mut HeaderMap, next: &Url, previous: &[Url]) {
if let Some(previous) = previous.last() {
let cross_host = next.host_str() != previous.host_str()
|| next.port_or_known_default() != previous.port_or_known_default()
|| next.scheme() != previous.scheme();
if cross_host {
headers.remove(AUTHORIZATION);
headers.remove(COOKIE);
headers.remove("cookie2");
headers.remove(PROXY_AUTHORIZATION);
headers.remove(WWW_AUTHENTICATE);
}
}
}
- 什么算跨域: scheme、host 字符串、端口(没写时取 scheme 的默认端口)三者有一个不同。host 比较的是字符串,不做解析,所以
127.0.0.1和localhost是两个 host,api.example.com和example.com也是。比的是下一跳和上一跳。 - 去掉什么: 固定的五个头。名单写在函数里,
ClientBuilder没有配置项可以加。HeaderValue::set_sensitive只影响Debug输出和 HTTP/2 的头压缩,这个函数不看它。 - 不跨域时: 什么都不去,五个头和自定义头一起跟过去。
所以「认证失败」和「密钥外泄」是同一个机制的两面:reqwest 只认识它名单里的凭据头,名单之外的一律当成普通头照发。
修法
API 客户端:跨域时不要自动跟随。 用 Policy::custom 在 origin 变化时停下,把 3xx 交回调用方:
fn same_origin(a: &reqwest::Url, b: &reqwest::Url) -> bool {
a.scheme() == b.scheme() && a.host_str() == b.host_str() && a.port_or_known_default() == b.port_or_known_default()
}
fn stop_cross_origin(attempt: Attempt) -> reqwest::redirect::Action {
let prev = attempt.previous().last().cloned();
match prev {
Some(p) if !same_origin(&p, attempt.url()) => attempt.stop(),
_ if attempt.previous().len() >= 10 => attempt.error("too many redirects"),
_ => attempt.follow(),
}
}
let c = reqwest::Client::builder().redirect(Policy::custom(stop_cross_origin)).build()?;
== custom: stop cross-origin
same origin, other path 200 OK at http://127.0.0.1:54245/echo sees ["authorization: Bearer tok-123", "x-api-key: key-456"]
127.0.0.1 -> localhost, same port stopped: 307 Temporary Redirect, location=http://localhost:54245/echo
other port stopped: 307 Temporary Redirect, location=http://127.0.0.1:54246/echo
同源的跳转照常跟随,凭据保留;跨域的跳转变成一个 Ok(307),Location 在手里。调用方据此决定:目标是自己信任的主机(比如服务换了域名),就带上凭据重发一次,最好顺手把配置里的基地址改掉;不认识的主机就报错。stop 返回的是普通响应,要配合 4xx、5xx 也是 Ok 那样检查状态码,3xx 也要当成「没拿到结果」。
更简单的是 Policy::none():一律不跟随,API 调用本来就很少需要重定向。
只是本机测试里 127.0.0.1 和 localhost 混用: 统一成一种写法,跨域就不会发生。
怎么检查
- 比较最终 URL:
response.url()是跟随之后的地址,和请求的 URL 不同就说明发生过重定向。遇到莫名的401/403,先打印它。 - 看客户端的策略: 发
Authorization以外的密钥头(x-api-key、api-key、各家自己的头)的客户端,如果用的是默认策略,就有跨域外泄的可能。 - 写测试: 测试服务器回一个指向另一个端口的 307,断言另一个端口上收不到密钥头。
小结
- 跟随重定向时,scheme、host 字符串、端口任何一个变了,reqwest 就去掉
Authorization、Cookie、Cookie2、Proxy-Authorization、WWW-Authenticate。 - 下一跳因此可能回
401,看起来像 token 失效;127.0.0.1换localhost也算跨域。 - 名单之外的头一律照发,
x-api-key这类自定义密钥头会跟到任何地方;set_sensitive不起作用,名单也不能配置。 - API 客户端用
Policy::custom在跨域时stop,或者直接Policy::none()。