1use async_trait::async_trait;
4use rand::seq::SliceRandom;
5use reqwest::header::{HeaderMap, HeaderValue};
6use std::collections::HashSet;
7
8use crate::{
9 config::Config,
10 error::CapturedError,
11 http_client::{HttpClient, HttpResponse},
12 reports::{Finding, Severity},
13};
14
15use super::Scanner;
16
17pub struct CorsScanner;
18
19impl CorsScanner {
20 pub fn new(_config: &Config) -> Self {
21 Self
22 }
23}
24
25static REGEX_BYPASS_SUFFIXES: &[&str] = &[".cdn-edge.net", "%60.cdn-edge.net"];
26static REGEX_BYPASS_PREFIXES: &[&str] = &["cdn", "img"];
27
28fn extract_domain_from_url(url: &str) -> Option<String> {
29 url.split("://")
30 .nth(1)?
31 .split('/')
32 .next()
33 .map(|s| s.to_string())
34}
35
36fn generate_probe_origins(url: &str) -> Vec<String> {
37 let mut origins = vec!["null".to_string(), "https://cdn.example.net".to_string()];
38
39 if let Some(domain) = extract_domain_from_url(url) {
40 let scheme = if url.starts_with("https://") {
41 "https"
42 } else {
43 "http"
44 };
45 origins.push(format!("{}://{}", scheme, domain));
46 origins.push(format!("{}://app.{}", scheme, domain));
47 origins.push(format!("{}://cdn.{}", scheme, domain));
48 origins.push(format!("{}://www.{}", scheme, domain));
49 }
50
51 let mut seen = HashSet::new();
53 origins.retain(|origin| seen.insert(origin.clone()));
54 if origins.len() > 1 {
55 let mut rng = rand::thread_rng();
56 origins.shuffle(&mut rng);
57 }
58
59 origins
60}
61
62async fn probe_cors_response(
63 client: &HttpClient,
64 url: &str,
65 origin: &str,
66) -> Result<HttpResponse, CapturedError> {
67 let mut preflight = HeaderMap::new();
68 let origin_header = HeaderValue::from_str(origin).map_err(|e| {
69 CapturedError::from_str(
70 "cors/probe",
71 Some(url.to_string()),
72 format!("Invalid Origin header value '{origin}': {e}"),
73 )
74 })?;
75 preflight.insert("Origin", origin_header);
76 preflight.insert(
77 "Access-Control-Request-Method",
78 HeaderValue::from_static("GET"),
79 );
80
81 if let Ok(resp) = client.options(url, Some(preflight)).await {
83 if resp.header("access-control-allow-origin").is_some() {
84 return Ok(resp);
85 }
86 }
87
88 let extra = [
89 ("Origin".to_string(), origin.to_string()),
90 (
91 "Access-Control-Request-Method".to_string(),
92 "GET".to_string(),
93 ),
94 ];
95 client.get_with_headers(url, &extra).await
96}
97
98#[async_trait]
99impl Scanner for CorsScanner {
100 fn name(&self) -> &'static str {
101 "cors"
102 }
103
104 async fn scan(
105 &self,
106 url: &str,
107 client: &HttpClient,
108 config: &Config,
109 ) -> (Vec<Finding>, Vec<CapturedError>) {
110 let mut findings = Vec::new();
111 let mut errors = Vec::new();
112 let mut regex_bypass_checked = false;
113
114 let probe_origins = generate_probe_origins(url);
115 let target_origin = extract_domain_from_url(url)
116 .map(|domain| {
117 let scheme = if url.starts_with("https://") {
118 "https"
119 } else {
120 "http"
121 };
122 format!("{}://{}", scheme, domain)
123 })
124 .unwrap_or_default();
125
126 for origin in &probe_origins {
127 let resp = match probe_cors_response(client, url, origin).await {
128 Ok(r) => r,
129 Err(e) => {
130 errors.push(e);
131 continue;
132 }
133 };
134
135 let acao = resp.header("access-control-allow-origin");
136 let acac = resp.header("access-control-allow-credentials");
137
138 if acao == Some("*") && acac == Some("true") {
140 continue;
141 }
142
143 if acao == Some("*") && acac != Some("true") {
145 findings.push(
146 Finding::new(
147 url,
148 "cors/wildcard-no-credentials",
149 "Wildcard CORS without credentials",
150 Severity::Low,
151 "ACAO header is '*' but credentials not allowed. Only exploitable if sensitive data exposed without auth.",
152 "cors",
153 )
154 .with_evidence(format!(
155 "Access-Control-Allow-Origin: *\nAccess-Control-Allow-Credentials: {}",
156 acac.unwrap_or("-")
157 ))
158 .with_remediation(
159 "If endpoint handles sensitive data, restrict CORS to specific trusted origins.",
160 ),
161 );
162 break;
163 }
164
165 if let Some(reflected) = acao {
167 if reflected == origin.as_str()
168 && origin != "null"
169 && (origin.starts_with("http://") || origin.starts_with("https://"))
170 && !regex_bypass_checked
171 {
172 regex_bypass_checked = true;
173 for suffix in REGEX_BYPASS_SUFFIXES {
174 let bypass = format!("{}{}", reflected, suffix);
175 match probe_cors_response(client, url, &bypass).await {
176 Ok(r) => {
177 if r.header("access-control-allow-origin") == Some(&bypass)
178 && r.header("access-control-allow-credentials") == Some("true")
179 {
180 findings.push(
181 Finding::new(
182 url,
183 "cors/regex-bypass-suffix",
184 "CORS regex bypass (suffix)",
185 Severity::High,
186 format!("Origin validation uses weak regex — attacker can bypass by appending: {}", bypass),
187 "cors",
188 )
189 .with_evidence(format!(
190 "Origin: {}\nAccess-Control-Allow-Origin: {}\nAccess-Control-Allow-Credentials: true",
191 bypass, bypass
192 ))
193 .with_remediation(
194 "Use exact domain matching or strict regex anchors (^https://trusted\\.com$).",
195 ),
196 );
197 break;
198 }
199 }
200 Err(e) => errors.push(e),
201 }
202 }
203
204 let (scheme, rest) = match reflected.split_once("://") {
205 Some((s, r)) => (s, r),
206 None => continue,
207 };
208 for prefix in REGEX_BYPASS_PREFIXES {
209 let bypass = format!("{}://{}{}", scheme, prefix, rest);
210 match probe_cors_response(client, url, &bypass).await {
211 Ok(r) => {
212 if r.header("access-control-allow-origin") == Some(&bypass)
213 && r.header("access-control-allow-credentials") == Some("true")
214 {
215 findings.push(
216 Finding::new(
217 url,
218 "cors/regex-bypass-prefix",
219 "CORS regex bypass (prefix)",
220 Severity::High,
221 format!("Origin validation uses weak regex — attacker can bypass by prepending: {}", bypass),
222 "cors",
223 )
224 .with_evidence(format!(
225 "Origin: {}\nAccess-Control-Allow-Origin: {}\nAccess-Control-Allow-Credentials: true",
226 bypass, bypass
227 ))
228 .with_remediation(
229 "Use exact domain matching or strict regex anchors (^https://trusted\\.com$).",
230 ),
231 );
232 break;
233 }
234 }
235 Err(e) => errors.push(e),
236 }
237 }
238 }
239 }
240
241 if acao == Some(origin.as_str()) {
243 if !target_origin.is_empty() && origin.as_str() == target_origin.as_str() {
245 continue;
246 }
247 if *origin == "null" {
248 findings.push(
249 Finding::new(
250 url,
251 "cors/null-origin",
252 "Null origin accepted",
253 Severity::Medium,
254 "Server accepts 'null' origin, exploitable from sandboxed iframes \
255 or local file:// contexts.",
256 "cors",
257 )
258 .with_evidence(format!(
259 "Origin: null\nAccess-Control-Allow-Origin: null\n\
260 Access-Control-Allow-Credentials: {}",
261 acac.unwrap_or("-"),
262 ))
263 .with_remediation(
264 "Explicitly disallow the 'null' origin and restrict CORS to known origins.",
265 ),
266 );
267 } else {
268 let creds = acac == Some("true");
269 findings.push(
270 Finding::new(
271 url,
272 "cors/reflected-origin",
273 "Reflected CORS origin",
274 if creds { Severity::High } else { Severity::Low },
275 if creds {
276 format!(
277 "Origin '{origin}' reflected with credentials allowed — \
278 potential credential theft via cross-origin request."
279 )
280 } else {
281 format!("Origin '{origin}' reflected (credentials not allowed).")
282 },
283 "cors",
284 )
285 .with_evidence(format!(
286 "Origin: {origin}\n\
287 Access-Control-Allow-Origin: {}\n\
288 Access-Control-Allow-Credentials: {}",
289 acao.unwrap_or("-"),
290 acac.unwrap_or("-"),
291 ))
292 .with_remediation(
293 "Validate origins against an allowlist and only enable credentials for trusted origins.",
294 ),
295 );
296 }
297
298 let vary = resp.header("vary").unwrap_or("");
300 if !vary.to_ascii_lowercase().contains("origin") {
301 findings.push(
302 Finding::new(
303 url,
304 "cors/missing-vary-origin",
305 "CORS reflection without Vary: Origin",
306 Severity::Low,
307 "Origin is reflected but the response lacks Vary: Origin, which can cause cache poisoning and cross-tenant leaks.",
308 "cors",
309 )
310 .with_evidence(format!(
311 "Origin: {origin}\nVary: {}",
312 if vary.is_empty() { "-" } else { vary }
313 ))
314 .with_remediation(
315 "Add `Vary: Origin` to responses that reflect the Origin header.",
316 ),
317 );
318 }
319 }
320 }
321
322 if config.active_checks {
324 let origin = "https://cdn.example.net";
325 let mut extra = reqwest::header::HeaderMap::new();
326 extra.insert("Origin", HeaderValue::from_static(origin));
327 extra.insert(
328 "Access-Control-Request-Method",
329 HeaderValue::from_static("DELETE"),
330 );
331 extra.insert(
332 "Access-Control-Request-Headers",
333 HeaderValue::from_static("authorization"),
334 );
335
336 match client.options(url, Some(extra)).await {
337 Ok(resp) => {
338 let acao = resp.header("access-control-allow-origin");
339 let acam = resp
340 .header("access-control-allow-methods")
341 .unwrap_or("")
342 .to_ascii_uppercase();
343 let allowed = acam.contains("DELETE") || acam.contains("*");
344
345 if allowed && (acao == Some("*") || acao == Some(origin)) {
346 findings.push(
347 Finding::new(
348 url,
349 "cors/preflight-unsafe-methods",
350 "CORS preflight allows unsafe methods",
351 Severity::Medium,
352 "Preflight response allows unsafe methods for a hostile origin.",
353 "cors",
354 )
355 .with_evidence(format!(
356 "Origin: {origin}\nAccess-Control-Allow-Origin: {}\nAccess-Control-Allow-Methods: {}",
357 acao.unwrap_or("-"),
358 if acam.is_empty() { "-" } else { &acam }
359 ))
360 .with_remediation(
361 "Restrict allowed methods in CORS responses and require authentication for dangerous verbs.",
362 ),
363 );
364 }
365 }
366 Err(e) => errors.push(e),
367 }
368 }
369
370 (findings, errors)
371 }
372}