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}