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}