Rust in Everything
基于 tower-http 0.7.1

CORS 的内部

主线位置:作用点主线的前两个时刻。call 时把能算的头先算好,OPTIONS 请求直接短路成预检响应;响应 future 完成时把算好的头合进内层的响应。预检请求怎么被短路、不允许的来源为什么只是少一个头、Vary 为什么从三个头变成按配置计算,axum 卷 其余常用中间件 已经实测过,这里讲配置在类型上怎么组织。

flowchart TB
  cfg["CorsLayer:七个配置字段"] -->|"layer():检查配置,算 Vary"| svc["Cors<S>"]
  svc -->|"poll_ready:再检查一次"| ready[转发内层]
  svc -->|"call:同步算头"| hs["HeaderMap:Credentials、PrivateNetwork、Vary"]
  svc -->|"call:AllowOrigin"| fut["AllowOriginFuture:Ok 或 Future"]
  hs --> opt{"OPTIONS?"}
  fut --> opt
  opt -->|是| pre["PreflightCall:等来源结果,B::default()"]
  opt -->|否| cc["CorsCall:先等来源结果,再等内层,合并头"]

七个字段,七个小枚举

CorsLayer 没有一个字段是裸的 HeaderValue:

pub struct CorsLayer {
    allow_credentials: AllowCredentials,
    allow_headers: AllowHeaders,
    allow_methods: AllowMethods,
    allow_origin: AllowOrigin,
    allow_private_network: AllowPrivateNetwork,
    expose_headers: ExposeHeaders,
    max_age: MaxAge,
    vary: Vary,
    is_vary_custom: bool,
}

每个类型都在各自的文件里,形状一样:一个公开的新类型包一个私有枚举,From<Any>、From<[T; N]>、From<Vec<T>>、From<bool> 之类的转换让 .allow_origin(Any)、.allow_methods([Method::GET]) 能直接写;一个 pub(super) fn to_header(...) -> Option<(HeaderName, HeaderValue)> 交给 Cors::call,返回 None 就不写这个头。枚举的变体就是「这个头的值从哪来」:

字段变体随请求变化的
AllowOriginConst、List、Predicate、AsyncPredicate后三种
AllowHeaders、AllowMethodsConst(Option<HeaderValue>)、MirrorRequestMirrorRequest
AllowCredentials、AllowPrivateNetworkYes、No、PredicatePredicate
MaxAgeExact、FnFn
ExposeHeadersConst无

Any 不是一个独立变体,它就是值为 * 的 Const。allow_headers([A, B]) 在构造时就用 separated_by_commas 拼成一个 "a,b" 的 HeaderValue,也是 Const。所以「通配」和「列表」对 AllowHeaders / AllowMethods 来说是同一种东西:每次请求都回同一个值,由浏览器去比对。

mirror_request 有两种含义。AllowHeaders::mirror_request() / AllowMethods::mirror_request() 是专门的变体,把请求里的 Access-Control-Request-Headers / -Method 原样抄回去;AllowOrigin::mirror_request() 没有专门的变体,就是 Self::predicate(|_, _| true)。

AllowOrigin 的四种来源

AllowOrigin::to_future 把四种来源统一成一个 future:

        match &self.0 {
            OriginInner::Const(v) => AllowOriginFuture::ok(Some((name, v.clone()))),
            OriginInner::List(l) => {
                AllowOriginFuture::ok(origin.filter(|o| l.contains(o)).map(|o| (name, o.clone())))
            }
            OriginInner::Predicate(c) => AllowOriginFuture::ok(
                origin
                    .filter(|origin| c(origin, parts))
                    .map(|o| (name, o.clone())),
            ),
            OriginInner::AsyncPredicate(f) => {
                if let Some(origin) = origin.cloned() {
                    let fut = f(origin.clone(), parts);
                    AllowOriginFuture::fut(async move { fut.await.then_some((name, origin)) })
                } else {
                    AllowOriginFuture::ok(None)
                }
            }
        }
  • Const:不看请求的 Origin,对谁都回这个值。exact(a) 和 any() 都落在这里,区别只是值是不是 *。
  • List:Vec<HeaderValue> 线性查找,按字节相等;匹配上就把请求的 Origin 抄回去。
  • Predicate:Arc<dyn Fn(&HeaderValue, &RequestParts) -> bool>,能看到整个请求头部,可以按路径决定。
  • AsyncPredicate:谓词返回一个装箱的 future,比如去数据库查租户的来源白名单。

