Rust in Everything
基于 tower-http 0.7.1

反向代理后面 CsrfLayer 回 403

主线位置:作用点主线的第一个时刻,短路。Csrf::call 判定跨源就不调内层,直接回 403。五步判定顺序在 CSRF 里讲过,那一页用 oneshot 改写 Host 模拟过代理;这一页起一个真的代理,看浏览器、代理、后端各自看到什么。

sequenceDiagram
  participant B as 浏览器(app 的页面)
  participant P as 反向代理
  participant A as 后端 CsrfLayer
  B->>P: POST /form,Host: app,Origin: http://app
  P->>A: Host: backend,X-Forwarded-Host: app,Origin: http://app
  A->>A: 没有 Sec-Fetch-Site,比较 Origin 的 authority 与 Host
  A-->>P: 403 CrossOriginRequestFromOldBrowser
  P-->>B: 403

复现

后端是挂了 CsrfLayer::new() 的 axum 服务;代理是另一个 axum 服务,用 hyper-util 的客户端转发,转发前把 Host 去掉(客户端会按目标地址重新填)、原值放进 X-Forwarded-Host。客户端的请求模拟浏览器在代理地址上的页面里提交表单:Origin 是代理的地址。

tower-http = { version = "=0.7.1", features = ["csrf"] }
axum = { version = "=0.8.9", default-features = false, features = ["tokio", "http1"] }
hyper-util = { version = "=0.1.21", features = ["client-legacy", "http1", "tokio"] }
http-body-util = "0.1"
tokio = { version = "=1.54.1", features = ["macros", "rt", "net"] }
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{header, uri::Uri, HeaderValue};
use axum::response::Response;
use axum::routing::{any, post};
use axum::Router;
use http_body_util::BodyExt;
use hyper_util::{client::legacy::Client, rt::TokioExecutor};
use std::net::SocketAddr;
use tower_http::csrf::CsrfLayer;

type HttpClient = Client<hyper_util::client::legacy::connect::HttpConnector, Body>;

async fn spawn(app: Router) -> SocketAddr {
  let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
  let addr = listener.local_addr().unwrap();
  tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
  addr
}

// 一个反向代理,行为同 nginx 的默认 `proxy_set_header Host $proxy_host`:
// 后端在 Host 里看到的是它自己的地址,原来的 Host 只放进 X-Forwarded-Host
async fn proxy(State((client, backend)): State<(HttpClient, SocketAddr)>, mut req: Request) -> Response {
  let host = req.headers_mut().remove(header::HOST).unwrap();
  req.headers_mut().insert("x-forwarded-host", host);
  *req.uri_mut() = Uri::try_from(format!("http://{backend}{}", req.uri().path())).unwrap();
  client.request(req).await.unwrap().map(Body::new)
}

async fn send(client: &HttpClient, name: &str, to: SocketAddr, headers: &[(&str, String)]) {
  let mut req = Request::post(format!("http://{to}/form"));
  for (k, v) in headers {
    req = req.header(*k, HeaderValue::from_str(v).unwrap());
  }
  let res = client.request(req.body(Body::empty()).unwrap()).await.unwrap();
  let status = res.status();
  let body = res.into_body().collect().await.unwrap().to_bytes();
  println!("{name:<44} {status} {body:?}");
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  let client: HttpClient = Client::builder(TokioExecutor::new()).build_http();
  let form = || Router::new().route("/form", post(|| async { "saved" }));
  let backend = spawn(form().layer(CsrfLayer::new())).await;
  let front = spawn(Router::new().route("/{*rest}", any(proxy)).with_state((client.clone(), backend))).await;

  // 浏览器打开 http://<front> 时:Origin 和 Host 都是前端地址
  let origin = format!("http://{front}");
  send(&client, "direct to backend, Origin = backend", backend, &[("origin", format!("http://{backend}"))]).await;
  send(&client, "via proxy, Origin only (old browser)", front, &[("origin", origin.clone())]).await;
  send(&client, "via proxy, Sec-Fetch-Site: same-origin", front, &[("origin", origin.clone()), ("sec-fetch-site", "same-origin".into())]).await;

  // 信任对外的来源:先绑定第二个代理的端口,才知道它的地址
  let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
  let front2 = listener.local_addr().unwrap();
  let origin2 = format!("http://{front2}");
  let trusted = spawn(form().layer(CsrfLayer::new().add_trusted_origin(&origin2).unwrap())).await;
  let app = Router::new().route("/{*rest}", any(proxy)).with_state((client.clone(), trusted));
  tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
  send(&client, "via proxy, add_trusted_origin(front)", front2, &[("origin", origin2)]).await;
}

