Rust in Everything
基于 reqwest 0.13.5

重定向以后 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()。

💬 关联讨论

发起讨论
加载中...