Skip to main content

api_scanner/scanner/
csp.rs

1// src/scanner/csp.rs
2
3use async_trait::async_trait;
4use once_cell::sync::Lazy;
5use regex::Regex;
6
7use crate::{
8    config::Config,
9    error::CapturedError,
10    http_client::HttpClient,
11    reports::{Finding, Severity},
12};
13
14use super::Scanner;
15
16pub struct CspScanner;
17
18impl CspScanner {
19    pub fn new(_config: &Config) -> Self {
20        Self
21    }
22}
23
24// Directives that must exist for a meaningful CSP
25static REQUIRED_DIRECTIVES: &[&str] = &["default-src", "script-src", "object-src", "base-uri"];
26
27// Source expressions that trivially bypass script restrictions
28static UNSAFE_SOURCES: &[(&str, &str)] = &[
29    (
30        "'unsafe-inline'",
31        "Allows inline scripts/styles — XSS mitigation lost.",
32    ),
33    (
34        "'unsafe-eval'",
35        "Allows eval() — bypasses script-src restrictions.",
36    ),
37    (
38        "'unsafe-hashes'",
39        "Allows execution of hashed inline handlers.",
40    ),
41    (
42        "data:",
43        "'data:' URI in script context allows arbitrary script execution.",
44    ),
45    ("http:", "Plain HTTP source allows MITM script injection."),
46    ("*", "Wildcard source allows loading from any host."),
47];
48
49// CDN / JSONP-enabled hosts well-known for bypass gadgets
50static BYPASS_HOSTS: Lazy<Vec<Regex>> = Lazy::new(|| {
51    [
52        r"(?i)^(?:https?://)?cdn\.cloudflare\.com(?:/[^\s]*)?$",
53        r"(?i)^(?:https?://)?ajax\.googleapis\.com(?:/[^\s]*)?$",
54        r"(?i)^(?:https?://)?cdnjs\.cloudflare\.com(?:/[^\s]*)?$",
55        r"(?i)^(?:https?://)?cdn\.jsdelivr\.net(?:/[^\s]*)?$",
56        r"(?i)^(?:https?://)?unpkg\.com(?:/[^\s]*)?$",
57        r"(?i)^(?:https?://)?rawgit\.com(?:/[^\s]*)?$",
58        r"(?i)^(?:https?://)?raw\.githubusercontent\.com(?:/[^\s]*)?$",
59        r"(?i)^(?:https?://)?stackpath\.bootstrapcdn\.com(?:/[^\s]*)?$",
60        r"(?i)^(?:https?://)?code\.jquery\.com(?:/[^\s]*)?$",
61        r"(?i)^(?:https?://)?yandex\.st(?:/[^\s]*)?$",
62        r"(?i)^(?:https?://)?api\.twitter\.com(?:/[^\s]*)?$",
63        r"(?i)^(?:https?://)?platform\.twitter\.com(?:/[^\s]*)?$",
64    ]
65    .iter()
66    .map(|p| Regex::new(p).unwrap())
67    .collect()
68});
69
70#[async_trait]
71impl Scanner for CspScanner {
72    fn name(&self) -> &'static str {
73        "csp"
74    }
75
76    async fn scan(
77        &self,
78        url: &str,
79        client: &HttpClient,
80        _config: &Config,
81    ) -> (Vec<Finding>, Vec<CapturedError>) {
82        let mut findings = Vec::new();
83        let mut errors = Vec::new();
84
85        let resp = match client.get(url).await {
86            Ok(r) => r,
87            Err(e) => {
88                errors.push(e);
89                return (findings, errors);
90            }
91        };
92
93        // ── Header presence ───────────────────────────────────────────────────
94        let csp_value = match resp.header("content-security-policy") {
95            Some(v) => v.to_string(),
96            None => {
97                // Check report-only as informational
98                if let Some(ro) = resp.header("content-security-policy-report-only") {
99                    findings.push(Finding::new(
100                        url,
101                        "csp/report-only",
102                        "CSP Report-Only",
103                        Severity::Info,
104                        "Only CSP Report-Only header present; policy is not enforced.",
105                        "csp",
106                    )
107                    .with_evidence(format!(
108                        "Content-Security-Policy-Report-Only: {ro}"
109                    ))
110                    .with_remediation(
111                        "Deploy an enforcing Content-Security-Policy header after validating reports.",
112                    ));
113                } else {
114                    findings.push(
115                        Finding::new(
116                            url,
117                            "csp/missing",
118                            "No CSP header",
119                            Severity::Info,
120                            "No Content-Security-Policy header detected. CSP is a defense-in-depth mechanism.",
121                            "csp",
122                        )
123                        .with_remediation(
124                            "Add a Content-Security-Policy header with least-privilege sources.",
125                        ),
126                    );
127                }
128                return (findings, errors);
129            }
130        };
131
132        // ── Parse directives into a map ───────────────────────────────────────
133        let directives = parse_csp(&csp_value);
134
135        // ── Missing required directives ───────────────────────────────────────
136        for req in REQUIRED_DIRECTIVES {
137            if !directives.contains_key(*req) {
138                // Missing directives are informational - not exploitable alone
139                let severity = match *req {
140                    "default-src" | "script-src" => Severity::Low, // More important but still not exploitable
141                    _ => Severity::Info, // Other directives are nice-to-have
142                };
143                findings.push(
144                    Finding::new(
145                        url,
146                        format!("csp/missing-directive/{req}"),
147                        format!("CSP missing '{req}'"),
148                        severity,
149                        format!("CSP is missing the '{req}' directive. Not exploitable without an injection vulnerability."),
150                        "csp",
151                    )
152                    .with_evidence(format!("Content-Security-Policy: {csp_value}"))
153                    .with_remediation(format!(
154                        "Add the '{req}' directive with a restrictive allowlist."
155                    )),
156                );
157            }
158        }
159
160        // ── Unsafe source values ──────────────────────────────────────────────
161        let script_sources = directives
162            .get("script-src")
163            .or_else(|| directives.get("default-src"))
164            .cloned()
165            .unwrap_or_default();
166
167        for (token, desc) in UNSAFE_SOURCES {
168            if script_sources.iter().any(|s| s.eq_ignore_ascii_case(token)) {
169                // CSP weaknesses are not exploitable alone - downgrade to Low/Info
170                let severity = match *token {
171                    "*" => Severity::Medium, // Wildcard is worse - allows any domain
172                    "'unsafe-inline'" | "'unsafe-eval'" => Severity::Low, // Common but needs XSS to exploit
173                    _ => Severity::Info, // Other unsafe sources are informational
174                };
175
176                findings.push(
177                    Finding::new(
178                        url,
179                        format!("csp/unsafe-source/{}", token.trim_matches('\'')),
180                        format!("CSP unsafe source: {token}"),
181                        severity,
182                        format!("script-src contains '{token}': {desc} Note: Not exploitable without an injection vulnerability."),
183                        "csp",
184                    )
185                    .with_evidence(format!("Content-Security-Policy: {csp_value}"))
186                    .with_remediation(
187                        "Remove unsafe script sources and use nonces or hashes for inline scripts.",
188                    ),
189                );
190            }
191        }
192
193        // ── Known bypass-gadget CDN hosts ─────────────────────────────────────
194        for source in &script_sources {
195            for re in BYPASS_HOSTS.iter() {
196                if re.is_match(source) {
197                    findings.push(
198                        Finding::new(
199                            url,
200                            "csp/bypassable-cdn",
201                            "CSP bypassable CDN",
202                            Severity::Medium,
203                            format!(
204                                "script-src allows '{source}', which hosts JSONP endpoints or \
205                             third-party scripts that can bypass CSP."
206                            ),
207                            "csp",
208                        )
209                        .with_evidence(format!("Content-Security-Policy: {csp_value}"))
210                        .with_remediation(
211                            "Pin scripts with subresource integrity or self-host critical assets.",
212                        ),
213                    );
214                    break;
215                }
216            }
217        }
218
219        // ── Frame ancestors / clickjacking ────────────────────────────────────
220        if !directives.contains_key("frame-ancestors") {
221            findings.push(Finding::new(
222                url,
223                "csp/missing-frame-ancestors",
224                "CSP missing frame-ancestors",
225                Severity::Low,
226                "CSP lacks 'frame-ancestors' directive (clickjacking protection).",
227                "csp",
228            )
229            .with_evidence(format!("Content-Security-Policy: {csp_value}"))
230            .with_remediation(
231                "Add 'frame-ancestors' with a strict allowlist (or 'none') to prevent clickjacking.",
232            ));
233        }
234
235        (findings, errors)
236    }
237}
238
239// ── CSP parser ────────────────────────────────────────────────────────────────
240
241/// Returns a map of `directive_name → [source, ...]`.
242fn parse_csp(header: &str) -> std::collections::HashMap<String, Vec<String>> {
243    let mut map = std::collections::HashMap::new();
244
245    for directive in header.split(';') {
246        let directive = directive.trim();
247        if directive.is_empty() {
248            continue;
249        }
250        let mut parts = directive.splitn(2, char::is_whitespace);
251        let name = parts.next().unwrap_or("").trim().to_ascii_lowercase();
252        let sources: Vec<String> = parts
253            .next()
254            .unwrap_or("")
255            .split_whitespace()
256            .map(|s| s.to_string())
257            .collect();
258
259        map.insert(name, sources);
260    }
261
262    map
263}