1use std::time::Duration;
33
34use serde::{Deserialize, Serialize};
35
36use crate::error::Result;
37use crate::http::{FetchedResponse, GuardedFetcher};
38use crate::validation::normalize_host;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "kebab-case")]
46pub enum HeaderVerdict {
47 Absent,
49 Weak,
51 Moderate,
53 Strict,
55 Present,
57}
58
59impl HeaderVerdict {
60 fn score_pct(self) -> u32 {
63 match self {
64 HeaderVerdict::Strict | HeaderVerdict::Present => 100,
65 HeaderVerdict::Moderate => 60,
66 HeaderVerdict::Weak => 30,
67 HeaderVerdict::Absent => 0,
68 }
69 }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct HeaderFinding {
75 pub header: String,
77 pub present: bool,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub value: Option<String>,
81 pub verdict: HeaderVerdict,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub note: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct CookieFinding {
90 pub name: String,
91 pub secure: bool,
92 pub http_only: bool,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub same_site: Option<String>,
95 pub verdict: HeaderVerdict,
96 #[serde(skip_serializing_if = "Vec::is_empty")]
98 pub issues: Vec<String>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct Disclosure {
104 pub header: String,
105 pub value: String,
106 pub versioned: bool,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct HeaderReport {
114 pub domain: String,
115 pub url: String,
117 pub status: u16,
118 pub redirects: usize,
120 pub grade: String,
122 pub score: u32,
124 pub headers: Vec<HeaderFinding>,
125 #[serde(skip_serializing_if = "Vec::is_empty")]
126 pub cookies: Vec<CookieFinding>,
127 #[serde(skip_serializing_if = "Vec::is_empty")]
128 pub disclosures: Vec<Disclosure>,
129 pub notes: Vec<String>,
131}
132
133pub const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(10);
136
137const HEADER_WEIGHTS: &[(&str, u32)] = &[
145 ("strict-transport-security", 20),
146 ("content-security-policy", 20),
147 ("x-frame-options", 15),
148 ("x-content-type-options", 10),
149 ("referrer-policy", 10),
150 ("permissions-policy", 10),
151 ("cross-origin-opener-policy", 5),
152 ("cross-origin-embedder-policy", 5),
153 ("cross-origin-resource-policy", 5),
154];
155
156const HSTS_STRONG_MAX_AGE: u64 = 31_536_000;
159const HSTS_MODERATE_MAX_AGE: u64 = 15_552_000;
162
163const COOKIE_PENALTY: u32 = 4;
165const COOKIE_PENALTY_CAP: u32 = 12;
166const DISCLOSURE_PENALTY: u32 = 3;
168const DISCLOSURE_PENALTY_CAP: u32 = 6;
169
170fn parse_attributes(value: &str) -> Vec<(String, String)> {
176 value
177 .split(';')
178 .filter_map(|part| {
179 let part = part.trim();
180 if part.is_empty() {
181 return None;
182 }
183 Some(match part.split_once('=') {
184 Some((k, v)) => (k.trim().to_ascii_lowercase(), v.trim().to_string()),
185 None => (part.to_ascii_lowercase(), String::new()),
186 })
187 })
188 .collect()
189}
190
191fn attr<'a>(attrs: &'a [(String, String)], key: &str) -> Option<&'a str> {
193 attrs
194 .iter()
195 .find(|(k, _)| k == key)
196 .map(|(_, v)| v.as_str())
197}
198
199fn grade_hsts(value: Option<&str>, over_https: bool) -> (HeaderVerdict, Option<String>) {
200 let Some(value) = value else {
201 return (
202 HeaderVerdict::Absent,
203 Some("Add Strict-Transport-Security to stop protocol-downgrade and cookie-stripping attacks.".into()),
204 );
205 };
206 if !over_https {
209 return (
210 HeaderVerdict::Absent,
211 Some("Strict-Transport-Security was served over plain HTTP, where browsers ignore it; serve the final page over HTTPS.".into()),
212 );
213 }
214 let attrs = parse_attributes(value);
215 let max_age: Option<u64> =
217 attr(&attrs, "max-age").and_then(|v| v.trim().trim_matches('"').parse().ok());
218 let include_subdomains = attr(&attrs, "includesubdomains").is_some();
219
220 match max_age {
221 None | Some(0) => (
222 HeaderVerdict::Weak,
223 Some("max-age is missing or zero, which disables HSTS entirely.".into()),
224 ),
225 Some(age) if age >= HSTS_STRONG_MAX_AGE && include_subdomains => {
226 (HeaderVerdict::Strict, None)
227 }
228 Some(age) if age >= HSTS_STRONG_MAX_AGE => (
229 HeaderVerdict::Moderate,
230 Some("max-age is strong but includeSubDomains is missing — subdomains stay downgradable.".into()),
231 ),
232 Some(age) if age >= HSTS_MODERATE_MAX_AGE => (
233 HeaderVerdict::Moderate,
234 Some(format!(
235 "max-age={age} is below the 31536000 (1 year) that preload requires."
236 )),
237 ),
238 Some(age) => (
239 HeaderVerdict::Weak,
240 Some(format!(
241 "max-age={age} is a short window; use at least 15552000 (6 months)."
242 )),
243 ),
244 }
245}
246
247type CspPolicy = Vec<(String, Vec<String>)>;
249
250fn parse_csp_policies<'a>(values: impl IntoIterator<Item = &'a str>) -> Vec<CspPolicy> {
258 values
259 .into_iter()
260 .flat_map(|v| v.split(','))
261 .map(|policy| {
262 policy
263 .split(';')
264 .filter_map(|directive| {
265 let mut tokens = directive.split_ascii_whitespace();
266 let name = tokens.next()?.to_ascii_lowercase();
267 Some((name, tokens.map(str::to_ascii_lowercase).collect()))
268 })
269 .collect::<CspPolicy>()
270 })
271 .filter(|p| !p.is_empty())
272 .collect()
273}
274
275fn csp_directive<'a>(policy: &'a CspPolicy, name: &str) -> Option<&'a [String]> {
276 policy
277 .iter()
278 .find(|(n, _)| n == name)
279 .map(|(_, sources)| sources.as_slice())
280}
281
282enum ScriptControl {
284 None,
286 Unsafe(Vec<&'static str>),
288 Safe,
290}
291
292fn script_control(policy: &CspPolicy) -> ScriptControl {
293 let Some(sources) =
295 csp_directive(policy, "script-src").or_else(|| csp_directive(policy, "default-src"))
296 else {
297 return ScriptControl::None;
298 };
299 let has = |t: &str| sources.iter().any(|s| s == t);
300 let inline_neutralized = has("'strict-dynamic'")
304 || sources.iter().any(|s| {
305 s.starts_with("'nonce-")
306 || s.starts_with("'sha256-")
307 || s.starts_with("'sha384-")
308 || s.starts_with("'sha512-")
309 });
310 let mut unsafe_kw = Vec::new();
311 if has("'unsafe-inline'") && !inline_neutralized {
312 unsafe_kw.push("'unsafe-inline'");
313 }
314 if has("'unsafe-eval'") {
315 unsafe_kw.push("'unsafe-eval'");
316 }
317 if unsafe_kw.is_empty() {
318 ScriptControl::Safe
319 } else {
320 ScriptControl::Unsafe(unsafe_kw)
321 }
322}
323
324fn grade_csp(policies: &[CspPolicy], report_only: bool) -> (HeaderVerdict, Option<String>) {
325 if policies.is_empty() {
326 if report_only {
327 return (
328 HeaderVerdict::Weak,
329 Some("Only Content-Security-Policy-Report-Only is set — it reports violations but blocks nothing.".into()),
330 );
331 }
332 return (
333 HeaderVerdict::Absent,
334 Some(
335 "Add Content-Security-Policy — it is the primary defense against injected script."
336 .into(),
337 ),
338 );
339 };
340
341 let controls: Vec<ScriptControl> = policies.iter().map(script_control).collect();
344 if controls.iter().any(|c| matches!(c, ScriptControl::Safe)) {
345 return (HeaderVerdict::Strict, None);
346 }
347
348 let mut which: Vec<&str> = Vec::new();
349 for control in &controls {
350 if let ScriptControl::Unsafe(kw) = control {
351 for k in kw {
352 if !which.contains(k) {
353 which.push(k);
354 }
355 }
356 }
357 }
358 if !which.is_empty() {
359 return (
360 HeaderVerdict::Weak,
361 Some(format!(
362 "Script policy allows {} — this reopens the injection hole CSP exists to close.",
363 which.join(" and ")
364 )),
365 );
366 }
367
368 (
369 HeaderVerdict::Moderate,
370 Some(
371 "Policy sets neither default-src nor script-src, so script loading is unrestricted."
372 .into(),
373 ),
374 )
375}
376
377fn grade_frame_options(
378 value: Option<&str>,
379 csp_policies: &[CspPolicy],
380) -> (HeaderVerdict, Option<String>) {
381 let csp_frame_ancestors = csp_policies
385 .iter()
386 .any(|p| csp_directive(p, "frame-ancestors").is_some());
387
388 let Some(value) = value else {
389 if csp_frame_ancestors {
390 return (
391 HeaderVerdict::Present,
392 Some(
393 "No X-Frame-Options, but CSP frame-ancestors is set, which supersedes it."
394 .into(),
395 ),
396 );
397 }
398 return (
399 HeaderVerdict::Absent,
400 Some(
401 "Add X-Frame-Options (or CSP frame-ancestors) to prevent clickjacking via framing."
402 .into(),
403 ),
404 );
405 };
406
407 match value.trim().to_ascii_uppercase().as_str() {
408 "DENY" | "SAMEORIGIN" => (HeaderVerdict::Strict, None),
409 other if csp_frame_ancestors => (
412 HeaderVerdict::Present,
413 Some(format!(
414 "X-Frame-Options '{other}' is not a valid value, but CSP frame-ancestors is set and supersedes it."
415 )),
416 ),
417 v if v.starts_with("ALLOW-FROM") => (
418 HeaderVerdict::Weak,
419 Some(
420 "ALLOW-FROM is obsolete and ignored by modern browsers; use CSP frame-ancestors."
421 .into(),
422 ),
423 ),
424 other => (
425 HeaderVerdict::Weak,
426 Some(format!(
427 "Unrecognized value '{other}' — use DENY or SAMEORIGIN."
428 )),
429 ),
430 }
431}
432
433fn grade_content_type_options(value: Option<&str>) -> (HeaderVerdict, Option<String>) {
434 match value.map(|v| v.trim().to_ascii_lowercase()) {
435 None => (
436 HeaderVerdict::Absent,
437 Some(
438 "Add X-Content-Type-Options: nosniff to stop MIME-sniffing of user-supplied files."
439 .into(),
440 ),
441 ),
442 Some(ref v) if v == "nosniff" => (HeaderVerdict::Strict, None),
443 Some(other) => (
444 HeaderVerdict::Weak,
445 Some(format!(
446 "Value '{other}' is not recognized; the only valid value is nosniff."
447 )),
448 ),
449 }
450}
451
452fn grade_referrer_policy(value: Option<&str>) -> (HeaderVerdict, Option<String>) {
453 let Some(value) = value else {
454 return (
455 HeaderVerdict::Absent,
456 Some(
457 "Add Referrer-Policy so URLs (and any secrets in them) do not leak cross-origin."
458 .into(),
459 ),
460 );
461 };
462 let strict = [
463 "no-referrer",
464 "same-origin",
465 "strict-origin",
466 "strict-origin-when-cross-origin",
467 ];
468 let moderate = [
469 "origin",
470 "origin-when-cross-origin",
471 "no-referrer-when-downgrade",
472 ];
473
474 let effective = value
478 .split(',')
479 .map(|t| t.trim().to_ascii_lowercase())
480 .rev()
481 .find(|t| {
482 strict.contains(&t.as_str()) || moderate.contains(&t.as_str()) || t == "unsafe-url"
483 });
484
485 if effective.as_deref().is_some_and(|t| strict.contains(&t)) {
486 (HeaderVerdict::Strict, None)
487 } else if effective.as_deref().is_some_and(|t| moderate.contains(&t)) {
488 (
489 HeaderVerdict::Moderate,
490 Some("Policy still sends the origin cross-site; strict-origin-when-cross-origin is tighter.".into()),
491 )
492 } else if effective.as_deref() == Some("unsafe-url") {
493 (
494 HeaderVerdict::Weak,
495 Some(
496 "unsafe-url sends the full URL to every destination, including over plain HTTP."
497 .into(),
498 ),
499 )
500 } else {
501 (
502 HeaderVerdict::Weak,
503 Some(format!("Unrecognized policy '{}'.", value.trim())),
504 )
505 }
506}
507
508fn grade_presence(value: Option<&str>, advice: &str) -> (HeaderVerdict, Option<String>) {
510 match value {
511 Some(v) if !v.trim().is_empty() => (HeaderVerdict::Present, None),
512 _ => (HeaderVerdict::Absent, Some(advice.to_string())),
513 }
514}
515
516fn grade_enum(
518 value: Option<&str>,
519 table: &[(&str, HeaderVerdict)],
520 absent_advice: &str,
521) -> (HeaderVerdict, Option<String>) {
522 let Some(value) = value else {
523 return (HeaderVerdict::Absent, Some(absent_advice.to_string()));
524 };
525 let v = value
528 .split(';')
529 .next()
530 .unwrap_or_default()
531 .trim()
532 .to_ascii_lowercase();
533 match table.iter().find(|(k, _)| *k == v) {
534 Some((_, verdict)) => (*verdict, None),
535 None => (
536 HeaderVerdict::Weak,
537 Some(format!("Unrecognized value '{}'.", value.trim())),
538 ),
539 }
540}
541
542fn grade_cookies(cookies: &[String]) -> Vec<CookieFinding> {
544 cookies
545 .iter()
546 .map(|raw| {
547 let attrs = parse_attributes(raw.split_once(';').map_or("", |(_, rest)| rest));
551 let name = raw
555 .split(';')
556 .next()
557 .map(|first| first.split_once('=').map_or(first, |(k, _)| k).trim())
558 .filter(|n| !n.is_empty())
559 .unwrap_or("(unnamed)")
560 .to_string();
561
562 let secure = attr(&attrs, "secure").is_some();
563 let http_only = attr(&attrs, "httponly").is_some();
564 let same_site = attr(&attrs, "samesite").map(|v| v.to_string());
565
566 let mut issues = Vec::new();
567 if !secure {
568 issues.push("missing Secure (may be sent over plain HTTP)".to_string());
569 }
570 if !http_only {
571 issues.push("missing HttpOnly (readable by JavaScript/XSS)".to_string());
572 }
573 match same_site.as_deref().map(|s| s.to_ascii_lowercase()) {
574 None => issues.push("missing SameSite (CSRF exposure)".to_string()),
575 Some(ref s) if s == "none" && !secure => {
576 issues.push("SameSite=None without Secure is rejected by browsers".to_string());
577 }
578 _ => {}
579 }
580
581 let verdict = match issues.len() {
582 0 => HeaderVerdict::Strict,
583 1 => HeaderVerdict::Moderate,
584 _ => HeaderVerdict::Weak,
585 };
586
587 CookieFinding {
588 name,
589 secure,
590 http_only,
591 same_site,
592 verdict,
593 issues,
594 }
595 })
596 .collect()
597}
598
599fn looks_versioned(value: &str) -> bool {
602 let bytes = value.as_bytes();
603 bytes.windows(2).any(|w| {
604 (w[0] == b'/' && w[1].is_ascii_digit())
606 || (w[0].is_ascii_digit() && w[1] == b'.')
607 || (w[0] == b'.' && w[1].is_ascii_digit())
608 })
609}
610
611const DISCLOSURE_HEADERS: &[&str] = &[
613 "server",
614 "x-powered-by",
615 "x-aspnet-version",
616 "x-aspnetmvc-version",
617 "x-generator",
618 "x-drupal-cache",
619];
620
621fn collect_disclosures(response: &FetchedResponse) -> Vec<Disclosure> {
622 DISCLOSURE_HEADERS
623 .iter()
624 .filter_map(|name| {
625 let value = response.header(name)?;
626 if value.trim().is_empty() {
627 return None;
628 }
629 Some(Disclosure {
630 header: (*name).to_string(),
631 value: value.to_string(),
632 versioned: looks_versioned(value),
633 })
634 })
635 .collect()
636}
637
638fn compute_score(
640 headers: &[HeaderFinding],
641 cookies: &[CookieFinding],
642 disclosures: &[Disclosure],
643) -> u32 {
644 let earned: u32 = headers
645 .iter()
646 .map(|f| {
647 let weight = HEADER_WEIGHTS
648 .iter()
649 .find(|(name, _)| *name == f.header)
650 .map(|(_, w)| *w)
651 .unwrap_or(0);
652 weight * f.verdict.score_pct() / 100
653 })
654 .sum();
655
656 let cookie_penalty = (cookies.iter().filter(|c| !c.issues.is_empty()).count() as u32
657 * COOKIE_PENALTY)
658 .min(COOKIE_PENALTY_CAP);
659
660 let disclosure_penalty = (disclosures.iter().filter(|d| d.versioned).count() as u32
661 * DISCLOSURE_PENALTY)
662 .min(DISCLOSURE_PENALTY_CAP);
663
664 earned.saturating_sub(cookie_penalty + disclosure_penalty)
665}
666
667fn grade_letter(score: u32) -> &'static str {
669 match score {
670 95..=u32::MAX => "A+",
671 85..=94 => "A",
672 75..=84 => "B",
673 65..=74 => "C",
674 50..=64 => "D",
675 30..=49 => "E",
676 _ => "F",
677 }
678}
679
680fn build_notes(
682 headers: &[HeaderFinding],
683 cookies: &[CookieFinding],
684 disclosures: &[Disclosure],
685) -> Vec<String> {
686 let mut notes = Vec::new();
687
688 for finding in headers {
691 if let Some(note) = &finding.note {
692 notes.push(format!("{}: {}", finding.header, note));
693 }
694 }
695
696 for cookie in cookies {
697 if !cookie.issues.is_empty() {
698 notes.push(format!(
699 "cookie '{}': {}",
700 cookie.name,
701 cookie.issues.join("; ")
702 ));
703 }
704 }
705
706 for d in disclosures.iter().filter(|d| d.versioned) {
707 notes.push(format!(
708 "{} discloses a version ('{}') — an attacker can match it against known CVEs.",
709 d.header, d.value
710 ));
711 }
712
713 notes
714}
715
716fn build_report(domain: String, response: &FetchedResponse) -> HeaderReport {
719 let csp_policies = parse_csp_policies(response.header_all("content-security-policy"));
720 let csp_report_only = response
721 .header("content-security-policy-report-only")
722 .is_some();
723 let over_https = response
724 .final_url
725 .get(..8)
726 .is_some_and(|scheme| scheme.eq_ignore_ascii_case("https://"));
727
728 let graded: Vec<(&str, (HeaderVerdict, Option<String>))> = vec![
729 (
730 "strict-transport-security",
731 grade_hsts(response.header("strict-transport-security"), over_https),
732 ),
733 (
734 "content-security-policy",
735 grade_csp(&csp_policies, csp_report_only),
736 ),
737 (
738 "x-frame-options",
739 grade_frame_options(response.header("x-frame-options"), &csp_policies),
740 ),
741 (
742 "x-content-type-options",
743 grade_content_type_options(response.header("x-content-type-options")),
744 ),
745 (
746 "referrer-policy",
747 grade_referrer_policy(response.header("referrer-policy")),
748 ),
749 (
750 "permissions-policy",
751 grade_presence(
752 response.header("permissions-policy"),
753 "Add Permissions-Policy to disable powerful browser features the site does not use.",
754 ),
755 ),
756 (
757 "cross-origin-opener-policy",
758 grade_enum(
759 response.header("cross-origin-opener-policy"),
760 &[
761 ("same-origin", HeaderVerdict::Strict),
762 ("same-origin-allow-popups", HeaderVerdict::Moderate),
763 ("unsafe-none", HeaderVerdict::Weak),
764 ],
765 "Add Cross-Origin-Opener-Policy: same-origin to isolate the browsing context.",
766 ),
767 ),
768 (
769 "cross-origin-embedder-policy",
770 grade_enum(
771 response.header("cross-origin-embedder-policy"),
772 &[
773 ("require-corp", HeaderVerdict::Strict),
774 ("credentialless", HeaderVerdict::Strict),
775 ("unsafe-none", HeaderVerdict::Weak),
776 ],
777 "Add Cross-Origin-Embedder-Policy: require-corp to block un-opted-in cross-origin loads.",
778 ),
779 ),
780 (
781 "cross-origin-resource-policy",
782 grade_enum(
783 response.header("cross-origin-resource-policy"),
784 &[
785 ("same-origin", HeaderVerdict::Strict),
786 ("same-site", HeaderVerdict::Moderate),
787 ("cross-origin", HeaderVerdict::Weak),
788 ],
789 "Add Cross-Origin-Resource-Policy to control who may embed this origin's resources.",
790 ),
791 ),
792 ];
793
794 let headers: Vec<HeaderFinding> = HEADER_WEIGHTS
796 .iter()
797 .filter_map(|(name, _)| {
798 let (_, (verdict, note)) = graded.iter().find(|(n, _)| n == name)?;
799 let values: Vec<&str> = response.header_all(name).collect();
803 let value = (!values.is_empty()).then(|| values.join(", "));
804 Some(HeaderFinding {
805 header: (*name).to_string(),
806 present: value.is_some(),
807 value,
808 verdict: *verdict,
809 note: note.clone(),
810 })
811 })
812 .collect();
813
814 let set_cookies: Vec<String> = response
815 .header_all("set-cookie")
816 .map(|v| v.to_string())
817 .collect();
818 let cookies = grade_cookies(&set_cookies);
819 let disclosures = collect_disclosures(response);
820
821 let score = compute_score(&headers, &cookies, &disclosures);
822 let notes = build_notes(&headers, &cookies, &disclosures);
823
824 HeaderReport {
825 domain,
826 url: response.final_url.clone(),
827 status: response.status,
828 redirects: response.redirects,
829 grade: grade_letter(score).to_string(),
830 score,
831 headers,
832 cookies,
833 disclosures,
834 notes,
835 }
836}
837
838pub async fn audit_headers(domain: &str, timeout: Duration) -> Result<HeaderReport> {
862 let domain = normalize_host(domain)?;
865 let fetcher = GuardedFetcher::new().with_timeout(timeout);
866 let response = fetcher.get(&format!("https://{domain}/")).await?;
867 Ok(build_report(domain, &response))
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873
874 fn response_with(headers: &[(&str, &str)]) -> FetchedResponse {
877 FetchedResponse {
878 final_url: "https://example.com/".to_string(),
879 status: 200,
880 headers: headers
881 .iter()
882 .map(|(k, v)| (k.to_ascii_lowercase(), v.to_string()))
883 .collect(),
884 body: String::new(),
885 redirects: 0,
886 }
887 }
888
889 fn csp(values: &[&str]) -> Vec<CspPolicy> {
891 parse_csp_policies(values.iter().copied())
892 }
893
894 #[test]
895 fn hsts_grading_bands() {
896 assert_eq!(grade_hsts(None, true).0, HeaderVerdict::Absent);
897 assert_eq!(
898 grade_hsts(Some("max-age=31536000; includeSubDomains"), true).0,
899 HeaderVerdict::Strict
900 );
901 assert_eq!(
903 grade_hsts(Some("max-age=31536000"), true).0,
904 HeaderVerdict::Moderate
905 );
906 assert_eq!(
907 grade_hsts(Some("max-age=15552000; includeSubDomains"), true).0,
908 HeaderVerdict::Moderate
909 );
910 assert_eq!(grade_hsts(Some("max-age=300"), true).0, HeaderVerdict::Weak);
911 assert_eq!(grade_hsts(Some("max-age=0"), true).0, HeaderVerdict::Weak);
913 assert_eq!(
914 grade_hsts(Some("includeSubDomains"), true).0,
915 HeaderVerdict::Weak
916 );
917 }
918
919 #[test]
920 fn csp_unsafe_directives_are_weak() {
921 assert_eq!(
922 grade_csp(&csp(&["default-src 'self' 'unsafe-inline'"]), false).0,
923 HeaderVerdict::Weak
924 );
925 assert_eq!(
926 grade_csp(&csp(&["script-src 'unsafe-eval'"]), false).0,
927 HeaderVerdict::Weak
928 );
929 assert_eq!(
930 grade_csp(&csp(&["default-src 'self'"]), false).0,
931 HeaderVerdict::Strict
932 );
933 assert_eq!(
935 grade_csp(&csp(&["img-src 'self'"]), false).0,
936 HeaderVerdict::Moderate
937 );
938 assert_eq!(grade_csp(&[], false).0, HeaderVerdict::Absent);
939 assert_eq!(grade_csp(&[], true).0, HeaderVerdict::Weak);
941 }
942
943 #[test]
944 fn csp_frame_ancestors_substitutes_for_x_frame_options() {
945 let (verdict, note) =
948 grade_frame_options(None, &csp(&["default-src 'self'; frame-ancestors 'none'"]));
949 assert_eq!(verdict, HeaderVerdict::Present);
950 assert!(note.unwrap_or_default().contains("frame-ancestors"));
951
952 assert_eq!(
954 grade_frame_options(None, &csp(&["default-src 'self'"])).0,
955 HeaderVerdict::Absent
956 );
957 assert_eq!(
958 grade_frame_options(Some("DENY"), &[]).0,
959 HeaderVerdict::Strict
960 );
961 assert_eq!(
962 grade_frame_options(Some("SAMEORIGIN"), &[]).0,
963 HeaderVerdict::Strict
964 );
965 assert_eq!(
967 grade_frame_options(Some("ALLOW-FROM https://x.com"), &[]).0,
968 HeaderVerdict::Weak
969 );
970 }
971
972 #[test]
973 fn hsts_quoted_max_age_and_insecure_transport() {
974 assert_eq!(
976 grade_hsts(Some("max-age=\"31536000\"; includeSubDomains"), true).0,
977 HeaderVerdict::Strict
978 );
979 assert_eq!(
981 grade_hsts(Some("max-age=31536000; includeSubDomains"), false).0,
982 HeaderVerdict::Absent
983 );
984 let mut response = response_with(&[(
985 "Strict-Transport-Security",
986 "max-age=31536000; includeSubDomains",
987 )]);
988 response.final_url = "http://example.com/".to_string();
989 let report = build_report("example.com".into(), &response);
990 let hsts = report
991 .headers
992 .iter()
993 .find(|h| h.header == "strict-transport-security")
994 .unwrap();
995 assert_eq!(hsts.verdict, HeaderVerdict::Absent);
996 }
997
998 #[test]
999 fn csp_nonce_hash_and_strict_dynamic_neutralize_unsafe_inline() {
1000 assert_eq!(
1003 grade_csp(
1004 &csp(&["script-src 'nonce-r4nd' 'unsafe-inline' 'strict-dynamic' https:; object-src 'none'; base-uri 'none'"]),
1005 false
1006 )
1007 .0,
1008 HeaderVerdict::Strict
1009 );
1010 assert_eq!(
1011 grade_csp(&csp(&["script-src 'sha256-abc=' 'unsafe-inline'"]), false).0,
1012 HeaderVerdict::Strict
1013 );
1014 assert_eq!(
1016 grade_csp(&csp(&["script-src 'nonce-x' 'unsafe-eval'"]), false).0,
1017 HeaderVerdict::Weak
1018 );
1019 assert_eq!(
1022 grade_csp(
1023 &csp(&["default-src 'self'; style-src 'self' 'unsafe-inline'"]),
1024 false
1025 )
1026 .0,
1027 HeaderVerdict::Strict
1028 );
1029 assert_eq!(
1031 grade_csp(
1032 &csp(&["default-src 'self'; script-src 'self' 'unsafe-inline'"]),
1033 false
1034 )
1035 .0,
1036 HeaderVerdict::Weak
1037 );
1038 }
1039
1040 #[test]
1041 fn csp_is_graded_across_every_delivered_policy() {
1042 let response = response_with(&[
1044 ("Content-Security-Policy", "frame-ancestors 'self'"),
1045 ("Content-Security-Policy", "default-src 'self'"),
1046 ]);
1047 let report = build_report("example.com".into(), &response);
1048 let find = |name: &str| {
1049 report
1050 .headers
1051 .iter()
1052 .find(|h| h.header == name)
1053 .unwrap()
1054 .clone()
1055 };
1056 assert_eq!(
1057 find("content-security-policy").verdict,
1058 HeaderVerdict::Strict
1059 );
1060 assert_eq!(find("x-frame-options").verdict, HeaderVerdict::Present);
1062 assert_eq!(
1064 find("content-security-policy").value.as_deref(),
1065 Some("frame-ancestors 'self', default-src 'self'")
1066 );
1067 assert_eq!(
1070 grade_csp(
1071 &csp(&["script-src 'unsafe-inline', script-src 'self'"]),
1072 false
1073 )
1074 .0,
1075 HeaderVerdict::Strict
1076 );
1077 }
1078
1079 #[test]
1080 fn invalid_xfo_is_superseded_by_frame_ancestors() {
1081 let (verdict, note) = grade_frame_options(
1083 Some("ALLOW-FROM https://x.example"),
1084 &csp(&["frame-ancestors 'self'"]),
1085 );
1086 assert_eq!(verdict, HeaderVerdict::Present);
1087 assert!(note.unwrap_or_default().contains("frame-ancestors"));
1088 assert_eq!(
1090 grade_frame_options(Some("ALLOW-FROM https://x.example"), &[]).0,
1091 HeaderVerdict::Weak
1092 );
1093 }
1094
1095 #[test]
1096 fn cross_origin_policies_ignore_structured_header_parameters() {
1097 let coep = [
1098 ("require-corp", HeaderVerdict::Strict),
1099 ("unsafe-none", HeaderVerdict::Weak),
1100 ];
1101 assert_eq!(
1102 grade_enum(Some("require-corp; report-to=\"default\""), &coep, "x").0,
1103 HeaderVerdict::Strict
1104 );
1105 let coop = [
1106 ("same-origin", HeaderVerdict::Strict),
1107 ("same-origin-allow-popups", HeaderVerdict::Moderate),
1108 ];
1109 assert_eq!(
1110 grade_enum(
1111 Some("same-origin-allow-popups; report-to=\"gws\""),
1112 &coop,
1113 "x"
1114 )
1115 .0,
1116 HeaderVerdict::Moderate
1117 );
1118 }
1119
1120 #[test]
1121 fn cookie_name_is_not_mistaken_for_an_attribute() {
1122 let findings = grade_cookies(&[
1123 "secure=1; Path=/".to_string(),
1124 "HttpOnly=yes; SameSite=Lax".to_string(),
1125 ]);
1126 assert_eq!(findings[0].name, "secure");
1127 assert!(!findings[0].secure, "a cookie NAMED secure is not Secure");
1128 assert!(!findings[1].http_only);
1129 assert_eq!(findings[1].same_site.as_deref(), Some("Lax"));
1130 }
1131
1132 #[test]
1133 fn referrer_policy_bands() {
1134 assert_eq!(
1135 grade_referrer_policy(Some("strict-origin-when-cross-origin")).0,
1136 HeaderVerdict::Strict
1137 );
1138 assert_eq!(
1139 grade_referrer_policy(Some("no-referrer")).0,
1140 HeaderVerdict::Strict
1141 );
1142 assert_eq!(
1143 grade_referrer_policy(Some("origin")).0,
1144 HeaderVerdict::Moderate
1145 );
1146 assert_eq!(
1147 grade_referrer_policy(Some("unsafe-url")).0,
1148 HeaderVerdict::Weak
1149 );
1150 assert_eq!(
1153 grade_referrer_policy(Some("no-referrer, strict-origin-when-cross-origin")).0,
1154 HeaderVerdict::Strict
1155 );
1156 assert_eq!(
1157 grade_referrer_policy(Some("no-referrer, unsafe-url")).0,
1158 HeaderVerdict::Weak
1159 );
1160 assert_eq!(
1161 grade_referrer_policy(Some("strict-origin, origin")).0,
1162 HeaderVerdict::Moderate
1163 );
1164 assert_eq!(
1166 grade_referrer_policy(Some("no-referrer, some-future-policy")).0,
1167 HeaderVerdict::Strict
1168 );
1169 assert_eq!(grade_referrer_policy(None).0, HeaderVerdict::Absent);
1170 }
1171
1172 #[test]
1173 fn content_type_options_only_accepts_nosniff() {
1174 assert_eq!(
1175 grade_content_type_options(Some("nosniff")).0,
1176 HeaderVerdict::Strict
1177 );
1178 assert_eq!(
1179 grade_content_type_options(Some("NOSNIFF")).0,
1180 HeaderVerdict::Strict
1181 );
1182 assert_eq!(
1183 grade_content_type_options(Some("sniff")).0,
1184 HeaderVerdict::Weak
1185 );
1186 assert_eq!(grade_content_type_options(None).0, HeaderVerdict::Absent);
1187 }
1188
1189 #[test]
1190 fn cookie_names_preserve_case() {
1191 let findings = grade_cookies(&[
1194 "JSESSIONID=abc; Secure".to_string(),
1195 "WMF-Last-Access=1; Secure; HttpOnly".to_string(),
1196 ]);
1197 assert_eq!(findings[0].name, "JSESSIONID");
1198 assert_eq!(findings[1].name, "WMF-Last-Access");
1199 assert!(findings[1].secure);
1201 assert!(findings[1].http_only);
1202 }
1203
1204 #[test]
1205 fn cookie_flags_are_graded_and_named() {
1206 let findings = grade_cookies(&[
1207 "session=abc; Secure; HttpOnly; SameSite=Strict".to_string(),
1208 "tracker=xyz".to_string(),
1209 "mixed=1; Secure".to_string(),
1210 ]);
1211 assert_eq!(findings[0].name, "session");
1212 assert_eq!(findings[0].verdict, HeaderVerdict::Strict);
1213 assert!(findings[0].issues.is_empty());
1214
1215 assert_eq!(findings[1].name, "tracker");
1217 assert_eq!(findings[1].verdict, HeaderVerdict::Weak);
1218 assert_eq!(findings[1].issues.len(), 3);
1219
1220 assert_eq!(findings[2].verdict, HeaderVerdict::Weak);
1222 assert!(findings[2].secure);
1223 assert!(!findings[2].http_only);
1224 }
1225
1226 #[test]
1227 fn same_site_none_without_secure_is_flagged() {
1228 let findings = grade_cookies(&["c=1; HttpOnly; SameSite=None".to_string()]);
1229 assert!(
1230 findings[0]
1231 .issues
1232 .iter()
1233 .any(|i| i.contains("SameSite=None without Secure")),
1234 "got {:?}",
1235 findings[0].issues
1236 );
1237 }
1238
1239 #[test]
1240 fn version_disclosure_detection() {
1241 assert!(looks_versioned("nginx/1.25.3"));
1242 assert!(looks_versioned("PHP/8.2.1"));
1243 assert!(looks_versioned("Apache/2.4"));
1244 assert!(!looks_versioned("cloudflare"));
1246 assert!(!looks_versioned("nginx"));
1247 }
1248
1249 #[test]
1250 fn perfect_headers_score_a_plus() {
1251 let response = response_with(&[
1252 (
1253 "strict-transport-security",
1254 "max-age=63072000; includeSubDomains; preload",
1255 ),
1256 ("content-security-policy", "default-src 'self'"),
1257 ("x-frame-options", "DENY"),
1258 ("x-content-type-options", "nosniff"),
1259 ("referrer-policy", "no-referrer"),
1260 ("permissions-policy", "geolocation=()"),
1261 ("cross-origin-opener-policy", "same-origin"),
1262 ("cross-origin-embedder-policy", "require-corp"),
1263 ("cross-origin-resource-policy", "same-origin"),
1264 ]);
1265 let report = build_report("example.com".to_string(), &response);
1266 assert_eq!(report.score, 100, "notes: {:?}", report.notes);
1267 assert_eq!(report.grade, "A+");
1268 assert!(report.notes.is_empty(), "got {:?}", report.notes);
1269 }
1270
1271 #[test]
1272 fn bare_response_scores_f_and_lists_every_gap() {
1273 let report = build_report("example.com".to_string(), &response_with(&[]));
1274 assert_eq!(report.score, 0);
1275 assert_eq!(report.grade, "F");
1276 assert_eq!(report.headers.len(), HEADER_WEIGHTS.len());
1278 assert_eq!(report.notes.len(), HEADER_WEIGHTS.len());
1279 assert!(report.headers.iter().all(|h| !h.present));
1280 }
1281
1282 #[test]
1283 fn penalties_apply_but_stay_bounded() {
1284 let cookies = grade_cookies(&(0..10).map(|i| format!("c{i}=1")).collect::<Vec<_>>());
1287 let disclosures = vec![
1288 Disclosure {
1289 header: "server".into(),
1290 value: "nginx/1.0".into(),
1291 versioned: true,
1292 },
1293 Disclosure {
1294 header: "x-powered-by".into(),
1295 value: "PHP/8.0".into(),
1296 versioned: true,
1297 },
1298 Disclosure {
1299 header: "x-generator".into(),
1300 value: "Drupal/9.1".into(),
1301 versioned: true,
1302 },
1303 ];
1304 let headers: Vec<HeaderFinding> = HEADER_WEIGHTS
1306 .iter()
1307 .map(|(name, _)| HeaderFinding {
1308 header: (*name).to_string(),
1309 present: true,
1310 value: None,
1311 verdict: HeaderVerdict::Strict,
1312 note: None,
1313 })
1314 .collect();
1315 let score = compute_score(&headers, &cookies, &disclosures);
1316 assert_eq!(score, 100 - COOKIE_PENALTY_CAP - DISCLOSURE_PENALTY_CAP);
1317
1318 let empty: Vec<HeaderFinding> = Vec::new();
1320 assert_eq!(compute_score(&empty, &cookies, &disclosures), 0);
1321 }
1322
1323 #[test]
1324 fn weights_sum_to_one_hundred() {
1325 let total: u32 = HEADER_WEIGHTS.iter().map(|(_, w)| *w).sum();
1328 assert_eq!(total, 100);
1329 }
1330
1331 #[test]
1332 fn grade_bands_are_ordered_and_total() {
1333 assert_eq!(grade_letter(100), "A+");
1334 assert_eq!(grade_letter(95), "A+");
1335 assert_eq!(grade_letter(94), "A");
1336 assert_eq!(grade_letter(85), "A");
1337 assert_eq!(grade_letter(75), "B");
1338 assert_eq!(grade_letter(65), "C");
1339 assert_eq!(grade_letter(50), "D");
1340 assert_eq!(grade_letter(30), "E");
1341 assert_eq!(grade_letter(29), "F");
1342 assert_eq!(grade_letter(0), "F");
1343 }
1344
1345 #[test]
1346 fn report_captures_cookies_and_disclosures_from_response() {
1347 let mut response = response_with(&[
1348 ("server", "nginx/1.25.3"),
1349 ("x-powered-by", "PHP/8.2"),
1350 ("x-content-type-options", "nosniff"),
1351 ]);
1352 response.headers.push((
1354 "set-cookie".into(),
1355 "a=1; Secure; HttpOnly; SameSite=Lax".into(),
1356 ));
1357 response.headers.push(("set-cookie".into(), "b=2".into()));
1358
1359 let report = build_report("example.com".to_string(), &response);
1360 assert_eq!(report.cookies.len(), 2);
1361 assert_eq!(report.disclosures.len(), 2);
1362 assert!(report.disclosures.iter().all(|d| d.versioned));
1363 assert!(report.notes.iter().any(|n| n.contains("cookie 'b'")));
1364 assert!(report.notes.iter().any(|n| n.contains("CVE")));
1365 }
1366
1367 #[test]
1368 fn header_values_are_preserved_for_display() {
1369 let response = response_with(&[("x-frame-options", "SAMEORIGIN")]);
1370 let report = build_report("example.com".to_string(), &response);
1371 let xfo = report
1372 .headers
1373 .iter()
1374 .find(|h| h.header == "x-frame-options")
1375 .expect("x-frame-options finding");
1376 assert!(xfo.present);
1377 assert_eq!(xfo.value.as_deref(), Some("SAMEORIGIN"));
1378 }
1379}