Rust in Everything
基于 reqwest 0.13.5

builder error

主线位置:请求主线的第一步之前。RequestBuilder 的每个方法出错时不返回 Result,而是把错误存进自己,send() 才交出来;这套「错误先存着」的机制和七种出错方式见 请求构建。本页讲它最常见的来路:一个带换行的密钥。

flowchart TB
  file["token.txt 末尾换行"] --> bearer["bearer_auth"]
  file --> basic["basic_auth"]
  bearer --> hv["HeaderValue 校验"]
  hv -->|失败| stash["Err 存进 builder"]
  stash --> send["send"]
  send --> err["builder error"]
  basic --> b64["base64 编码"]
  b64 --> wire["照常发出"]
  wire --> srv["服务端认证失败"]

实验 crate:exp-reqwest/pitb-lab(src/bin/token.rs)。M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,release 构建,客户端 .no_proxy(),服务器是 testsrv 的 /echo。

现象

密钥放在文件里,echo sk-test-123 > token.txt 写进去,std::fs::read_to_string 读出来是 "sk-test-123\n"。拿它发请求:

  let token = String::from("sk-test-123\n");

  let e = c.get(srv.url("/echo")).bearer_auth(&token).send().await.unwrap_err();
  println!("bearer_auth: {e}");
  println!("  {e:?}");
  println!("  is_builder={} url={:?}", e.is_builder(), e.url());
  println!("  chain: {}", pitb_lab::chain(&e));

  let e = c.get(srv.url("/echo")).header("x-api-key", &token).send().await.unwrap_err();
  println!("x-api-key: {e}");

  let r = c.get(srv.url("/echo")).basic_auth("app", Some(&token)).send().await.unwrap();
  // ...
bearer_auth: builder error
  reqwest::Error { kind: Builder, source: http::Error(InvalidHeaderValue) }
  is_builder=true url=None
  chain: builder error <- failed to parse header value
x-api-key: builder error
basic_auth: sent, server saw `authorization: Basic YXBwOnNrLXRlc3QtMTIzCg==`
server accepted 1 connection(s), 1 request(s)
  • 日志里只有 builder error。source 再往下一层是 failed to parse header value,到底了。没有头名,没有 URL,也看不出是换行。一个请求设了十几个头时,只能一个个试。
  • 错误出在 send().await,离写错的那一行很远:bearer_auth 那一行没有任何返回值可以检查。
  • 前两个请求没有发出去,服务器收到的唯一一个请求来自 basic_auth。
  • basic_auth 不报错。YXBwOnNrLXRlc3QtMTIzCg== 解出来是 app:sk-test-123\n,换行被编进了密码。真正校验密码的服务器会回认证失败,客户端这一侧什么也看不出来。

原因

bearer_auth 把 token 拼成字符串,交给 header_sensitive:

    pub fn bearer_auth<T>(self, token: T) -> RequestBuilder
    where
        T: fmt::Display,
    {
        let header_value = format!("Bearer {token}");
        self.header_sensitive(crate::header::AUTHORIZATION, header_value, true)
    }

header_sensitive 里 HeaderValue::try_from 拒绝控制字符,\n 不合法,crate::error::builder(e.into()) 被存进 self.request,替换掉原来的 Ok(Request)。HTTP/1.1 里换行是头的分隔符,放进头值就能伪造出下一个头,所以 http crate 在构造 HeaderValue 时就挡掉它。header() 走的是同一个函数。

错误的 Display 对 Kind::Builder 只写 builder error;source 是 http::Error,它的 Display 是 failed to parse header value,同样不知道是哪个头。头名在 header_sensitive 里明明拿得到,但没有放进错误。send 看到 Err 就返回一个立刻失败的 Pending,不碰网络。

basic_auth 先调 util::basic_auth,把 "{username}:{password}" 整个写进 base64 编码器,再 HeaderValue::from_maybe_shared(...).expect("base64 is always valid HeaderValue")。base64 的输出只有字母、数字、+、/、=,永远是合法头值,所以换行不会被发现,只会被编码。

修法

读进来就 trim(),并且在启动时用头名校验一次。 配置加载的地方是唯一知道「这个值从哪来、要放进哪个头」的地方:

  let check = |t: &str| {
    HeaderValue::from_str(&format!("Bearer {t}")).map_err(|e| format!("Authorization (from token.txt): {e}"))
  };
  println!("startup check, raw: {:?}", check(&token).map(|_| ()));
  let token = token.trim();
  println!("startup check, trimmed: {:?}", check(token).map(|_| ()));
  let r = c.get(srv.url("/echo")).bearer_auth(token).send().await.unwrap();
  println!("trimmed: {}", r.status());
startup check, raw: Err("Authorization (from token.txt): failed to parse header value")
startup check, trimmed: Ok(())
trimmed: 200 OK

trim() 同时去掉 \r\n(Windows 上写的文件)和首尾空格。对 basic_auth 的用户名和密码也要 trim(),因为它不会报错。

发送之前就要拿到错误,用 build()。 build 返回 crate::Result<Request>,再用 Client::execute 发出去:

build(): is_err=true

错误内容和 send() 时一样,好处只是位置:可以紧挨着设头的代码处理,或者在请求模板建好时就失败。

怎么检查

  • e.is_builder() 为真:请求根本没发出去,不用去服务器日志里找。
  • 看 {e:?}:InvalidHeaderValue 是值的问题(换行、回车、其他控制字符),InvalidHeaderName 是名字的问题(比如名字里有空格)。
  • 非法头的错误 e.url() 是 None。URL 解析失败、.query() 传了元组、json() 序列化失败也都是 builder error,Debug 里的 source 能区分它们(请求构建 一页列了七种)。
  • 认证失败但客户端没报错时,看看用的是不是 basic_auth:把服务端收到的 Authorization 头 base64 解码,末尾多出的 \n 一眼就能看到。

小结

  • bearer_auth / header 拿到带换行的值,不报错,把错误存进 builder,send() 时返回 builder error;Display 和 source 里都没有头名和 URL。
  • basic_auth 不会因为换行失败:base64 后总是合法头值,错误的密码被照常发出。
  • 修法:读入时 trim(),启动时带着头名用 HeaderValue::from_str 校验;需要就近处理时用 build()。
  • is_builder() 为真说明请求没有发出;Debug 里的 InvalidHeaderValue / InvalidHeaderName 区分值和名。

💬 关联讨论

发起讨论
加载中...