guard_core_engine/
cors.rs1use std::collections::BTreeMap;
42
43#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct CorsConfig {
47 pub enabled: bool,
50 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
72pub 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#[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#[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 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 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 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 assert!(cors_response_headers(&cors, None).is_empty());
257 assert!(cors_response_headers(&cors, Some("")).is_empty());
259 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 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}