Skip to main content

max / makenotwork

8.9 KB · 285 lines History Blame Raw
1 //! CORS preflight verification, sends OPTIONS requests and checks Access-Control headers.
2
3 use tracing::instrument;
4
5 use crate::config::CorsCheck;
6 use crate::types::CorsCheckResult;
7
8 /// Send a CORS preflight OPTIONS request and verify the response allows the expected origin.
9 /// Returns one `CorsCheckResult` per `CorsCheck` in the input.
10 #[instrument(skip_all)]
11 pub async fn check_cors(target: &str, checks: &[CorsCheck]) -> Vec<CorsCheckResult> {
12 // Never `.unwrap()` (or fall back to `Client::new()`) on the client build: a
13 // build failure would panic this per-target CORS task and silently kill CORS
14 // monitoring for the target with no alert that the check died (fuzz-2026-07-06
15 // HIGH). `Client::new()` panics on the same failure, so surface it as a failed
16 // result per check instead.
17 let client = match crate::tls::https_client_builder()
18 .timeout(std::time::Duration::from_secs(10))
19 .redirect(reqwest::redirect::Policy::none())
20 .build()
21 {
22 Ok(c) => c,
23 Err(e) => {
24 tracing::warn!(error = %e, "cors: client build failed");
25 let now = chrono::Utc::now().to_rfc3339();
26 return checks
27 .iter()
28 .map(|check| CorsCheckResult {
29 target: target.to_string(),
30 url: check.url.clone(),
31 origin: check.origin.clone(),
32 method: check.method.clone(),
33 passes: false,
34 checked_at: now.clone(),
35 error: Some(format!("client build: {e}")),
36 })
37 .collect();
38 }
39 };
40
41 let mut results = Vec::with_capacity(checks.len());
42 for check in checks {
43 results.push(run_preflight(target, &client, check).await);
44 }
45 results
46 }
47
48 async fn run_preflight(
49 target: &str,
50 client: &reqwest::Client,
51 check: &CorsCheck,
52 ) -> CorsCheckResult {
53 let now = chrono::Utc::now().to_rfc3339();
54
55 let response = client
56 .request(reqwest::Method::OPTIONS, &check.url)
57 .header("Origin", &check.origin)
58 .header("Access-Control-Request-Method", &check.method)
59 .send()
60 .await;
61
62 match response {
63 Ok(resp) => {
64 let status = resp.status().as_u16();
65 let allow_origin = resp
66 .headers()
67 .get("access-control-allow-origin")
68 .and_then(|v| v.to_str().ok())
69 .unwrap_or("")
70 .to_string();
71 let allow_methods = resp
72 .headers()
73 .get("access-control-allow-methods")
74 .and_then(|v| v.to_str().ok())
75 .unwrap_or("")
76 .to_string();
77
78 let (passes, error) = evaluate_preflight(
79 status,
80 &allow_origin,
81 &allow_methods,
82 &check.origin,
83 &check.method,
84 );
85
86 CorsCheckResult {
87 target: target.to_string(),
88 url: check.url.clone(),
89 origin: check.origin.clone(),
90 method: check.method.clone(),
91 passes,
92 checked_at: now,
93 error,
94 }
95 }
96 Err(e) => CorsCheckResult {
97 target: target.to_string(),
98 url: check.url.clone(),
99 origin: check.origin.clone(),
100 method: check.method.clone(),
101 passes: false,
102 checked_at: now,
103 error: Some(format!("preflight request failed: {e}")),
104 },
105 }
106 }
107
108 /// Evaluate CORS preflight response headers against expected values.
109 /// Returns `(passes, error_message)`.
110 fn evaluate_preflight(
111 status: u16,
112 allow_origin: &str,
113 allow_methods: &str,
114 expected_origin: &str,
115 expected_method: &str,
116 ) -> (bool, Option<String>) {
117 let origin_ok = allow_origin == expected_origin || allow_origin == "*";
118 let method_ok = allow_methods
119 .split(',')
120 .any(|m| m.trim().eq_ignore_ascii_case(expected_method));
121
122 let passes = status < 400 && origin_ok && method_ok;
123
124 if passes {
125 (true, None)
126 } else {
127 let mut reasons = Vec::new();
128 if status >= 400 {
129 reasons.push(format!("HTTP {status}"));
130 }
131 if !origin_ok {
132 reasons.push(format!(
133 "Access-Control-Allow-Origin: {allow_origin:?} (expected {expected_origin:?} or \"*\")",
134 ));
135 }
136 if !method_ok {
137 reasons.push(format!(
138 "Access-Control-Allow-Methods: {allow_methods:?} (expected {expected_method:?})",
139 ));
140 }
141 (false, Some(reasons.join("; ")))
142 }
143 }
144
145 #[cfg(test)]
146 mod tests {
147 use super::*;
148
149 #[test]
150 fn preflight_exact_origin_match() {
151 let (passes, error) = evaluate_preflight(
152 200,
153 "https://makenot.work",
154 "PUT",
155 "https://makenot.work",
156 "PUT",
157 );
158 assert!(passes);
159 assert!(error.is_none());
160 }
161
162 #[test]
163 fn preflight_wildcard_origin() {
164 let (passes, error) = evaluate_preflight(200, "*", "GET", "https://makenot.work", "GET");
165 assert!(passes);
166 assert!(error.is_none());
167 }
168
169 #[test]
170 fn preflight_origin_mismatch() {
171 let (passes, error) = evaluate_preflight(
172 200,
173 "https://other.com",
174 "PUT",
175 "https://makenot.work",
176 "PUT",
177 );
178 assert!(!passes);
179 let msg = error.unwrap();
180 assert!(msg.contains("Access-Control-Allow-Origin"));
181 assert!(msg.contains("https://other.com"));
182 }
183
184 #[test]
185 fn preflight_method_case_insensitive() {
186 let (passes, _) = evaluate_preflight(200, "*", "put", "https://x.com", "PUT");
187 assert!(passes);
188 }
189
190 #[test]
191 fn preflight_method_comma_separated() {
192 let (passes, _) = evaluate_preflight(200, "*", "GET, PUT, DELETE", "https://x.com", "PUT");
193 assert!(passes);
194 }
195
196 #[test]
197 fn preflight_method_with_whitespace() {
198 let (passes, _) =
199 evaluate_preflight(200, "*", "GET , PUT , DELETE", "https://x.com", "PUT");
200 assert!(passes);
201 }
202
203 #[test]
204 fn preflight_method_mismatch() {
205 let (passes, error) = evaluate_preflight(200, "*", "GET, POST", "https://x.com", "PUT");
206 assert!(!passes);
207 let msg = error.unwrap();
208 assert!(msg.contains("Access-Control-Allow-Methods"));
209 }
210
211 #[test]
212 fn preflight_status_400_fails() {
213 let (passes, error) = evaluate_preflight(
214 403,
215 "https://makenot.work",
216 "PUT",
217 "https://makenot.work",
218 "PUT",
219 );
220 assert!(!passes);
221 assert!(error.unwrap().contains("HTTP 403"));
222 }
223
224 #[test]
225 fn preflight_multiple_failures() {
226 let (passes, error) = evaluate_preflight(
227 500,
228 "https://wrong.com",
229 "GET",
230 "https://makenot.work",
231 "PUT",
232 );
233 assert!(!passes);
234 let msg = error.unwrap();
235 assert!(msg.contains("HTTP 500"));
236 assert!(msg.contains("Access-Control-Allow-Origin"));
237 assert!(msg.contains("Access-Control-Allow-Methods"));
238 assert!(msg.contains("; "));
239 }
240
241 #[test]
242 fn preflight_missing_headers() {
243 let (passes, error) = evaluate_preflight(200, "", "", "https://makenot.work", "PUT");
244 assert!(!passes);
245 let msg = error.unwrap();
246 assert!(msg.contains("Access-Control-Allow-Origin"));
247 assert!(msg.contains("Access-Control-Allow-Methods"));
248 }
249
250 #[test]
251 fn cors_check_result_serde_roundtrip() {
252 let result = CorsCheckResult {
253 target: "mnw".to_string(),
254 url: "https://s3.example.com/bucket/test".to_string(),
255 origin: "https://makenot.work".to_string(),
256 method: "PUT".to_string(),
257 passes: true,
258 checked_at: "2026-03-28T00:00:00Z".to_string(),
259 error: None,
260 };
261 let json = serde_json::to_string(&result).unwrap();
262 let parsed: CorsCheckResult = serde_json::from_str(&json).unwrap();
263 assert_eq!(parsed.target, "mnw");
264 assert!(parsed.passes);
265 assert!(parsed.error.is_none());
266 }
267
268 #[test]
269 fn cors_check_result_with_error() {
270 let result = CorsCheckResult {
271 target: "mnw".to_string(),
272 url: "https://s3.example.com/bucket/test".to_string(),
273 origin: "https://makenot.work".to_string(),
274 method: "PUT".to_string(),
275 passes: false,
276 checked_at: "2026-03-28T00:00:00Z".to_string(),
277 error: Some("HTTP 403".to_string()),
278 };
279 let json = serde_json::to_string(&result).unwrap();
280 let parsed: CorsCheckResult = serde_json::from_str(&json).unwrap();
281 assert!(!parsed.passes);
282 assert_eq!(parsed.error.as_deref(), Some("HTTP 403"));
283 }
284 }
285