CSRF:移植 Go 1.25 的跨源保护
主线位置:作用点主线的第一个时刻,纯粹的短路模块。
call时只看方法和三个请求头,判定失败就不调内层,把 403 放进ResponseFuture的Rejected变体(这个枚举在 四件套 里拿来当过例子)。它不读 body,不改响应,响应 future 和 body 都是透传。
flowchart TB
m{"方法是 GET、HEAD、OPTIONS"} -->|是| ok[放行]
m -->|否| s{"Sec-Fetch-Site"}
s -->|"same-origin 或 none"| ok
s -->|"其他值"| ex1{"旁路或受信来源"}
ex1 -->|是| ok
ex1 -->|否| r1["403 CrossOriginRequest"]
s -->|"没有或为空"| o{"Origin"}
o -->|"没有或为空"| ok
o -->|有| h{"Origin 的 authority 等于请求的 authority"}
h -->|是| ok
h -->|否| ex2{"旁路或受信来源"}
ex2 -->|是| ok
ex2 -->|否| r2["403 CrossOriginRequestFromOldBrowser"]
CsrfLayer 是 0.7.0 的 #699,CHANGELOG 的原话是「porting the cross-origin protection scheme introduced in Go 1.25」。思路是:现代浏览器在每个请求上都带 Sec-Fetch-Site,告诉服务器这个请求是谁发起的,服务器据此拒绝跨站的写请求,不再需要往表单里塞 token。axum 卷 其余常用中间件 的最后一节只用一句话提了它,这一页把判定过程走完。
判定顺序
全部逻辑在 Csrf::verify,一个函数,五步。
第一步,安全方法放行。
// Deliberately not Method::is_safe: it also treats TRACE as safe, but the
// reference implementation only exempts GET/HEAD/OPTIONS, so we match it here.
if matches!(
req.method(),
&Method::GET | &Method::HEAD | &Method::OPTIONS
) {
return Ok(());
}
http::Method::is_safe 按 RFC 把 TRACE 也算安全,Go 的参考实现只放过这三个,这里照 Go 写,并在 service.rs 的测试里用一个断言把「is_safe 覆盖得更多」钉住,防止以后有人「简化」成 is_safe()。
第二步,看 Sec-Fetch-Site。
// Fetch spec mandates lowercase here; exact byte match is intentional.
match sec_fetch_site {
Some(b"same-origin" | b"none") => return Ok(()),
None | Some(b"") => {} // fall through to Origin check
Some(_) if is_exempt() => return Ok(()),
Some(_) => {
return Err(ProtectionError::new(
ProtectionErrorKind::CrossOriginRequest,
))
}
}
none 是用户直接在地址栏输入、点书签这类没有发起方的请求。same-site 不放行:同一个注册域下的另一个子域(evil.app.example 对 app.example)也被当成跨源。is_exempt 是两个豁免:with_insecure_bypass 给的 (&Method, &Uri) -> bool 谓词,以及 add_trusted_origin 登记的来源,后者按 Origin 头的原始字节查 HashSet<Vec<u8>>。
第三步,两个头都没有就放行。 走到这里说明没有 Sec-Fetch-Site。如果 Origin 也没有,请求要么来自不带这两个头的老浏览器的同源请求,要么根本不是浏览器(curl、服务间调用),都放行。CSRF 的前提是「浏览器替攻击者带上了用户的 cookie」,非浏览器客户端不在防护范围内。
第四步,Origin 和请求的 authority 比较。
// 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);
第 116 行。请求的 authority 先取 URI 里的(HTTP/2 的 :authority、HTTP/1.1 的绝对形式请求行),没有才用 Host 头。Origin 先被解析成 Uri,scheme 不是 http / https 的(包括 Origin: null)解析结果直接作废,这一步必然不相等。比较的只是 authority 的字节,scheme 不参与,注释自己承认这是 fail open:Origin: http://app.example 对一个 https://app.example 的站点会被放行,而按同源策略它们是两个源。注释说「HSTS helps」,即站点开了 HSTS 后浏览器不会再以 http 访问它,这个缺口才不可利用。
第五步,再给豁免一次机会,仍不行就是 CrossOriginRequestFromOldBrowser。
ProtectionError 与拒绝响应
被拒的请求在 Csrf::call 里变成响应:
Err(err) => {
let mut response = self
.rejection_response
.response_for_protection_error(err.clone());
response.extensions_mut().insert(err);
ResponseFuture::rejected(Ok(response))
}
ProtectionError是一个不透明的结构体,只有kind();ProtectionErrorKind是non_exhaustive的两个变体,分别对应第二步和第五步。文档的说法是:以后要给错误加上下文,不必再破坏兼容。- 错误总是被插进响应的 extensions,不管拒绝响应是谁造的。外层的
Trace或日志中间件可以据此区分「明确的跨站」和「老浏览器的保守拒绝」。 - 拒绝响应由
ResponseForProtectionError<B>造,任何FnMut(ProtectionError) -> Response<B> + Clone都实现了它。默认的DefaultResponseForProtectionError只对B: Default实现,造一个空 body 的 403。换成自定义响应以后,Csrf就不再要求内层 body 实现Default。
受信来源在登记时由 parse_origin 校验形式:不能有路径(连结尾的 / 都不行)、查询、片段,scheme 只能是 http / https,主机名必须是 ASCII(IDN 要写 punycode)。校验通过后存的是原样的字符串,匹配时按字节比。校验接受 HTTPS://Example.COM 这样的写法,而浏览器发出的 Origin 总是小写,这样登记的来源永远匹配不上。
跑一遍
tower-http = { version = "=0.7.1", features = ["csrf"] }
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
http = "1"
http-body-util = "0.1"
bytes = "1"
use bytes::Bytes;
use http::{Method, Request, Response, StatusCode};
use http_body_util::{BodyExt, Full};
use std::convert::Infallible;
use tower::{Layer, ServiceExt};
use tower_http::csrf::{CsrfLayer, ProtectionError};
async fn handler(_: Request<Full<Bytes>>) -> Result<Response<Full<Bytes>>, Infallible> {
Ok(Response::new(Full::from("handled")))
}
async fn send<L>(layer: &L, name: &str, method: Method, uri: &str, headers: &[(&str, &str)])
where
L: Layer<tower::util::ServiceFn<fn(Request<Full<Bytes>>) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Response<Full<Bytes>>, Infallible>> + Send>>>>,
L::Service: tower::Service<Request<Full<Bytes>>, Response = Response<Full<Bytes>>, Error = Infallible>,
{
let mut req = Request::builder().method(method).uri(uri);
for (k, v) in headers {
req = req.header(*k, *v);
}
let f: fn(Request<Full<Bytes>>) -> _ = |r| Box::pin(handler(r)) as _;
let res = layer.layer(tower::service_fn(f)).oneshot(req.body(Full::default()).unwrap()).await.unwrap();
let kind = res.extensions().get::<ProtectionError>().map(|e| format!("{:?}", e.kind())).unwrap_or("-".into());
let status = res.status();
let body = res.into_body().collect().await.unwrap().to_bytes();
println!("{name:<44} {} {kind} {body:?}", status.as_u16());
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let layer = CsrfLayer::new().add_trusted_origin("https://partner.example").unwrap();
let p = Method::POST;
send(&layer, "TRACE, Sec-Fetch-Site: cross-site", Method::TRACE, "/", &[("sec-fetch-site", "cross-site")]).await;
send(&layer, "POST, Sec-Fetch-Site: same-origin", p.clone(), "/", &[("sec-fetch-site", "same-origin")]).await;
send(&layer, "POST, Sec-Fetch-Site: same-site", p.clone(), "/", &[("sec-fetch-site", "same-site")]).await;
send(&layer, "POST, cross-site, trusted Origin", p.clone(), "/", &[("sec-fetch-site", "cross-site"), ("origin", "https://partner.example")]).await;
send(&layer, "POST, no Sec-Fetch-Site, no Origin", p.clone(), "/", &[("host", "app.example")]).await;
send(&layer, "POST, Origin https://app.example = Host", p.clone(), "/", &[("origin", "https://app.example"), ("host", "app.example")]).await;
send(&layer, "POST, Origin http://app.example = Host", p.clone(), "/", &[("origin", "http://app.example"), ("host", "app.example")]).await;
send(&layer, "POST, Origin https://evil.example", p.clone(), "/", &[("origin", "https://evil.example"), ("host", "app.example")]).await;
send(&layer, "POST, Origin: null", p.clone(), "/", &[("origin", "null"), ("host", "app.example")]).await;
send(&layer, "proxy: Host rewritten to backend:8080", p.clone(), "/", &[("origin", "https://app.example"), ("host", "backend:8080")]).await;
send(&layer, "proxy, browser sends Sec-Fetch-Site", p.clone(), "/", &[("sec-fetch-site", "same-origin"), ("origin", "https://app.example"), ("host", "backend:8080")]).await;
send(&layer, "absolute URI authority wins over Host", p.clone(), "http://app.example/", &[("origin", "https://app.example"), ("host", "backend:8080")]).await;
let upper = CsrfLayer::new().add_trusted_origin("HTTPS://Partner.Example").unwrap();
send(&upper, "trusted HTTPS://Partner.Example", p.clone(), "/", &[("sec-fetch-site", "cross-site"), ("origin", "https://partner.example")]).await;
for bad in ["https://partner.example/", "partner.example", "https://pärtner.example"] {
println!("add_trusted_origin({bad:?}): {}", CsrfLayer::new().add_trusted_origin(bad).unwrap_err());
}
let custom = CsrfLayer::new().with_rejection_response(|e: ProtectionError| {
let mut res = Response::new(Full::from(format!("blocked: {e}")));
*res.status_mut() = StatusCode::BAD_REQUEST;
res
});
send(&custom, "custom rejection", p.clone(), "/", &[("sec-fetch-site", "cross-site")]).await;
send(&custom, "custom rejection, old browser", p.clone(), "/", &[("origin", "https://evil.example"), ("host", "app.example")]).await;
}
输出(M2 Max、rustc 1.99.0,跑 3 次相同):
TRACE, Sec-Fetch-Site: cross-site 403 CrossOriginRequest b""
POST, Sec-Fetch-Site: same-origin 200 - b"handled"
POST, Sec-Fetch-Site: same-site 403 CrossOriginRequest b""
POST, cross-site, trusted Origin 200 - b"handled"
POST, no Sec-Fetch-Site, no Origin 200 - b"handled"
POST, Origin https://app.example = Host 200 - b"handled"
POST, Origin http://app.example = Host 200 - b"handled"
POST, Origin https://evil.example 403 CrossOriginRequestFromOldBrowser b""
POST, Origin: null 403 CrossOriginRequestFromOldBrowser b""
proxy: Host rewritten to backend:8080 403 CrossOriginRequestFromOldBrowser b""
proxy, browser sends Sec-Fetch-Site 200 - b"handled"
absolute URI authority wins over Host 200 - b"handled"
trusted HTTPS://Partner.Example 403 CrossOriginRequest b""
add_trusted_origin("https://partner.example/"): invalid origin "https://partner.example/": path, query, and fragment are not allowed
add_trusted_origin("partner.example"): invalid origin "partner.example": scheme must be http or https
add_trusted_origin("https://pärtner.example"): invalid origin "https://pärtner.example": non-ASCII hostnames must be supplied in punycode (xn--…)
custom rejection 400 CrossOriginRequest b"blocked: Cross-Origin request detected"
custom rejection, old browser 400 CrossOriginRequestFromOldBrowser b"blocked: Cross-Origin request from old browser detected"
TRACE被拒:第一步故意不用is_safe。same-site被拒,same-origin放行。http://app.example对Host: app.example放行:scheme 不参与比较。- 反向代理改了
Host:代理把Host换成内部地址backend:8080后,没有Sec-Fetch-Site的请求就成了CrossOriginRequestFromOldBrowser。同样的代理,请求带着Sec-Fetch-Site: same-origin(现代浏览器都会带)就不受影响,第二步已经放行,根本走不到Host比较。模块文档的「Deployment caveat」一节说的就是这件事,修法是让代理原样转发Host和Origin。 - 请求行是绝对形式时用 URI 里的 authority,
Host被忽略。 - 用大写登记的受信来源匹配不上浏览器发来的小写
Origin。 - 自定义拒绝响应可以改状态码和正文,extensions 里的
ProtectionError仍然在。
问: 用了 CsrfLayer,SameSite=Lax 的 cookie 和 CSRF token 还要吗?
答: 这个中间件管的是「浏览器发起的、带着用户凭据的跨源写请求」。它把 GET 一律放行,所以有副作用的 GET 不受保护,这一点和 token 方案一样,靠「GET 不改状态」的约定。它也把没有 Origin、没有 Sec-Fetch-Site 的请求放行,这一分支的 trace 日志写的理由是「same-origin or not a browser request」。SameSite cookie 是浏览器侧的另一道防线,两者不冲突。要不要再加 token,取决于你是否需要防住连这两个头都不发的客户端。
小结
CsrfLayer(0.7.0,#699)移植 Go 1.25 的跨源保护,判定全部在Csrf::verify,只看方法、Sec-Fetch-Site、Origin、请求的 authority。- 顺序:GET/HEAD/OPTIONS 放行(故意不用
is_safe,TRACE 不放行)→Sec-Fetch-Site为same-origin/none放行,其他值除非豁免否则 403 → 两个头都没有放行 →Origin的 authority 与 URI authority(没有则Host)按字节比 → 再查一次豁免 → 403。 - 比较不看 scheme,
http://的Origin对 https 站点会放行,源码注释称之为 fail open;same-site不放行。 - 反向代理改写
Host只影响不带Sec-Fetch-Site的请求,结果是CrossOriginRequestFromOldBrowser。 ProtectionError总被放进拒绝响应的 extensions;with_rejection_response换掉默认的空 403 后不再要求 body 实现Default。受信来源按原始字节匹配,登记时写成大写就永远匹配不上。