反向代理后面 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,第五步的豁免检查放行。
出处
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,或者登记受信来源。
💬 关联讨论
发起讨论加载中...