Skip to main content

ssg/audit/gates/
csp_sri.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! CSP + SRI hash validation gate.
5//!
6//! Per page:
7//! - Asserts a `Content-Security-Policy` is present (either as a
8//!   `_headers` rule or a `<meta http-equiv="Content-Security-Policy">`
9//!   tag).
10//! - Asserts every cross-origin `<script src>` and
11//!   `<link rel="stylesheet" href>` carries an `integrity` SRI hash.
12//!
13//! The CSP-consistency check across pages is a warning (different
14//! pages with different policies are a smell but legitimate when a
15//! single section needs a stricter policy).
16
17use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
18use std::collections::HashSet;
19
20const NAME: &str = "csp_sri";
21
22/// CSP + SRI hash validation gate.
23///
24/// # Examples
25///
26/// ```
27/// use ssg::audit::AuditGate;
28/// use ssg::audit::gates::csp_sri::CspSriGate;
29/// assert_eq!(CspSriGate.name(), "csp_sri");
30/// ```
31#[derive(Debug, Clone, Copy)]
32pub struct CspSriGate;
33
34impl AuditGate for CspSriGate {
35    fn name(&self) -> &'static str {
36        NAME
37    }
38
39    fn explain(&self) -> &'static str {
40        "Asserts every page declares a Content-Security-Policy (via a \
41         <meta http-equiv=\"Content-Security-Policy\"> tag or a site \
42         _headers file) and that every cross-origin <script src> / \
43         <link rel=\"stylesheet\"> carries a valid `integrity` SRI \
44         attribute. Policy drift between pages is reported as a warn."
45    }
46
47    fn run(&self, site: &Site, _opts: &AuditOptions) -> Vec<Finding> {
48        let mut findings = Vec::new();
49        let mut policies: HashSet<String> = HashSet::new();
50
51        // Detect a site-level _headers file.
52        let site_headers = site.root.join("_headers");
53        let has_site_csp = site_headers.exists()
54            && std::fs::read_to_string(&site_headers).is_ok_and(|s| {
55                s.to_lowercase().contains("content-security-policy")
56            });
57
58        for path in &site.html_files {
59            let Ok(html) = site.read(path) else { continue };
60            let rel = site.rel(path);
61
62            let policy = extract_meta_csp(&html);
63            if !has_site_csp && policy.is_none() {
64                findings.push(
65                    Finding::new(
66                        NAME,
67                        Severity::Error,
68                        "Page has no Content-Security-Policy (no <meta http-equiv> and no _headers)",
69                    )
70                    .with_code("CSP-MISSING")
71                    .with_path(rel.clone()),
72                );
73            }
74            if let Some(p) = policy {
75                let _ = policies.insert(p);
76            }
77
78            for asset in extract_remote_assets(&html) {
79                if asset.integrity.is_none() {
80                    findings.push(
81                        Finding::new(
82                            NAME,
83                            Severity::Error,
84                            format!(
85                                "{} {} missing SRI `integrity` attribute",
86                                asset.kind, asset.href
87                            ),
88                        )
89                        .with_code("SRI-MISSING")
90                        .with_path(rel.clone()),
91                    );
92                }
93            }
94        }
95
96        if policies.len() > 1 {
97            findings.push(
98                Finding::new(
99                    NAME,
100                    Severity::Warn,
101                    format!(
102                        "Found {} distinct CSP policies across pages",
103                        policies.len()
104                    ),
105                )
106                .with_code("CSP-DRIFT"),
107            );
108        }
109
110        findings
111    }
112}
113
114fn extract_meta_csp(html: &str) -> Option<String> {
115    let lower = html.to_ascii_lowercase();
116    let needle = "<meta";
117    let mut cursor = 0;
118    while let Some(rel) = lower[cursor..].find(needle) {
119        let abs = cursor + rel;
120        let end = super::find_tag_end(html, abs);
121        let tag = &html[abs..end];
122        cursor = end;
123        // Attribute-based match: tolerant of quoting style, case, and
124        // attribute order — minifiers emit unquoted
125        // `http-equiv=Content-Security-Policy`.
126        let is_csp = super::hreflang_attr(tag, "http-equiv")
127            .is_some_and(|v| v.eq_ignore_ascii_case("content-security-policy"));
128        if is_csp {
129            if let Some(c) = super::hreflang_attr(tag, "content") {
130                return Some(c);
131            }
132        }
133    }
134    None
135}
136
137struct RemoteAsset {
138    kind: &'static str,
139    href: String,
140    integrity: Option<String>,
141}
142
143fn extract_remote_assets(html: &str) -> Vec<RemoteAsset> {
144    let mut out = Vec::new();
145    let lower = html.to_lowercase();
146
147    let mut cursor = 0;
148    while let Some(rel) = lower[cursor..].find("<script") {
149        let abs = cursor + rel;
150        let end = super::find_tag_end(html, abs);
151        let tag = &html[abs..end];
152        cursor = end;
153        let Some(src) = super::hreflang_attr(tag, "src") else {
154            continue;
155        };
156        if !is_remote(&src) {
157            continue;
158        }
159        out.push(RemoteAsset {
160            kind: "<script src=>",
161            href: src,
162            integrity: super::hreflang_attr(tag, "integrity"),
163        });
164    }
165
166    let mut cursor = 0;
167    while let Some(rel) = lower[cursor..].find("<link") {
168        let abs = cursor + rel;
169        let end = super::find_tag_end(html, abs);
170        let tag = &html[abs..end];
171        cursor = end;
172        // `rel` may be unquoted (minified) and space-separated
173        // (`rel="stylesheet preload"`).
174        let is_stylesheet = super::hreflang_attr(tag, "rel").is_some_and(|r| {
175            r.split_ascii_whitespace()
176                .any(|t| t.eq_ignore_ascii_case("stylesheet"))
177        });
178        if !is_stylesheet {
179            continue;
180        }
181        let Some(href) = super::hreflang_attr(tag, "href") else {
182            continue;
183        };
184        if !is_remote(&href) {
185            continue;
186        }
187        out.push(RemoteAsset {
188            kind: "<link rel=stylesheet>",
189            href,
190            integrity: super::hreflang_attr(tag, "integrity"),
191        });
192    }
193
194    out
195}
196
197fn is_remote(href: &str) -> bool {
198    href.starts_with("http://")
199        || href.starts_with("https://")
200        || href.starts_with("//")
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn site_with(pages: &[(&str, &str)]) -> Site {
208        let tmp = tempfile::tempdir().unwrap();
209        let root = tmp.path().to_path_buf();
210        let mut files = Vec::new();
211        for (rel, html) in pages {
212            let p = root.join(rel);
213            // root.join(rel) always has a parent directory.
214            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
215            std::fs::write(&p, html).unwrap();
216            files.push(p);
217        }
218        std::mem::forget(tmp);
219        Site {
220            root,
221            html_files: files,
222        }
223    }
224
225    #[test]
226    fn page_with_csp_and_sri_is_clean() {
227        let html = r#"<html><head>
228            <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
229            <script src="https://cdn.example/x.js" integrity="sha256-abc"></script>
230            <link rel="stylesheet" href="/local.css">
231        </head><body></body></html>"#;
232        let s = site_with(&[("index.html", html)]);
233        let f = CspSriGate.run(&s, &AuditOptions::default());
234        assert!(f.is_empty(), "got {f:?}");
235    }
236
237    #[test]
238    fn page_missing_csp_and_sri_flagged() {
239        let html = r#"<html><head>
240            <script src="https://cdn.example/x.js"></script>
241        </head><body></body></html>"#;
242        let s = site_with(&[("index.html", html)]);
243        let f = CspSriGate.run(&s, &AuditOptions::default());
244        assert!(f.iter().any(|x| x.code.as_deref() == Some("CSP-MISSING")));
245        assert!(f.iter().any(|x| x.code.as_deref() == Some("SRI-MISSING")));
246    }
247
248    #[test]
249    fn site_headers_csp_satisfies_requirement() {
250        let html = r#"<html><head>
251            <script src="https://cdn.example/x.js" integrity="sha256-z"></script>
252        </head><body></body></html>"#;
253        let tmp = tempfile::tempdir().unwrap();
254        let root = tmp.path().to_path_buf();
255        std::fs::write(
256            root.join("_headers"),
257            "/*\n  Content-Security-Policy: default-src 'self'\n",
258        )
259        .unwrap();
260        let p = root.join("index.html");
261        std::fs::write(&p, html).unwrap();
262        let s = Site {
263            root,
264            html_files: vec![p],
265        };
266        std::mem::forget(tmp);
267        let f = CspSriGate.run(&s, &AuditOptions::default());
268        assert!(f.is_empty(), "got {f:?}");
269    }
270
271    #[test]
272    fn protocol_relative_stylesheet_needs_integrity() {
273        let html = r#"<html><head>
274            <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
275            <link rel="stylesheet" href="//cdn.example/styles.css">
276        </head><body></body></html>"#;
277        let s = site_with(&[("index.html", html)]);
278        let f = CspSriGate.run(&s, &AuditOptions::default());
279        assert!(f.iter().any(|x| x.code.as_deref() == Some("SRI-MISSING")));
280    }
281
282    #[test]
283    fn local_assets_dont_need_integrity() {
284        let html = r#"<html><head>
285            <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
286            <script src="/local.js"></script>
287            <link rel="stylesheet" href="/local.css">
288        </head><body></body></html>"#;
289        let s = site_with(&[("index.html", html)]);
290        let f = CspSriGate.run(&s, &AuditOptions::default());
291        assert!(
292            f.is_empty(),
293            "local assets should not require SRI, got {f:?}"
294        );
295    }
296
297    #[test]
298    fn http_remote_script_needs_integrity() {
299        let html = r#"<html><head>
300            <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
301            <script src="http://cdn.example/x.js"></script>
302        </head><body></body></html>"#;
303        let s = site_with(&[("index.html", html)]);
304        let f = CspSriGate.run(&s, &AuditOptions::default());
305        assert!(f.iter().any(|x| x.code.as_deref() == Some("SRI-MISSING")));
306    }
307
308    #[test]
309    fn csp_drift_warns() {
310        let a = r#"<html><head>
311            <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
312        </head><body></body></html>"#;
313        let b = r#"<html><head>
314            <meta http-equiv="Content-Security-Policy" content="default-src 'self' https:">
315        </head><body></body></html>"#;
316        let s = site_with(&[("a.html", a), ("b.html", b)]);
317        let f = CspSriGate.run(&s, &AuditOptions::default());
318        assert!(
319            f.iter().any(|x| x.code.as_deref() == Some("CSP-DRIFT")),
320            "expected CSP-DRIFT, got {f:?}"
321        );
322    }
323
324    #[test]
325    fn script_without_src_skipped() {
326        let html = r#"<html><head>
327            <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
328            <script>console.log('inline');</script>
329        </head><body></body></html>"#;
330        let s = site_with(&[("index.html", html)]);
331        let f = CspSriGate.run(&s, &AuditOptions::default());
332        assert!(f.is_empty(), "inline script should not need SRI, got {f:?}");
333    }
334
335    #[test]
336    fn link_non_stylesheet_skipped() {
337        let html = r#"<html><head>
338            <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
339            <link rel="preconnect" href="https://cdn.example">
340        </head><body></body></html>"#;
341        let s = site_with(&[("index.html", html)]);
342        let f = CspSriGate.run(&s, &AuditOptions::default());
343        assert!(f.is_empty(), "preconnect should not need SRI, got {f:?}");
344    }
345
346    #[test]
347    fn single_quoted_stylesheet_recognised() {
348        let html = r#"<html><head>
349            <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
350            <link rel='stylesheet' href='https://cdn.example/s.css'>
351        </head><body></body></html>"#;
352        let s = site_with(&[("index.html", html)]);
353        let f = CspSriGate.run(&s, &AuditOptions::default());
354        assert!(f.iter().any(|x| x.code.as_deref() == Some("SRI-MISSING")));
355    }
356
357    #[test]
358    fn minified_unquoted_csp_meta_recognised() {
359        // Regression: minified HTML emits unquoted attribute values in
360        // original case; the gate must not report CSP-MISSING.
361        // The SRI-less remote script keeps `f` non-empty so the
362        // no-CSP-MISSING predicate actually evaluates.
363        let html = "<html><head>\
364            <meta content=\"default-src 'self'\" http-equiv=Content-Security-Policy>\
365            <script src=\"https://cdn.example/x.js\"></script>\
366        </head><body></body></html>";
367        let s = site_with(&[("index.html", html)]);
368        let f = CspSriGate.run(&s, &AuditOptions::default());
369        assert!(
370            f.iter().all(|x| x.code.as_deref() != Some("CSP-MISSING")),
371            "unquoted http-equiv must count: {f:?}"
372        );
373    }
374
375    #[test]
376    fn other_http_equiv_meta_does_not_count_as_csp() {
377        // True positive preserved: a page whose only http-equiv is
378        // something else still has no CSP.
379        let html = "<html><head>\
380            <meta http-equiv=X-UA-Compatible content=\"IE=edge\">\
381        </head><body></body></html>";
382        let s = site_with(&[("index.html", html)]);
383        let f = CspSriGate.run(&s, &AuditOptions::default());
384        assert!(
385            f.iter().any(|x| x.code.as_deref() == Some("CSP-MISSING")),
386            "non-CSP http-equiv must still flag: {f:?}"
387        );
388    }
389
390    #[test]
391    fn minified_unquoted_stylesheet_rel_needs_integrity() {
392        let html = "<html><head>\
393            <meta http-equiv=Content-Security-Policy content=\"default-src 'self'\">\
394            <link href=https://cdn.example/s.css rel=stylesheet>\
395        </head><body></body></html>";
396        let s = site_with(&[("index.html", html)]);
397        let f = CspSriGate.run(&s, &AuditOptions::default());
398        assert!(
399            f.iter().any(|x| x.code.as_deref() == Some("SRI-MISSING")),
400            "unquoted rel=stylesheet must be seen: {f:?}"
401        );
402    }
403
404    #[test]
405    fn metadata_methods_exposed() {
406        let g = CspSriGate;
407        assert_eq!(g.name(), "csp_sri");
408        assert!(g.explain().contains("Content-Security-Policy"));
409        let _copy: CspSriGate = g;
410        let _clone = g;
411        let dbg = format!("{g:?}");
412        assert!(dbg.contains("CspSriGate"));
413    }
414
415    #[test]
416    fn empty_site_returns_no_findings() {
417        let s = site_with(&[]);
418        let f = CspSriGate.run(&s, &AuditOptions::default());
419        assert!(f.is_empty());
420    }
421
422    #[test]
423    fn meta_csp_without_content_attr_yields_none() {
424        // http-equiv matches but there is no content= value to return;
425        // the scanner keeps looking and ends with None.
426        let html = r#"<meta http-equiv="Content-Security-Policy">"#;
427        assert_eq!(extract_meta_csp(html), None);
428    }
429
430    #[test]
431    fn stylesheet_link_without_href_is_skipped() {
432        let html = r#"<link rel="stylesheet"><link rel="stylesheet" href="https://cdn.example/a.css">"#;
433        let assets = extract_remote_assets(html);
434        assert_eq!(assets.len(), 1, "href-less link must be skipped");
435        assert_eq!(assets[0].href, "https://cdn.example/a.css");
436    }
437
438    #[test]
439    fn unreadable_html_skipped() {
440        let tmp = tempfile::tempdir().unwrap();
441        let root = tmp.path().to_path_buf();
442        let dir_as_file = root.join("page.html");
443        std::fs::create_dir_all(&dir_as_file).unwrap();
444        let s = Site {
445            root,
446            html_files: vec![dir_as_file],
447        };
448        let _ = CspSriGate.run(&s, &AuditOptions::default());
449        std::mem::forget(tmp);
450    }
451}