Rust in Everything
基于 tower-http 0.7.1

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 仍然在。
📝NOTE
没有蠢问题

问: 用了 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。受信来源按原始字节匹配,登记时写成大写就永远匹配不上。

💬 关联讨论

发起讨论
加载中...