只有异步谓词需要真的等,前三种都是 AllowOriginFuture::Ok,第一次 poll 就 Ready。为了让四种来源走同一条路,Cors 的 ResponseFuture::poll 在非预检分支里先等来源结果、再 poll 内层:

            KindProj::CorsCall { allow_origin_future, allow_origin_complete, future, headers } => {
                if !*allow_origin_complete {
                    headers.extend(ready!(allow_origin_future.poll(cx)));
                    *allow_origin_complete = true;
                }

                let mut response: Response<B> = ready!(future.poll(cx))?;
                // ...
                if let Some(vary) = headers.remove(header::VARY) {
                    response_headers.append(header::VARY, vary);
                }
                // extend will overwrite previous headers of remaining names
                response_headers.extend(headers.drain());

两个后果:

  • 内层的 call 在 Cors::call 里已经同步调过了,但它返回的 future 要等异步谓词完成才第一次被 poll。axum 的 handler 是 async fn,所以 handler 的第一行代码一定晚于谓词。谓词说「不允许」也不会拦住内层,只是少一个头。
  • 合并头时 Vary 用 append,不覆盖 handler 自己写的 Vary(比如压缩加的 accept-encoding);其余 CORS 头用 extend,handler 自己写的同名头会被覆盖。

List 的构造函数 AllowOrigin::list 遇到 * 当场 panic:列表的语义是「匹配就抄回请求的 Origin」,* 永远不会和一个真实的 Origin 字节相等,放进去等于什么都不允许,所以干脆不让放。

非法配置:两处检查,一个缺口

ensure_usable_cors_rules 只查一件事:allow_credentials 为 Yes 时,Allow-Headers、Allow-Methods、Allow-Origin、Expose-Headers 都不能是 *。它被调用两次:

第二处看起来多余,原因在 Cors<S> 自己也有一套建造方法:Cors::new(svc).allow_credentials(true).allow_origin(Any) 不经过 Layer::layer,每个方法只走 map_layer(改字段、重算 Vary),不检查。能拦住这条路的只有 poll_ready。

检查用的是 AllowCredentials::is_true,只认 Yes。AllowCredentials::predicate(...) 不算 true,于是「谓词形式的凭据 + Any 来源」能通过检查,运行时同时写出 Access-Control-Allow-Credentials: true 和 Access-Control-Allow-Origin: *,浏览器会拒绝这个组合。

下面把四种来源、异步谓词的顺序、三种 panic 和这个缺口跑一遍:

tower-http = { version = "=0.7.1", features = ["cors"] }
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::{header, HeaderValue, Request, Response};
use http_body_util::Full;
use std::{convert::Infallible, panic};
use tower::{Layer, ServiceExt};
use tower_http::cors::{AllowCredentials, AllowOrigin, Any, Cors, CorsLayer};

async fn handler(_: Request<Full<Bytes>>) -> Result<Response<Full<Bytes>>, Infallible> {
  println!("    handler runs");
  Ok(Response::new(Full::from("hi")))
}

fn get(origin: &str) -> Request<Full<Bytes>> {
  Request::get("/").header(header::ORIGIN, origin).body(Full::default()).unwrap()
}

fn show(res: &Response<Full<Bytes>>) {
  for name in ["access-control-allow-origin", "access-control-allow-credentials", "vary"] {
    if let Some(v) = res.headers().get(name) {
      println!("    {name}: {}", v.to_str().unwrap());
    }
  }
}

async fn send(name: &str, layer: CorsLayer, origin: &str) {
  println!("{name}, Origin: {origin}");
  let res = layer.layer(tower::service_fn(handler)).oneshot(get(origin)).await.unwrap();
  show(&res);
}

fn panic_msg(f: impl FnOnce() + panic::UnwindSafe) -> String {
  match panic::catch_unwind(f) {
    Ok(()) => "no panic".into(),
    Err(e) => e.downcast_ref::<String>().cloned().or(e.downcast_ref::<&str>().map(|s| s.to_string())).unwrap(),
  }
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  let a = HeaderValue::from_static("https://a.example");
  send("exact", CorsLayer::new().allow_origin(a.clone()), "https://evil.example").await;
  send("list", CorsLayer::new().allow_origin([a.clone()]), "https://a.example").await;
  send("list", CorsLayer::new().allow_origin([a.clone()]), "https://evil.example").await;
  send("predicate", CorsLayer::new().allow_origin(AllowOrigin::predicate(|o, _| o.as_bytes().ends_with(b".a.example"))), "https://api.a.example").await;
  send(
    "async_predicate",
    CorsLayer::new().allow_origin(AllowOrigin::async_predicate(|o: HeaderValue, _| async move {
      println!("    async predicate checks {:?}", o);
      false
    })),
    "https://b.example",
  )
  .await;
  send("credentials predicate + Any", CorsLayer::new().allow_credentials(AllowCredentials::predicate(|_, _| true)).allow_origin(Any), "https://b.example").await;

  panic::set_hook(Box::new(|_| {}));
  println!("list with *: {}", panic_msg(|| { let _ = AllowOrigin::list([HeaderValue::from_static("*")]); }));
  println!("layer(): {}", panic_msg(|| { let _ = CorsLayer::new().allow_credentials(true).allow_origin(Any).layer(tower::service_fn(handler)); }));
  let built = panic_msg(|| { let _ = Cors::new(tower::service_fn(handler)).allow_credentials(true).allow_origin(Any); });
  println!("Cors::new(..).allow_credentials(true).allow_origin(Any): {built}");
  let svc = Cors::new(tower::service_fn(handler)).allow_credentials(true).allow_origin(Any);
  let r = tokio::spawn(async move { svc.oneshot(get("https://b.example")).await }).await;
  println!("first poll_ready: {}", match r { Err(e) if e.is_panic() => { let p = e.into_panic(); p.downcast_ref::<String>().cloned().or(p.downcast_ref::<&str>().map(|s| s.to_string())).unwrap() } _ => "no panic".into() });
}

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

exact, Origin: https://evil.example
    handler runs
    access-control-allow-origin: https://a.example
list, Origin: https://a.example
    handler runs
    access-control-allow-origin: https://a.example
    vary: origin
list, Origin: https://evil.example
    handler runs
    vary: origin
predicate, Origin: https://api.a.example
    handler runs
    access-control-allow-origin: https://api.a.example
    vary: origin
async_predicate, Origin: https://b.example
    async predicate checks "https://b.example"
    handler runs
    vary: origin
credentials predicate + Any, Origin: https://b.example
    handler runs
    access-control-allow-origin: *
    access-control-allow-credentials: true
list with *: Wildcard origin (`*`) cannot be passed to `AllowOrigin::list`. Use `AllowOrigin::any()` instead
layer(): Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` with `Access-Control-Allow-Origin: *`
Cors::new(..).allow_credentials(true).allow_origin(Any): no panic
first poll_ready: Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` with `Access-Control-Allow-Origin: *`
  • exact 对 evil.example 也回 https://a.example:Const 不看请求。浏览器比对后会拒绝,所以这依然安全,只是没有 Vary。
  • 四种来源里只有 Const 不带 Vary: origin,这就是 varies_with_origin 的判断:!matches!(&self.0, OriginInner::Const(_))。
  • 异步谓词先于 handler 运行,返回 false 后 handler 照样执行。
  • 直接用 Cors::new(..) 拼出非法配置时,构造不 panic,panic 推迟到第一次 poll_ready(这里在被 spawn 的任务里,用 JoinError::into_panic 取回信息)。
  • 谓词形式的凭据绕过了检查,写出了浏览器不接受的组合。

tag 之后:validate()

0.7.1 里非法配置只能 panic。配置来自配置文件、想报错而不是崩溃的程序,只能自己先把规则检查一遍。tag 之后合入的 #747(「cors: Add validate() and CorsConfigurationError」,写作时未发版)加了 CorsLayer::validate() 和 Cors::validate(),返回 Result<(), CorsConfigurationError>,枚举四个变体对应四个 assert!,Display 文本和 panic 信息一样。它查的仍然是 is_true(),所以上面谓词凭据的缺口在 validate() 里也还在。layer()、poll_ready 的 panic 没有去掉,validate() 是在它们之前多给一次机会。

📝NOTE
没有蠢问题

问: 用 async_predicate 去数据库查白名单,每个请求都会查一次吗?

答: 会。Cors::call 每次都调 to_future,AsyncPredicate 每次都调一次谓词,tower-http 不缓存结果,也不区分预检和后续的实际请求。没有 Origin 的请求(浏览器同源的 GET、非浏览器客户端)不会调谓词,直接得到 None。要缓存就在谓词里自己做,比如把白名单放进 Arc<RwLock<..>> 定期刷新,谓词只读内存。

小结

  • CorsLayer 的七个字段各是一个「新类型 + 私有枚举」,枚举变体表示头的值从哪来;Any 和列表都是 Const,每次回同一个值。
  • AllowOrigin 四种来源统一成 AllowOriginFuture,只有异步谓词真的要等;非预检请求先等来源结果再 poll 内层,内层的 future 因此晚于谓词开始,但谓词拒绝不会拦住它。
  • 只有 Const 以外的来源和 mirror_request 的方法/头会进 Vary。合并时 Vary 追加,其余 CORS 头覆盖 handler 写的同名头。
  • 非法组合在 layer() 和每次 poll_ready 检查;poll_ready 那次是为了 Cors::new(..) 直接拼配置的路径。AllowOrigin::list 含 * 当场 panic。
  • 检查只认 allow_credentials(true),谓词形式的凭据能和 * 一起写出去;tag 之后的 validate() 让非法配置可以不 panic,但同样没有覆盖这个缺口。

💬 关联讨论

发起讨论
加载中...