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/// The headers `CorsHandler.build_response_headers` adds to the response pass.
150///
151/// The adapter's `_inject_cors_headers` applies them to every response after
152/// the modifier: `Vary: Origin`, and `Access-Control-Expose-Headers` when
153/// `cors_expose_headers` (`expose_headers`) is set. Empty exactly when [`cors_response_headers`]
154/// is (no CORS, no Origin, or a disallowed origin).
155#[must_use]
156pub fn cors_handler_headers(
157    cors: &CorsConfig,
158    expose_headers: &[String],
159    origin: Option<&str>,
160) -> BTreeMap<String, String> {
161    let mut headers = BTreeMap::new();
162    if cors_response_headers(cors, origin).is_empty() {
163        return headers;
164    }
165    headers.insert("Vary".to_owned(), "Origin".to_owned());
166    if !expose_headers.is_empty() {
167        headers.insert(
168            "Access-Control-Expose-Headers".to_owned(),
169            expose_headers.join(", "),
170        );
171    }
172    headers
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn an_allowed_preflight_answers_the_full_header_set() {
181        let cors = CorsConfig {
182            enabled: true,
183            allow_origins: vec!["https://app.example.com".to_owned()],
184            allow_methods: vec!["GET".to_owned(), "POST".to_owned()],
185            allow_headers: vec!["content-type".to_owned(), "x-custom".to_owned()],
186            allow_credentials: true,
187            max_age: 900,
188        };
189        let answer = build_preflight_response(
190            &cors,
191            PreflightRequest {
192                origin: Some("https://app.example.com"),
193                request_method: Some("post"),
194                request_headers_raw: Some("Content-Type, X-Custom"),
195            },
196        );
197        assert_eq!(answer.status_code, 200);
198        assert_eq!(answer.body, "OK");
199        assert_eq!(
200            answer
201                .headers
202                .get("Access-Control-Allow-Origin")
203                .map(String::as_str),
204            Some("https://app.example.com"),
205            "credentials keep the exact origin (no wildcard echo)"
206        );
207        assert_eq!(
208            answer
209                .headers
210                .get("Access-Control-Allow-Methods")
211                .map(String::as_str),
212            Some("GET, POST")
213        );
214        assert_eq!(
215            answer
216                .headers
217                .get("Access-Control-Max-Age")
218                .map(String::as_str),
219            Some("900")
220        );
221        assert_eq!(
222            answer
223                .headers
224                .get("Access-Control-Allow-Credentials")
225                .map(String::as_str),
226            Some("true")
227        );
228        assert_eq!(
229            answer.headers.get("Vary").map(String::as_str),
230            Some("Origin")
231        );
232    }
233
234    #[test]
235    fn a_disallowed_preflight_answers_the_failure_list() {
236        let cors = CorsConfig {
237            enabled: true,
238            allow_origins: vec!["https://app.example.com".to_owned()],
239            allow_methods: vec!["GET".to_owned()],
240            allow_headers: vec!["content-type".to_owned()],
241            allow_credentials: false,
242            max_age: 600,
243        };
244        let answer = build_preflight_response(
245            &cors,
246            PreflightRequest {
247                origin: Some("https://evil.example.com"),
248                request_method: Some("DELETE"),
249                request_headers_raw: Some("X-Injected"),
250            },
251        );
252        assert_eq!(answer.status_code, 400);
253        assert_eq!(answer.body, "Disallowed CORS: origin, method, headers");
254        // The failure still carries the method list, the max age, and Vary.
255        assert_eq!(
256            answer
257                .headers
258                .get("Access-Control-Allow-Methods")
259                .map(String::as_str),
260            Some("GET")
261        );
262        assert_eq!(
263            answer
264                .headers
265                .get("Access-Control-Max-Age")
266                .map(String::as_str),
267            Some("600")
268        );
269        assert!(!answer.headers.contains_key("Access-Control-Allow-Origin"));
270    }
271
272    #[test]
273    fn the_wildcard_config_downgrades_the_credentials_echo() {
274        let cors = CorsConfig {
275            enabled: true,
276            allow_origins: vec!["*".to_owned()],
277            allow_methods: vec!["PATCH".to_owned()],
278            allow_headers: vec!["*".to_owned()],
279            allow_credentials: false,
280            max_age: 600,
281        };
282        let answer = build_preflight_response(
283            &cors,
284            PreflightRequest {
285                origin: Some("https://any.example.com"),
286                request_method: Some("PATCH"),
287                request_headers_raw: Some("X-Anything"),
288            },
289        );
290        assert_eq!(answer.status_code, 200);
291        assert_eq!(
292            answer
293                .headers
294                .get("Access-Control-Allow-Origin")
295                .map(String::as_str),
296            Some("*")
297        );
298        assert_eq!(
299            answer
300                .headers
301                .get("Access-Control-Allow-Headers")
302                .map(String::as_str),
303            Some("X-Anything"),
304            "the wildcard header policy echoes the requested list"
305        );
306    }
307
308    #[test]
309    fn is_preflight_needs_options_and_the_request_method_header() {
310        let headers = vec![
311            (
312                String::from("Origin"),
313                String::from("https://app.example.com"),
314            ),
315            (
316                String::from("Access-Control-Request-Method"),
317                String::from("POST"),
318            ),
319        ];
320        assert!(is_preflight("OPTIONS", &headers));
321        assert!(is_preflight("options", &headers));
322        // A plain OPTIONS (no preflight header) is not a preflight.
323        let plain = vec![(
324            String::from("Origin"),
325            String::from("https://app.example.com"),
326        )];
327        assert!(!is_preflight("OPTIONS", &plain));
328        assert!(!is_preflight("GET", &headers));
329    }
330
331    #[test]
332    fn wildcard_policy_with_downgraded_credentials_answers_the_wildcard() {
333        // The corpus cors_wildcard_with_credentials_blocked shape: the
334        // config asks for credentials with the wildcard, the resolution
335        // drops the flag, and the response still answers "*" without the
336        // allow-credentials header.
337        let mut cors = CorsConfig {
338            enabled: true,
339            allow_origins: vec!["*".to_owned()],
340            allow_credentials: true,
341            ..CorsConfig::default()
342        };
343        downgrade_wildcard_credentials(&mut cors);
344        let headers = cors_response_headers(&cors, Some("https://app.example.com"));
345        assert_eq!(
346            headers
347                .get("Access-Control-Allow-Origin")
348                .map(String::as_str),
349            Some("*")
350        );
351        assert!(!headers.contains_key("Access-Control-Allow-Credentials"));
352        assert_eq!(
353            headers
354                .get("Access-Control-Allow-Methods")
355                .map(String::as_str),
356            Some("GET, POST, PUT, PATCH, DELETE, OPTIONS")
357        );
358        assert_eq!(
359            headers
360                .get("Access-Control-Allow-Headers")
361                .map(String::as_str),
362            Some("*")
363        );
364        assert_eq!(
365            headers.get("Access-Control-Max-Age").map(String::as_str),
366            Some("3600")
367        );
368    }
369
370    #[test]
371    fn allowed_origin_is_echoed() {
372        let cors = CorsConfig {
373            enabled: true,
374            allow_origins: vec!["https://app.example.com".to_owned()],
375            ..CorsConfig::default()
376        };
377        let headers = cors_response_headers(&cors, Some("https://app.example.com"));
378        assert_eq!(
379            headers
380                .get("Access-Control-Allow-Origin")
381                .map(String::as_str),
382            Some("https://app.example.com")
383        );
384    }
385
386    #[test]
387    fn disallowed_origin_and_disabled_cors_get_nothing() {
388        let cors = CorsConfig {
389            enabled: true,
390            allow_origins: vec!["https://app.example.com".to_owned()],
391            ..CorsConfig::default()
392        };
393        assert!(cors_response_headers(&cors, Some("https://evil.example.com")).is_empty());
394        assert!(
395            cors_response_headers(
396                &CorsConfig {
397                    enabled: false,
398                    ..CorsConfig::default()
399                },
400                Some("https://app.example.com")
401            )
402            .is_empty()
403        );
404        // No Origin header at all: no CORS headers.
405        assert!(cors_response_headers(&cors, None).is_empty());
406    }
407
408    #[test]
409    fn credentials_survive_for_an_exact_origin_policy() {
410        let cors = CorsConfig {
411            enabled: true,
412            allow_origins: vec!["https://app.example.com".to_owned()],
413            allow_credentials: true,
414            ..CorsConfig::default()
415        };
416        let headers = cors_response_headers(&cors, Some("https://app.example.com"));
417        assert_eq!(
418            headers
419                .get("Access-Control-Allow-Credentials")
420                .map(String::as_str),
421            Some("true")
422        );
423    }
424    #[test]
425    fn the_handler_headers_add_vary_and_the_exposed_headers() {
426        let mut cors = CorsConfig {
427            enabled: true,
428            allow_origins: vec!["https://app.example.com".to_owned()],
429            ..CorsConfig::default()
430        };
431        let expose = vec!["X-Request-Id".to_owned(), "X-Trace".to_owned()];
432        let headers = cors_handler_headers(&cors, &expose, Some("https://app.example.com"));
433        assert_eq!(headers.get("Vary").map(String::as_str), Some("Origin"));
434        assert_eq!(
435            headers
436                .get("Access-Control-Expose-Headers")
437                .map(String::as_str),
438            Some("X-Request-Id, X-Trace")
439        );
440        // Nothing for a disallowed origin, no origin, or a disabled switch.
441        assert!(cors_handler_headers(&cors, &expose, Some("https://evil.example.com")).is_empty());
442        assert!(cors_handler_headers(&cors, &expose, None).is_empty());
443        let headers = cors_handler_headers(&cors, &[], Some("https://app.example.com"));
444        assert_eq!(headers.len(), 1, "only Vary without exposed headers");
445        cors.enabled = false;
446        assert!(cors_handler_headers(&cors, &expose, Some("https://app.example.com")).is_empty());
447    }
448}
449
450#[cfg(test)]
451mod coverage_tests {
452    use super::*;
453
454    #[test]
455    fn cors_headers_require_an_origin_and_a_nonempty_allowlist() {
456        let cors = CorsConfig {
457            enabled: true,
458            allow_origins: vec!["https://good".to_owned()],
459            ..CorsConfig::default()
460        };
461        // no Origin header: nothing emitted
462        assert!(cors_response_headers(&cors, None).is_empty());
463        // an empty Origin is falsy in the reference: nothing emitted
464        assert!(cors_response_headers(&cors, Some("")).is_empty());
465        // an empty allowlist: nothing emitted
466        let empty = CorsConfig {
467            enabled: true,
468            allow_origins: Vec::new(),
469            allow_methods: Vec::new(),
470            allow_headers: Vec::new(),
471            allow_credentials: false,
472            max_age: 600,
473        };
474        assert!(cors_response_headers(&empty, Some("https://good")).is_empty());
475    }
476
477    #[test]
478    fn cors_headers_block_undowngraded_wildcard_credentials() {
479        // a hand-built wildcard policy that still carries credentials is
480        // blocked outright (the second line of defense)
481        let cors = CorsConfig {
482            enabled: true,
483            allow_origins: vec!["*".to_owned()],
484            allow_credentials: true,
485            ..CorsConfig::default()
486        };
487        assert!(cors_response_headers(&cors, Some("https://good")).is_empty());
488    }
489}
490
491/// The preflight request header the reference gates an OPTIONS request on
492/// (`ALLOWED_PREFLIGHT_REQUEST_HEADER`).
493pub const ALLOWED_PREFLIGHT_REQUEST_HEADER: &str = "access-control-request-method";
494
495/// The reference `is_preflight`: an `OPTIONS` method (case-insensitive)
496/// carrying the `access-control-request-method` header.
497#[must_use]
498pub fn is_preflight(method: &str, request_headers: &[(String, String)]) -> bool {
499    method.eq_ignore_ascii_case("OPTIONS")
500        && request_headers
501            .iter()
502            .any(|(name, _)| name.eq_ignore_ascii_case(ALLOWED_PREFLIGHT_REQUEST_HEADER))
503}
504
505/// The reference `build_preflight_response`'s request view: the three
506/// headers the preflight validation reads (each already extracted by the
507/// adapter from its own header map).
508#[derive(Debug, Clone, Copy, Default)]
509pub struct PreflightRequest<'a> {
510    /// The `Origin` header value, if present.
511    pub origin: Option<&'a str>,
512    /// The `Access-Control-Request-Method` value, if present.
513    pub request_method: Option<&'a str>,
514    /// The raw `Access-Control-Request-Headers` value, if present.
515    pub request_headers_raw: Option<&'a str>,
516}
517
518/// The reference `CorsPreflightResponse`: the status, the header set, and
519/// the plain-text body the preflight answer renders.
520#[derive(Debug, Clone, PartialEq, Eq)]
521pub struct CorsPreflightResponse {
522    /// `200` for an allowed preflight, `400` for a disallowed one.
523    pub status_code: u16,
524    /// The CORS headers the answer carries (`Vary`, allow-origin,
525    /// allow-methods, max-age, credentials, allow-headers).
526    pub headers: BTreeMap<String, String>,
527    /// `"OK"` allowed, `"Disallowed CORS: <failures>"` otherwise.
528    pub body: String,
529}
530
531/// The reference `build_preflight_response` (`cors_handler.py`).
532///
533/// Validate the origin, the requested method, and the requested headers
534/// against the resolved config; always carry the method list, the max
535/// age, and the credentials flag; a failure answers `400` with the
536/// joined failure list.
537#[must_use]
538pub fn build_preflight_response(
539    cors: &CorsConfig,
540    request: PreflightRequest<'_>,
541) -> CorsPreflightResponse {
542    let mut failures: Vec<&'static str> = Vec::new();
543    let mut response_headers = BTreeMap::from([(String::from("Vary"), String::from("Origin"))]);
544
545    // The origin arm (`_validate_preflight_origin`): an allowed origin
546    // gets the wildcard (unless credentials downgrade it) or itself.
547    let origin = request.origin.unwrap_or_default();
548    if is_origin_allowed(origin, &cors.allow_origins) {
549        let wildcard = cors.allow_origins.iter().any(|o| o == "*");
550        let allow_origin = if wildcard && !cors.allow_credentials {
551            "*"
552        } else {
553            origin
554        };
555        response_headers.insert(
556            String::from("Access-Control-Allow-Origin"),
557            allow_origin.to_owned(),
558        );
559    } else {
560        failures.push("origin");
561    }
562
563    // The method arm: the uppercased request method must be listed.
564    let requested_method = request
565        .request_method
566        .unwrap_or_default()
567        .to_ascii_uppercase();
568    if !cors
569        .allow_methods
570        .iter()
571        .any(|method| method.to_ascii_uppercase() == requested_method)
572    {
573        failures.push("method");
574    }
575
576    // The headers arm (`_validate_preflight_headers`): the wildcard echoes
577    // the requested list, a listed set rejects any unlisted name, and an
578    // exact set echoes the raw value.
579    let requested_headers: Vec<String> = request
580        .request_headers_raw
581        .unwrap_or_default()
582        .split(',')
583        .map(str::trim)
584        .filter(|name| !name.is_empty())
585        .map(str::to_ascii_lowercase)
586        .collect();
587    let requested_headers_raw = request.request_headers_raw.unwrap_or_default();
588    let allow_all_headers = cors.allow_headers.iter().any(|h| h == "*");
589    if allow_all_headers {
590        if !requested_headers_raw.is_empty() {
591            response_headers.insert(
592                String::from("Access-Control-Allow-Headers"),
593                requested_headers_raw.to_owned(),
594            );
595        }
596    } else if requested_headers.iter().any(|name| {
597        !cors
598            .allow_headers
599            .iter()
600            .any(|h| h.eq_ignore_ascii_case(name))
601    }) {
602        failures.push("headers");
603    }
604
605    response_headers.insert(
606        String::from("Access-Control-Allow-Methods"),
607        cors.allow_methods.join(", "),
608    );
609    response_headers.insert(
610        String::from("Access-Control-Max-Age"),
611        cors.max_age.to_string(),
612    );
613
614    if cors.allow_credentials {
615        response_headers.insert(
616            String::from("Access-Control-Allow-Credentials"),
617            String::from("true"),
618        );
619    }
620
621    if failures.is_empty() {
622        CorsPreflightResponse {
623            status_code: 200,
624            headers: response_headers,
625            body: String::from("OK"),
626        }
627    } else {
628        CorsPreflightResponse {
629            status_code: 400,
630            headers: response_headers,
631            body: format!("Disallowed CORS: {}", failures.join(", ")),
632        }
633    }
634}