dwara_core/dataplane/cors.rs
1//! Route-scoped CORS (DW-027, feature analysis 4.14).
2//!
3//! Two phases, both driven by one [`Cors`] policy attached to a route:
4//!
5//! - **Preflight short-circuit** ([`is_preflight`] +
6//! [`preflight_response`]): an `OPTIONS` request carrying BOTH `Origin`
7//! and `Access-Control-Request-Method` on a CORS-configured route is
8//! answered 204 by the gateway immediately after route resolution and
9//! the route-scoped limit checks — before authentication,
10//! authorization, rate limiting, and cap admission, and NEVER proxied
11//! upstream. Browsers send preflights without credentials, so gating
12//! them on authn would break every credentialed route; they are also
13//! pure metadata probes, so admitting them to the concurrency cap
14//! would let a hostile page burn slots cheaply. A preflight that
15//! fails validation (origin, method, or requested headers not
16//! allowed) is still short-circuited — 204 with NO CORS headers,
17//! which the browser reads as a failed preflight. A plain `OPTIONS`
18//! without the preflight markers is NOT intercepted and proxies
19//! normally. A preflight whose route `match.methods` list excludes
20//! `OPTIONS` never resolves the route at all (404, the documented
21//! request-path order) — include `OPTIONS` in the method list of CORS
22//! routes.
23//! - **Actual-response decoration** ([`decorate_actual`]): responses on
24//! the route (every action: proxy, redirect, respond) carry
25//! `Access-Control-Allow-Origin` (`*`, or the echoed request origin
26//! under a specific list), `Access-Control-Allow-Credentials: true`
27//! when configured, `Access-Control-Expose-Headers` when configured,
28//! and `Vary: Origin` (merged; see [`crate::dataplane::hardening::merge_vary`]).
29//! A request whose `Origin` is not allowed gets NO CORS headers — the
30//! response passes through unchanged (same-origin and no-cors reads
31//! never consult CORS).
32//!
33//! Origin matching uses the shared origin grammar in
34//! [`crate::config::normalize_origin`]: exact match on the normalized
35//! form (lowercase scheme and host, default port dropped, no userinfo)
36//! or the single config entry `*`. The config side of that comparison
37//! is precompiled into [`crate::config::CompiledCorsOrigins`] at
38//! snapshot-compile time (see [`crate::snapshot::RouteTable::cors_origins`]),
39//! so the request path normalizes only the request's own `Origin`.
40//! Subdomain wildcards (`*.example.com`) are deliberately not offered
41//! in v1: an explicit origin list is auditable, a wildcard is not; add
42//! entries per origin until that tradeoff is worth revisiting.
43
44use bytes::Bytes;
45use http_body_util::Full;
46use hyper::header::{
47 HeaderMap, HeaderValue, ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_HEADERS,
48 ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS,
49 ACCESS_CONTROL_MAX_AGE, ACCESS_CONTROL_REQUEST_HEADERS, ACCESS_CONTROL_REQUEST_METHOD, ORIGIN,
50};
51use hyper::{Method, Response, StatusCode};
52
53use crate::config::{CompiledCorsOrigins, Cors};
54use crate::dataplane::hardening::merge_vary;
55
56/// Is this request a CORS preflight (the only request shape the gateway
57/// answers without proxying)? Requires `OPTIONS`, an `Origin`, and the
58/// `Access-Control-Request-Method` marker the Fetch spec defines for
59/// preflights; anything less is a normal request.
60pub fn is_preflight(method: &Method, headers: &HeaderMap) -> bool {
61 *method == Method::OPTIONS
62 && headers.contains_key(ACCESS_CONTROL_REQUEST_METHOD)
63 && headers.contains_key(ORIGIN)
64}
65
66/// Is `method` (an `Access-Control-Request-Method` value) allowed?
67/// Case-insensitive token comparison against `allowed_methods`.
68fn method_allowed(cors: &Cors, method: &str) -> bool {
69 cors.allowed_methods
70 .iter()
71 .any(|m| m.eq_ignore_ascii_case(method.trim()))
72}
73
74/// Are ALL headers of an `Access-Control-Request-Headers` value allowed?
75/// `*` in `allowed_headers` permits any requested header; otherwise
76/// every requested name must appear (case-insensitively) in the list.
77fn headers_allowed(cors: &Cors, requested: &str) -> bool {
78 let wildcard = cors.allowed_headers.iter().any(|h| h == "*");
79 requested.split(',').all(|name| {
80 let name = name.trim();
81 if name.is_empty() {
82 return true;
83 }
84 wildcard
85 || cors
86 .allowed_headers
87 .iter()
88 .any(|h| h.eq_ignore_ascii_case(name))
89 })
90}
91
92/// Build the gateway-answered preflight response (DW-027): always 204;
93/// CORS headers only when the preflight validates against the policy.
94/// `origins` is the policy's snapshot-compiled origin set (normalized
95/// once at compile time, not per request).
96///
97/// Caching correctness: the response varies by `Origin` and, when
98/// echoing requested values, by the two request-marker headers — all
99/// three are listed in `Vary` unconditionally so a shared cache cannot
100/// serve one origin's preflight answer to another.
101pub fn preflight_response(
102 cors: &Cors,
103 origins: &CompiledCorsOrigins,
104 headers: &HeaderMap,
105) -> Response<Full<Bytes>> {
106 let origin = headers
107 .get(ORIGIN)
108 .and_then(|v| v.to_str().ok())
109 .unwrap_or_default();
110 let requested_method = headers
111 .get(ACCESS_CONTROL_REQUEST_METHOD)
112 .and_then(|v| v.to_str().ok())
113 .unwrap_or_default();
114 let requested_headers = headers
115 .get(ACCESS_CONTROL_REQUEST_HEADERS)
116 .and_then(|v| v.to_str().ok())
117 .unwrap_or_default();
118
119 let mut builder = Response::builder().status(StatusCode::NO_CONTENT).header(
120 "vary",
121 "Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
122 );
123
124 if origins.allows(origin)
125 && method_allowed(cors, requested_method)
126 && headers_allowed(cors, requested_headers)
127 {
128 // Echo the request origin under a specific list (the allowed
129 // set is closed, so the echo IS the policy decision); `*` only
130 // under the wildcard config (validation rejects `*` + creds).
131 let allow_origin = if origins.wildcard() { "*" } else { origin };
132 if let Ok(v) = HeaderValue::from_str(allow_origin) {
133 builder = builder.header(ACCESS_CONTROL_ALLOW_ORIGIN, v);
134 }
135 if let Ok(v) = HeaderValue::from_str(&cors.allowed_methods.join(", ")) {
136 builder = builder.header(ACCESS_CONTROL_ALLOW_METHODS, v);
137 }
138 // Allow-headers: echo the requested list under a `*` policy
139 // (the browser asked for exactly these), the configured list
140 // otherwise — both are correct answers to "may I send these".
141 let allow_headers = if cors.allowed_headers.iter().any(|h| h == "*") {
142 requested_headers.to_string()
143 } else {
144 cors.allowed_headers.join(", ")
145 };
146 if !allow_headers.is_empty() {
147 if let Ok(v) = HeaderValue::from_str(&allow_headers) {
148 builder = builder.header(ACCESS_CONTROL_ALLOW_HEADERS, v);
149 }
150 }
151 if cors.allow_credentials {
152 builder = builder.header(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
153 }
154 if let Some(max_age) = cors.max_age_secs {
155 builder = builder.header(ACCESS_CONTROL_MAX_AGE, max_age.to_string());
156 }
157 }
158
159 builder
160 .body(Full::new(Bytes::new()))
161 .expect("static preflight response is valid")
162}
163
164/// Decorate an ACTUAL (non-preflight) response with the route's CORS
165/// headers (DW-027). No-ops when the request carried no `Origin` or an
166/// origin the policy does not allow. `origins` is the policy's
167/// snapshot-compiled origin set (see [`preflight_response`]).
168pub fn decorate_actual(
169 cors: &Cors,
170 origins: &CompiledCorsOrigins,
171 origin: Option<&HeaderValue>,
172 resp_headers: &mut HeaderMap,
173) {
174 let Some(origin) = origin.and_then(|v| v.to_str().ok()) else {
175 return;
176 };
177 if !origins.allows(origin) {
178 return;
179 }
180 let allow_origin = if origins.wildcard() { "*" } else { origin };
181 if let Ok(v) = HeaderValue::from_str(allow_origin) {
182 resp_headers.insert(ACCESS_CONTROL_ALLOW_ORIGIN, v);
183 }
184 if cors.allow_credentials {
185 resp_headers.insert(
186 ACCESS_CONTROL_ALLOW_CREDENTIALS,
187 HeaderValue::from_static("true"),
188 );
189 }
190 if !cors.expose_headers.is_empty() {
191 if let Ok(v) = HeaderValue::from_str(&cors.expose_headers.join(", ")) {
192 resp_headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, v);
193 }
194 }
195 merge_vary(resp_headers, "Origin");
196}