Skip to main content

guard_core_engine/
cors.rs

1//! CORS handling, ported from the reference response-side CORS surface.
2//!
3//! The engine owns the verdict; adapters (and the conformance runner)
4//! apply the returned map to their responses, composing on top of the
5//! security-header set exactly like `apply_cors_headers`. Reference
6//! sources: `get_cors_headers` (`_security_headers_cors.py`), the config
7//! resolution (`_compute_cors_config`), and the `SecurityConfig` CORS
8//! field defaults.
9//!
10//! The wildcard + credentials misconfiguration is NOT rejected: the
11//! reference `_compute_cors_config` logs an error and drops the
12//! credentials flag at resolution time, so the wildcard policy answers
13//! without the allow-credentials header (the browser blocks credentialed
14//! CORS), and a disallowed origin simply gets no CORS headers (the browser
15//! enforces).
16//!
17//! # Example
18//!
19//! ```
20//! use guard_core_engine::cors::{CorsConfig, cors_response_headers};
21//!
22//! let cors = CorsConfig {
23//!     enabled: true,
24//!     allow_origins: vec!["https://app.example.com".to_owned()],
25//!     ..CorsConfig::default()
26//! };
27//! let headers = cors_response_headers(&cors, Some("https://app.example.com"));
28//! assert_eq!(
29//!     headers.get("Access-Control-Allow-Origin").map(String::as_str),
30//!     Some("https://app.example.com")
31//! );
32//! assert_eq!(
33//!     headers.get("Access-Control-Allow-Methods").map(String::as_str),
34//!     Some("GET, POST, PUT, PATCH, DELETE, OPTIONS")
35//! );
36//!
37//! // A disallowed origin gets nothing (the browser enforces).
38//! assert!(cors_response_headers(&cors, Some("https://evil.example.com")).is_empty());
39//! ```
40
41use std::collections::BTreeMap;
42
43/// The resolved CORS configuration: `_compute_cors_config`'s returned
44/// state, with the `SecurityConfig` CORS field defaults.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct CorsConfig {
47    /// The reference `enable_cors` (`false` by default): a disabled switch
48    /// means no CORS headers on any response.
49    pub enabled: bool,
50    /// The allowed origins; `"*"` is the wildcard origin.
51    pub allow_origins: Vec<String>,
52    pub allow_methods: Vec<String>,
53    pub allow_headers: Vec<String>,
54    pub allow_credentials: bool,
55    /// The reference `cors_max_age` (600 by default): the preflight
56    /// cache lifetime the `Access-Control-Max-Age` header carries.
57    pub max_age: u64,
58}
59
60impl Default for CorsConfig {
61    fn default() -> Self {
62        Self {
63            enabled: false,
64            allow_origins: vec!["*".to_owned()],
65            allow_methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
66                .iter()
67                .map(|method| (*method).to_owned())
68                .collect(),
69            allow_headers: vec!["*".to_owned()],
70            allow_credentials: false,
71            max_age: 600,
72        }
73    }
74}
75
76/// `_compute_cors_config`'s wildcard + credentials downgrade, in place.
77///
78/// The reference does not reject the combination: it logs an error and
79/// drops the credentials flag at resolution time (a CORS-disabled config
80/// resolves to no CORS surface upstream).
81pub fn downgrade_wildcard_credentials(cors: &mut CorsConfig) {
82    if cors.allow_origins.iter().any(|origin| origin == "*") && cors.allow_credentials {
83        cors.allow_credentials = false;
84    }
85}
86
87/// `_is_origin_allowed`: the wildcard or exact membership.
88#[must_use]
89pub fn is_origin_allowed(origin: &str, allowed_origins: &[String]) -> bool {
90    allowed_origins.iter().any(|allowed| allowed == "*")
91        || allowed_origins.iter().any(|allowed| allowed == origin)
92}
93
94/// `get_cors_headers` composed with `_build_cors_headers`.
95///
96/// No CORS headers without an enabled config or for a disallowed origin;
97/// otherwise the echoed origin (or `"*"` for a wildcard policy), the
98/// joined methods and headers lists, the hardcoded 3600 max-age, and the
99/// allow-credentials header only when the (already downgraded)
100/// configuration still carries credentials. `origin` is the request's
101/// `Origin` header value; `None` (or an empty value, the reference falsy
102/// check) means the request carries no Origin and gets no CORS headers.
103#[must_use]
104pub fn cors_response_headers(cors: &CorsConfig, origin: Option<&str>) -> BTreeMap<String, String> {
105    let mut headers = BTreeMap::new();
106    let origin = match origin.filter(|origin| !origin.is_empty()) {
107        Some(origin) if cors.enabled => origin,
108        _ => return headers,
109    };
110    if cors.allow_origins.is_empty() {
111        return headers;
112    }
113    // `_is_wildcard_with_credentials`: a wildcard policy whose credentials
114    // flag survived the resolution downgrade would block CORS entirely;
115    // after `downgrade_wildcard_credentials` the flag is always off, so
116    // this only fires on a hand-built un-downgraded config, exactly like
117    // the reference's second line of defense.
118    if cors.allow_origins.iter().any(|allowed| allowed == "*") && cors.allow_credentials {
119        return headers;
120    }
121    if !is_origin_allowed(origin, &cors.allow_origins) {
122        return headers;
123    }
124    let allow_origin =
125        if cors.allow_origins.iter().any(|allowed| allowed == "*") && !cors.allow_credentials {
126            "*".to_owned()
127        } else {
128            origin.to_owned()
129        };
130    headers.insert("Access-Control-Allow-Origin".to_owned(), allow_origin);
131    headers.insert(
132        "Access-Control-Allow-Methods".to_owned(),
133        cors.allow_methods.join(", "),
134    );
135    headers.insert(
136        "Access-Control-Allow-Headers".to_owned(),
137        cors.allow_headers.join(", "),
138    );
139    headers.insert("Access-Control-Max-Age".to_owned(), "3600".to_owned());
140    if cors.allow_credentials {
141        headers.insert(
142            "Access-Control-Allow-Credentials".to_owned(),
143            "true".to_owned(),
144        );
145    }
146    headers
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn an_allowed_preflight_answers_the_full_header_set() {
155        let cors = CorsConfig {
156            enabled: true,
157            allow_origins: vec!["https://app.example.com".to_owned()],
158            allow_methods: vec!["GET".to_owned(), "POST".to_owned()],
159            allow_headers: vec!["content-type".to_owned(), "x-custom".to_owned()],
160            allow_credentials: true,
161            max_age: 900,
162        };
163        let answer = build_preflight_response(
164            &cors,
165            PreflightRequest {
166                origin: Some("https://app.example.com"),
167                request_method: Some("post"),
168                request_headers_raw: Some("Content-Type, X-Custom"),
169            },
170        );
171        assert_eq!(answer.status_code, 200);
172        assert_eq!(answer.body, "OK");
173        assert_eq!(
174            answer
175                .headers
176                .get("Access-Control-Allow-Origin")
177                .map(String::as_str),
178            Some("https://app.example.com"),
179            "credentials keep the exact origin (no wildcard echo)"
180        );
181        assert_eq!(
182            answer
183                .headers
184                .get("Access-Control-Allow-Methods")
185                .map(String::as_str),
186            Some("GET, POST")
187        );
188        assert_eq!(
189            answer
190                .headers
191                .get("Access-Control-Max-Age")
192                .map(String::as_str),
193            Some("900")
194        );
195        assert_eq!(
196            answer
197                .headers
198                .get("Access-Control-Allow-Credentials")
199                .map(String::as_str),
200            Some("true")
201        );
202        assert_eq!(
203            answer.headers.get("Vary").map(String::as_str),
204            Some("Origin")
205        );
206    }
207
208    #[test]
209    fn a_disallowed_preflight_answers_the_failure_list() {
210        let cors = CorsConfig {
211            enabled: true,
212            allow_origins: vec!["https://app.example.com".to_owned()],
213            allow_methods: vec!["GET".to_owned()],
214            allow_headers: vec!["content-type".to_owned()],
215            allow_credentials: false,
216            max_age: 600,
217        };
218        let answer = build_preflight_response(
219            &cors,
220            PreflightRequest {
221                origin: Some("https://evil.example.com"),
222                request_method: Some("DELETE"),
223                request_headers_raw: Some("X-Injected"),
224            },
225        );
226        assert_eq!(answer.status_code, 400);
227        assert_eq!(answer.body, "Disallowed CORS: origin, method, headers");
228        // The failure still carries the method list, the max age, and Vary.
229        assert_eq!(
230            answer
231                .headers
232                .get("Access-Control-Allow-Methods")
233                .map(String::as_str),
234            Some("GET")
235        );
236        assert_eq!(
237            answer
238                .headers
239                .get("Access-Control-Max-Age")
240                .map(String::as_str),
241            Some("600")
242        );
243        assert!(!answer.headers.contains_key("Access-Control-Allow-Origin"));
244    }
245
246    #[test]
247    fn the_wildcard_config_downgrades_the_credentials_echo() {
248        let cors = CorsConfig {
249            enabled: true,
250            allow_origins: vec!["*".to_owned()],
251            allow_methods: vec!["PATCH".to_owned()],
252            allow_headers: vec!["*".to_owned()],
253            allow_credentials: false,
254            max_age: 600,
255        };
256        let answer = build_preflight_response(
257            &cors,
258            PreflightRequest {
259                origin: Some("https://any.example.com"),
260                request_method: Some("PATCH"),
261                request_headers_raw: Some("X-Anything"),
262            },
263        );
264        assert_eq!(answer.status_code, 200);
265        assert_eq!(
266            answer
267                .headers
268                .get("Access-Control-Allow-Origin")
269                .map(String::as_str),
270            Some("*")
271        );
272        assert_eq!(
273            answer
274                .headers
275                .get("Access-Control-Allow-Headers")
276                .map(String::as_str),
277            Some("X-Anything"),
278            "the wildcard header policy echoes the requested list"
279        );
280    }
281
282    #[test]
283    fn is_preflight_needs_options_and_the_request_method_header() {
284        let headers = vec![
285            (
286                String::from("Origin"),
287                String::from("https://app.example.com"),
288            ),
289            (
290                String::from("Access-Control-Request-Method"),
291                String::from("POST"),
292            ),
293        ];
294        assert!(is_preflight("OPTIONS", &headers));
295        assert!(is_preflight("options", &headers));
296        // A plain OPTIONS (no preflight header) is not a preflight.
297        let plain = vec![(
298            String::from("Origin"),
299            String::from("https://app.example.com"),
300        )];
301        assert!(!is_preflight("OPTIONS", &plain));
302        assert!(!is_preflight("GET", &headers));
303    }
304
305    #[test]
306    fn wildcard_policy_with_downgraded_credentials_answers_the_wildcard() {
307        // The corpus cors_wildcard_with_credentials_blocked shape: the
308        // config asks for credentials with the wildcard, the resolution
309        // drops the flag, and the response still answers "*" without the
310        // allow-credentials header.
311        let mut cors = CorsConfig {
312            enabled: true,
313            allow_origins: vec!["*".to_owned()],
314            allow_credentials: true,
315            ..CorsConfig::default()
316        };
317        downgrade_wildcard_credentials(&mut cors);
318        let headers = cors_response_headers(&cors, Some("https://app.example.com"));
319        assert_eq!(
320            headers
321                .get("Access-Control-Allow-Origin")
322                .map(String::as_str),
323            Some("*")
324        );
325        assert!(!headers.contains_key("Access-Control-Allow-Credentials"));
326        assert_eq!(
327            headers
328                .get("Access-Control-Allow-Methods")
329                .map(String::as_str),
330            Some("GET, POST, PUT, PATCH, DELETE, OPTIONS")
331        );
332        assert_eq!(
333            headers
334                .get("Access-Control-Allow-Headers")
335                .map(String::as_str),
336            Some("*")
337        );
338        assert_eq!(
339            headers.get("Access-Control-Max-Age").map(String::as_str),
340            Some("3600")
341        );
342    }
343
344    #[test]
345    fn allowed_origin_is_echoed() {
346        let cors = CorsConfig {
347            enabled: true,
348            allow_origins: vec!["https://app.example.com".to_owned()],
349            ..CorsConfig::default()
350        };
351        let headers = cors_response_headers(&cors, Some("https://app.example.com"));
352        assert_eq!(
353            headers
354                .get("Access-Control-Allow-Origin")
355                .map(String::as_str),
356            Some("https://app.example.com")
357        );
358    }
359
360    #[test]
361    fn disallowed_origin_and_disabled_cors_get_nothing() {
362        let cors = CorsConfig {
363            enabled: true,
364            allow_origins: vec!["https://app.example.com".to_owned()],
365            ..CorsConfig::default()
366        };
367        assert!(cors_response_headers(&cors, Some("https://evil.example.com")).is_empty());
368        assert!(
369            cors_response_headers(
370                &CorsConfig {
371                    enabled: false,
372                    ..CorsConfig::default()
373                },
374                Some("https://app.example.com")
375            )
376            .is_empty()
377        );
378        // No Origin header at all: no CORS headers.
379        assert!(cors_response_headers(&cors, None).is_empty());
380    }
381
382    #[test]
383    fn credentials_survive_for_an_exact_origin_policy() {
384        let cors = CorsConfig {
385            enabled: true,
386            allow_origins: vec!["https://app.example.com".to_owned()],
387            allow_credentials: true,
388            ..CorsConfig::default()
389        };
390        let headers = cors_response_headers(&cors, Some("https://app.example.com"));
391        assert_eq!(
392            headers
393                .get("Access-Control-Allow-Credentials")
394                .map(String::as_str),
395            Some("true")
396        );
397    }
398}
399
400#[cfg(test)]
401mod coverage_tests {
402    use super::*;
403
404    #[test]
405    fn cors_headers_require_an_origin_and_a_nonempty_allowlist() {
406        let cors = CorsConfig {
407            enabled: true,
408            allow_origins: vec!["https://good".to_owned()],
409            ..CorsConfig::default()
410        };
411        // no Origin header: nothing emitted
412        assert!(cors_response_headers(&cors, None).is_empty());
413        // an empty Origin is falsy in the reference: nothing emitted
414        assert!(cors_response_headers(&cors, Some("")).is_empty());
415        // an empty allowlist: nothing emitted
416        let empty = CorsConfig {
417            enabled: true,
418            allow_origins: Vec::new(),
419            allow_methods: Vec::new(),
420            allow_headers: Vec::new(),
421            allow_credentials: false,
422            max_age: 600,
423        };
424        assert!(cors_response_headers(&empty, Some("https://good")).is_empty());
425    }
426
427    #[test]
428    fn cors_headers_block_undowngraded_wildcard_credentials() {
429        // a hand-built wildcard policy that still carries credentials is
430        // blocked outright (the second line of defense)
431        let cors = CorsConfig {
432            enabled: true,
433            allow_origins: vec!["*".to_owned()],
434            allow_credentials: true,
435            ..CorsConfig::default()
436        };
437        assert!(cors_response_headers(&cors, Some("https://good")).is_empty());
438    }
439}
440
441/// The preflight request header the reference gates an OPTIONS request on
442/// (`ALLOWED_PREFLIGHT_REQUEST_HEADER`).
443pub const ALLOWED_PREFLIGHT_REQUEST_HEADER: &str = "access-control-request-method";
444
445/// The reference `is_preflight`: an `OPTIONS` method (case-insensitive)
446/// carrying the `access-control-request-method` header.
447#[must_use]
448pub fn is_preflight(method: &str, request_headers: &[(String, String)]) -> bool {
449    method.eq_ignore_ascii_case("OPTIONS")
450        && request_headers
451            .iter()
452            .any(|(name, _)| name.eq_ignore_ascii_case(ALLOWED_PREFLIGHT_REQUEST_HEADER))
453}
454
455/// The reference `build_preflight_response`'s request view: the three
456/// headers the preflight validation reads (each already extracted by the
457/// adapter from its own header map).
458#[derive(Debug, Clone, Copy, Default)]
459pub struct PreflightRequest<'a> {
460    /// The `Origin` header value, if present.
461    pub origin: Option<&'a str>,
462    /// The `Access-Control-Request-Method` value, if present.
463    pub request_method: Option<&'a str>,
464    /// The raw `Access-Control-Request-Headers` value, if present.
465    pub request_headers_raw: Option<&'a str>,
466}
467
468/// The reference `CorsPreflightResponse`: the status, the header set, and
469/// the plain-text body the preflight answer renders.
470#[derive(Debug, Clone, PartialEq, Eq)]
471pub struct CorsPreflightResponse {
472    /// `200` for an allowed preflight, `400` for a disallowed one.
473    pub status_code: u16,
474    /// The CORS headers the answer carries (`Vary`, allow-origin,
475    /// allow-methods, max-age, credentials, allow-headers).
476    pub headers: BTreeMap<String, String>,
477    /// `"OK"` allowed, `"Disallowed CORS: <failures>"` otherwise.
478    pub body: String,
479}
480
481/// The reference `build_preflight_response` (`cors_handler.py`).
482///
483/// Validate the origin, the requested method, and the requested headers
484/// against the resolved config; always carry the method list, the max
485/// age, and the credentials flag; a failure answers `400` with the
486/// joined failure list.
487#[must_use]
488pub fn build_preflight_response(
489    cors: &CorsConfig,
490    request: PreflightRequest<'_>,
491) -> CorsPreflightResponse {
492    let mut failures: Vec<&'static str> = Vec::new();
493    let mut response_headers = BTreeMap::from([(String::from("Vary"), String::from("Origin"))]);
494
495    // The origin arm (`_validate_preflight_origin`): an allowed origin
496    // gets the wildcard (unless credentials downgrade it) or itself.
497    let origin = request.origin.unwrap_or_default();
498    if is_origin_allowed(origin, &cors.allow_origins) {
499        let wildcard = cors.allow_origins.iter().any(|o| o == "*");
500        let allow_origin = if wildcard && !cors.allow_credentials {
501            "*"
502        } else {
503            origin
504        };
505        response_headers.insert(
506            String::from("Access-Control-Allow-Origin"),
507            allow_origin.to_owned(),
508        );
509    } else {
510        failures.push("origin");
511    }
512
513    // The method arm: the uppercased request method must be listed.
514    let requested_method = request
515        .request_method
516        .unwrap_or_default()
517        .to_ascii_uppercase();
518    if !cors
519        .allow_methods
520        .iter()
521        .any(|method| method.to_ascii_uppercase() == requested_method)
522    {
523        failures.push("method");
524    }
525
526    // The headers arm (`_validate_preflight_headers`): the wildcard echoes
527    // the requested list, a listed set rejects any unlisted name, and an
528    // exact set echoes the raw value.
529    let requested_headers: Vec<String> = request
530        .request_headers_raw
531        .unwrap_or_default()
532        .split(',')
533        .map(str::trim)
534        .filter(|name| !name.is_empty())
535        .map(str::to_ascii_lowercase)
536        .collect();
537    let requested_headers_raw = request.request_headers_raw.unwrap_or_default();
538    let allow_all_headers = cors.allow_headers.iter().any(|h| h == "*");
539    if allow_all_headers {
540        if !requested_headers_raw.is_empty() {
541            response_headers.insert(
542                String::from("Access-Control-Allow-Headers"),
543                requested_headers_raw.to_owned(),
544            );
545        }
546    } else if requested_headers.iter().any(|name| {
547        !cors
548            .allow_headers
549            .iter()
550            .any(|h| h.eq_ignore_ascii_case(name))
551    }) {
552        failures.push("headers");
553    }
554
555    response_headers.insert(
556        String::from("Access-Control-Allow-Methods"),
557        cors.allow_methods.join(", "),
558    );
559    response_headers.insert(
560        String::from("Access-Control-Max-Age"),
561        cors.max_age.to_string(),
562    );
563
564    if cors.allow_credentials {
565        response_headers.insert(
566            String::from("Access-Control-Allow-Credentials"),
567            String::from("true"),
568        );
569    }
570
571    if failures.is_empty() {
572        CorsPreflightResponse {
573            status_code: 200,
574            headers: response_headers,
575            body: String::from("OK"),
576        }
577    } else {
578        CorsPreflightResponse {
579            status_code: 400,
580            headers: response_headers,
581            body: format!("Disallowed CORS: {}", failures.join(", ")),
582        }
583    }
584}