M2 Max、rustc 1.99.0,跑 3 次输出相同:

direct to backend, Origin = backend          200 OK b"saved"
via proxy, Origin only (old browser)         403 Forbidden b""
via proxy, Sec-Fetch-Site: same-origin       200 OK b"saved"
via proxy, add_trusted_origin(front)         200 OK b"saved"
  • 直连:Origin 和 Host 都是后端地址,第四步按字节相等,放行。
  • 经过代理、只带 Origin:浏览器眼里这是同源提交(页面和表单都在代理地址上),后端却看到 Host 是自己的内部地址,Origin 的 authority 对不上,403,正文为空。代理放进去的 X-Forwarded-Host 没有任何作用。
  • 经过代理、带 Sec-Fetch-Site: same-origin:第二步就放行了,走不到 Host 比较。所以这个坑只出现在不发 Sec-Fetch-Site 的客户端上:不支持 Fetch Metadata 的老浏览器,以及自己带上 Origin 的脚本和非浏览器客户端。用新版 Chrome、Firefox、Safari 测试时一切正常,问题会在线上的少数用户那里冒出来。
  • 登记受信来源:把对外的来源(这里是代理地址)加进 add_trusted_origin,第五步的豁免检查放行。

出处

csrf/service.rs 第 110 行起:

        let host = req.headers().get("host").map(|h| h.as_bytes());

        // Mirrors the reference's `url.Parse(origin).Host == req.Host`. Per RFC 7230
        // §5.3, req.Host is the request-target authority (absolute-form URI / HTTP/2
        // `:authority`) if present, else the Host header. Byte-exact and scheme-blind,
        // so an http→https mismatch can't be caught here — we fail open (HSTS helps).
        let effective_host = req
            .uri()
            .authority()
            .map(|a| a.as_str().as_bytes())
            .or(host);

比较的对象只有两个来源:请求行或 HTTP/2 :authority 里的 authority,否则是 Host 头。X-Forwarded-Host、Forwarded 都不看,注释说明这里对照的是 Go 参考实现的 url.Parse(origin).Host == req.Host。对不上、又不在豁免里,就是 第 134 行 的 CrossOriginRequestFromOldBrowser;这个 ProtectionError 放在 403 响应的 extensions 里,外层中间件能读到,客户端看不到。

修法

  • 让代理原样转发 Host。nginx 写 proxy_set_header Host $host;,其他代理按各自的配置保留原始 Host。这是模块文档「Deployment caveat」给的办法,也最省事。
  • 登记对外的来源:CsrfLayer::new().add_trusted_origin("https://app.example")?。注意来源按原始字节匹配,大小写要和浏览器发的一致(小写),见 CSRF。
  • 排查时把 403 响应 extensions 里的 ProtectionError 打进日志,CrossOriginRequestFromOldBrowser 就是这一种;CrossOriginRequest 则是 Sec-Fetch-Site 判定的,和代理无关。

小结

  • 没有 Sec-Fetch-Site 的请求,CsrfLayer 按字节比较 Origin 的 authority 和请求的 authority(URI 里的,否则 Host)。
  • 代理改写 Host 后,同源提交被判成跨源;X-Forwarded-Host 不参与比较。
  • 现代浏览器带 Sec-Fetch-Site,不受影响,所以只在少数客户端上出现;让代理保留 Host,或者登记受信来源。

💬 关联讨论

发起讨论
加载中...