Skip to main content

api_scanner/scanner/
cors.rs

1// src/scanner/cors.rs
2
3use 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    // Keep probe set unique and randomize order to avoid deterministic fingerprints.
52    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    // Prefer OPTIONS probing (lower-impact than repeated GET requests).
82    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            // ── Wildcard with credentials (browser blocks, skip) ──────────────
139            if acao == Some("*") && acac == Some("true") {
140                continue;
141            }
142
143            // ── Wildcard without credentials (low severity) ────────────────────
144            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            // ── Test regex bypasses if origin reflected ───────────────────────
166            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            // ── Origin reflected ──────────────────────────────────────────────
242            if acao == Some(origin.as_str()) {
243                // Skip same-origin echoes; they are not exploitable via CORS.
244                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                // Missing Vary: Origin when reflecting origin can create cache leaks.
299                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        // Active preflight method exposure check (opt-in).
323        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}