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 就不写这个头。枚举的变体就是「这个头的值从哪来」:
| 字段 | 变体 | 随请求变化的 |
|---|---|---|
AllowOrigin | Const、List、Predicate、AsyncPredicate | 后三种 |
AllowHeaders、AllowMethods | Const(Option<HeaderValue>)、MirrorRequest | MirrorRequest |
AllowCredentials、AllowPrivateNetwork | Yes、No、Predicate | Predicate |
MaxAge | Exact、Fn | Fn |
ExposeHeaders | Const | 无 |
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 都不能是 *。它被调用两次:
Layer::layer:.layer(CorsLayer::...)装配时。Cors::poll_ready:每一次。
第二处看起来多余,原因在 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() 是在它们之前多给一次机会。
问: 用 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,但同样没有覆盖这个缺口。