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}
56
57impl Default for CorsConfig {
58    fn default() -> Self {
59        Self {
60            enabled: false,
61            allow_origins: vec!["*".to_owned()],
62            allow_methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
63                .iter()
64                .map(|method| (*method).to_owned())
65                .collect(),
66            allow_headers: vec!["*".to_owned()],
67            allow_credentials: false,
68        }
69    }
70}
71
72/// `_compute_cors_config`'s wildcard + credentials downgrade, in place.
73///
74/// The reference does not reject the combination: it logs an error and
75/// drops the credentials flag at resolution time (a CORS-disabled config
76/// resolves to no CORS surface upstream).
77pub fn downgrade_wildcard_credentials(cors: &mut CorsConfig) {
78    if cors.allow_origins.iter().any(|origin| origin == "*") && cors.allow_credentials {
79        cors.allow_credentials = false;
80    }
81}
82
83/// `_is_origin_allowed`: the wildcard or exact membership.
84#[must_use]
85pub fn is_origin_allowed(origin: &str, allowed_origins: &[String]) -> bool {
86    allowed_origins.iter().any(|allowed| allowed == "*")
87        || allowed_origins.iter().any(|allowed| allowed == origin)
88}
89
90/// `get_cors_headers` composed with `_build_cors_headers`.
91///
92/// No CORS headers without an enabled config or for a disallowed origin;
93/// otherwise the echoed origin (or `"*"` for a wildcard policy), the
94/// joined methods and headers lists, the hardcoded 3600 max-age, and the
95/// allow-credentials header only when the (already downgraded)
96/// configuration still carries credentials. `origin` is the request's
97/// `Origin` header value; `None` (or an empty value, the reference falsy
98/// check) means the request carries no Origin and gets no CORS headers.
99#[must_use]
100pub fn cors_response_headers(cors: &CorsConfig, origin: Option<&str>) -> BTreeMap<String, String> {
101    let mut headers = BTreeMap::new();
102    let origin = match origin.filter(|origin| !origin.is_empty()) {
103        Some(origin) if cors.enabled => origin,
104        _ => return headers,
105    };
106    if cors.allow_origins.is_empty() {
107        return headers;
108    }
109    // `_is_wildcard_with_credentials`: a wildcard policy whose credentials
110    // flag survived the resolution downgrade would block CORS entirely;
111    // after `downgrade_wildcard_credentials` the flag is always off, so
112    // this only fires on a hand-built un-downgraded config, exactly like
113    // the reference's second line of defense.
114    if cors.allow_origins.iter().any(|allowed| allowed == "*") && cors.allow_credentials {
115        return headers;
116    }
117    if !is_origin_allowed(origin, &cors.allow_origins) {
118        return headers;
119    }
120    let allow_origin =
121        if cors.allow_origins.iter().any(|allowed| allowed == "*") && !cors.allow_credentials {
122            "*".to_owned()
123        } else {
124            origin.to_owned()
125        };
126    headers.insert("Access-Control-Allow-Origin".to_owned(), allow_origin);
127    headers.insert(
128        "Access-Control-Allow-Methods".to_owned(),
129        cors.allow_methods.join(", "),
130    );
131    headers.insert(
132        "Access-Control-Allow-Headers".to_owned(),
133        cors.allow_headers.join(", "),
134    );
135    headers.insert("Access-Control-Max-Age".to_owned(), "3600".to_owned());
136    if cors.allow_credentials {
137        headers.insert(
138            "Access-Control-Allow-Credentials".to_owned(),
139            "true".to_owned(),
140        );
141    }
142    headers
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn wildcard_policy_with_downgraded_credentials_answers_the_wildcard() {
151        // The corpus cors_wildcard_with_credentials_blocked shape: the
152        // config asks for credentials with the wildcard, the resolution
153        // drops the flag, and the response still answers "*" without the
154        // allow-credentials header.
155        let mut cors = CorsConfig {
156            enabled: true,
157            allow_origins: vec!["*".to_owned()],
158            allow_credentials: true,
159            ..CorsConfig::default()
160        };
161        downgrade_wildcard_credentials(&mut cors);
162        let headers = cors_response_headers(&cors, Some("https://app.example.com"));
163        assert_eq!(
164            headers
165                .get("Access-Control-Allow-Origin")
166                .map(String::as_str),
167            Some("*")
168        );
169        assert!(!headers.contains_key("Access-Control-Allow-Credentials"));
170        assert_eq!(
171            headers
172                .get("Access-Control-Allow-Methods")
173                .map(String::as_str),
174            Some("GET, POST, PUT, PATCH, DELETE, OPTIONS")
175        );
176        assert_eq!(
177            headers
178                .get("Access-Control-Allow-Headers")
179                .map(String::as_str),
180            Some("*")
181        );
182        assert_eq!(
183            headers.get("Access-Control-Max-Age").map(String::as_str),
184            Some("3600")
185        );
186    }
187
188    #[test]
189    fn allowed_origin_is_echoed() {
190        let cors = CorsConfig {
191            enabled: true,
192            allow_origins: vec!["https://app.example.com".to_owned()],
193            ..CorsConfig::default()
194        };
195        let headers = cors_response_headers(&cors, Some("https://app.example.com"));
196        assert_eq!(
197            headers
198                .get("Access-Control-Allow-Origin")
199                .map(String::as_str),
200            Some("https://app.example.com")
201        );
202    }
203
204    #[test]
205    fn disallowed_origin_and_disabled_cors_get_nothing() {
206        let cors = CorsConfig {
207            enabled: true,
208            allow_origins: vec!["https://app.example.com".to_owned()],
209            ..CorsConfig::default()
210        };
211        assert!(cors_response_headers(&cors, Some("https://evil.example.com")).is_empty());
212        assert!(
213            cors_response_headers(
214                &CorsConfig {
215                    enabled: false,
216                    ..CorsConfig::default()
217                },
218                Some("https://app.example.com")
219            )
220            .is_empty()
221        );
222        // No Origin header at all: no CORS headers.
223        assert!(cors_response_headers(&cors, None).is_empty());
224    }
225
226    #[test]
227    fn credentials_survive_for_an_exact_origin_policy() {
228        let cors = CorsConfig {
229            enabled: true,
230            allow_origins: vec!["https://app.example.com".to_owned()],
231            allow_credentials: true,
232            ..CorsConfig::default()
233        };
234        let headers = cors_response_headers(&cors, Some("https://app.example.com"));
235        assert_eq!(
236            headers
237                .get("Access-Control-Allow-Credentials")
238                .map(String::as_str),
239            Some("true")
240        );
241    }
242}
243
244#[cfg(test)]
245mod coverage_tests {
246    use super::*;
247
248    #[test]
249    fn cors_headers_require_an_origin_and_a_nonempty_allowlist() {
250        let cors = CorsConfig {
251            enabled: true,
252            allow_origins: vec!["https://good".to_owned()],
253            ..CorsConfig::default()
254        };
255        // no Origin header: nothing emitted
256        assert!(cors_response_headers(&cors, None).is_empty());
257        // an empty Origin is falsy in the reference: nothing emitted
258        assert!(cors_response_headers(&cors, Some("")).is_empty());
259        // an empty allowlist: nothing emitted
260        let empty = CorsConfig {
261            enabled: true,
262            allow_origins: Vec::new(),
263            allow_methods: Vec::new(),
264            allow_headers: Vec::new(),
265            allow_credentials: false,
266        };
267        assert!(cors_response_headers(&empty, Some("https://good")).is_empty());
268    }
269
270    #[test]
271    fn cors_headers_block_undowngraded_wildcard_credentials() {
272        // a hand-built wildcard policy that still carries credentials is
273        // blocked outright (the second line of defense)
274        let cors = CorsConfig {
275            enabled: true,
276            allow_origins: vec!["*".to_owned()],
277            allow_credentials: true,
278            ..CorsConfig::default()
279        };
280        assert!(cors_response_headers(&cors, Some("https://good")).is_empty());
281    }
282}