1use std::time::Duration;
33
34use serde::{Deserialize, Serialize};
35
36use crate::error::Result;
37use crate::http::{GuardedFetcher, ResponseHead};
38use crate::validation::normalize_host;
39
40pub use crate::posture::PostureVerdict as HeaderVerdict;
44
45impl HeaderVerdict {
46 fn score_pct(self) -> u32 {
49 match self {
50 HeaderVerdict::Strict | HeaderVerdict::Present => 100,
51 HeaderVerdict::Moderate => 60,
52 HeaderVerdict::Weak => 30,
53 HeaderVerdict::Absent | HeaderVerdict::Unknown => 0,
56 }
57 }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct HeaderFinding {
63 pub header: String,
65 pub present: bool,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub value: Option<String>,
69 pub verdict: HeaderVerdict,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub note: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct CookieFinding {
78 pub name: String,
79 pub secure: bool,
80 pub http_only: bool,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub same_site: Option<String>,
83 pub verdict: HeaderVerdict,
84 #[serde(skip_serializing_if = "Vec::is_empty")]
86 pub issues: Vec<String>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Disclosure {
92 pub header: String,
93 pub value: String,
94 pub versioned: bool,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct HeaderReport {
102 pub domain: String,
103 pub url: String,
105 pub status: u16,
106 pub redirects: usize,
108 pub grade: String,
110 pub score: u32,
112 pub headers: Vec<HeaderFinding>,
113 #[serde(skip_serializing_if = "Vec::is_empty")]
114 pub cookies: Vec<CookieFinding>,
115 #[serde(skip_serializing_if = "Vec::is_empty")]
116 pub disclosures: Vec<Disclosure>,
117 pub notes: Vec<String>,
119}
120
121pub const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(10);
124
125const HEADER_WEIGHTS: &[(&str, u32)] = &[
133 ("strict-transport-security", 20),
134 ("content-security-policy", 20),
135 ("x-frame-options", 15),
136 ("x-content-type-options", 10),
137 ("referrer-policy", 10),
138 ("permissions-policy", 10),
139 ("cross-origin-opener-policy", 5),
140 ("cross-origin-embedder-policy", 5),
141 ("cross-origin-resource-policy", 5),
142];
143
144const HSTS_STRONG_MAX_AGE: u64 = 31_536_000;
147const HSTS_MODERATE_MAX_AGE: u64 = 15_552_000;
150
151const COOKIE_PENALTY: u32 = 4;
153const COOKIE_PENALTY_CAP: u32 = 12;
154const DISCLOSURE_PENALTY: u32 = 3;
156const DISCLOSURE_PENALTY_CAP: u32 = 6;
157
158fn parse_attributes(value: &str) -> Vec<(String, String)> {
164 value
165 .split(';')
166 .filter_map(|part| {
167 let part = part.trim();
168 if part.is_empty() {
169 return None;
170 }
171 Some(match part.split_once('=') {
172 Some((k, v)) => (k.trim().to_ascii_lowercase(), v.trim().to_string()),
173 None => (part.to_ascii_lowercase(), String::new()),
174 })
175 })
176 .collect()
177}
178
179fn attr<'a>(attrs: &'a [(String, String)], key: &str) -> Option<&'a str> {
181 attrs
182 .iter()
183 .find(|(k, _)| k == key)
184 .map(|(_, v)| v.as_str())
185}
186
187fn grade_hsts(value: Option<&str>, over_https: bool) -> (HeaderVerdict, Option<String>) {
188 let Some(value) = value else {
189 return (
190 HeaderVerdict::Absent,
191 Some("Add Strict-Transport-Security to stop protocol-downgrade and cookie-stripping attacks.".into()),
192 );
193 };
194 if !over_https {
197 return (
198 HeaderVerdict::Absent,
199 Some("Strict-Transport-Security was served over plain HTTP, where browsers ignore it; serve the final page over HTTPS.".into()),
200 );
201 }
202 let attrs = parse_attributes(value);
203 let max_age: Option<u64> = attr(&attrs, "max-age").and_then(|v| {
207 let digits = v.trim().trim_matches('"');
208 (!digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
209 .then(|| digits.parse().unwrap_or(u64::MAX))
210 });
211 let include_subdomains = attr(&attrs, "includesubdomains").is_some();
212
213 match max_age {
214 None | Some(0) => (
215 HeaderVerdict::Weak,
216 Some("max-age is missing or zero, which disables HSTS entirely.".into()),
217 ),
218 Some(age) if age >= HSTS_STRONG_MAX_AGE && include_subdomains => {
219 (HeaderVerdict::Strict, None)
220 }
221 Some(age) if age >= HSTS_STRONG_MAX_AGE => (
222 HeaderVerdict::Moderate,
223 Some("max-age is strong but includeSubDomains is missing — subdomains stay downgradable.".into()),
224 ),
225 Some(age) if age >= HSTS_MODERATE_MAX_AGE => (
226 HeaderVerdict::Moderate,
227 Some(format!(
228 "max-age={age} is below the 31536000 (1 year) that preload requires."
229 )),
230 ),
231 Some(age) => (
232 HeaderVerdict::Weak,
233 Some(format!(
234 "max-age={age} is a short window; use at least 15552000 (6 months)."
235 )),
236 ),
237 }
238}
239
240type CspPolicy = Vec<(String, Vec<String>)>;
242
243fn parse_csp_policies<'a>(values: impl IntoIterator<Item = &'a str>) -> Vec<CspPolicy> {
251 values
252 .into_iter()
253 .flat_map(|v| v.split(','))
254 .map(|policy| {
255 policy
256 .split(';')
257 .filter_map(|directive| {
258 let mut tokens = directive.split_ascii_whitespace();
259 let name = tokens.next()?.to_ascii_lowercase();
260 Some((name, tokens.map(str::to_ascii_lowercase).collect()))
261 })
262 .collect::<CspPolicy>()
263 })
264 .filter(|p| !p.is_empty())
265 .collect()
266}
267
268fn csp_directive<'a>(policy: &'a CspPolicy, name: &str) -> Option<&'a [String]> {
269 policy
270 .iter()
271 .find(|(n, _)| n == name)
272 .map(|(_, sources)| sources.as_slice())
273}
274
275enum ScriptControl {
277 None,
279 Unsafe(Vec<&'static str>),
282 Safe,
284}
285
286const PERMISSIVE_SCRIPT_SOURCES: [&str; 4] = ["*", "http:", "https:", "data:"];
289
290fn script_control(policy: &CspPolicy) -> ScriptControl {
291 let Some(sources) =
293 csp_directive(policy, "script-src").or_else(|| csp_directive(policy, "default-src"))
294 else {
295 return ScriptControl::None;
296 };
297 let has = |t: &str| sources.iter().any(|s| s == t);
298 let inline_neutralized = has("'strict-dynamic'")
302 || sources.iter().any(|s| {
303 s.starts_with("'nonce-")
304 || s.starts_with("'sha256-")
305 || s.starts_with("'sha384-")
306 || s.starts_with("'sha512-")
307 });
308 let mut unsafe_kw = Vec::new();
309 if has("'unsafe-inline'") && !inline_neutralized {
310 unsafe_kw.push("'unsafe-inline'");
311 }
312 if has("'unsafe-eval'") {
313 unsafe_kw.push("'unsafe-eval'");
314 }
315 if !has("'strict-dynamic'") {
318 unsafe_kw.extend(PERMISSIVE_SCRIPT_SOURCES.into_iter().filter(|src| has(src)));
319 }
320 if unsafe_kw.is_empty() {
321 ScriptControl::Safe
322 } else {
323 ScriptControl::Unsafe(unsafe_kw)
324 }
325}
326
327fn grade_csp(policies: &[CspPolicy], report_only: bool) -> (HeaderVerdict, Option<String>) {
328 if policies.is_empty() {
329 if report_only {
330 return (
331 HeaderVerdict::Weak,
332 Some("Only Content-Security-Policy-Report-Only is set — it reports violations but blocks nothing.".into()),
333 );
334 }
335 return (
336 HeaderVerdict::Absent,
337 Some(
338 "Add Content-Security-Policy — it is the primary defense against injected script."
339 .into(),
340 ),
341 );
342 };
343
344 let controls: Vec<ScriptControl> = policies.iter().map(script_control).collect();
347 if controls.iter().any(|c| matches!(c, ScriptControl::Safe)) {
348 return (HeaderVerdict::Strict, None);
349 }
350
351 let mut which: Vec<&str> = Vec::new();
352 for control in &controls {
353 if let ScriptControl::Unsafe(kw) = control {
354 for k in kw {
355 if !which.contains(k) {
356 which.push(k);
357 }
358 }
359 }
360 }
361 if !which.is_empty() {
362 return (
363 HeaderVerdict::Weak,
364 Some(format!(
365 "Script policy allows {} — this reopens the injection hole CSP exists to close.",
366 which.join(" and ")
367 )),
368 );
369 }
370
371 (
372 HeaderVerdict::Moderate,
373 Some(
374 "Policy sets neither default-src nor script-src, so script loading is unrestricted."
375 .into(),
376 ),
377 )
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382enum FrameAncestors {
383 Unset,
385 AnyOrigin,
388 Restricted,
390}
391
392fn frame_ancestors(csp_policies: &[CspPolicy]) -> FrameAncestors {
393 let mut state = FrameAncestors::Unset;
394 for sources in csp_policies
395 .iter()
396 .filter_map(|p| csp_directive(p, "frame-ancestors"))
397 {
398 if sources
400 .iter()
401 .any(|s| matches!(s.as_str(), "*" | "http:" | "https:"))
402 {
403 state = FrameAncestors::AnyOrigin;
404 } else {
405 return FrameAncestors::Restricted;
406 }
407 }
408 state
409}
410
411fn grade_frame_options(
412 value: Option<&str>,
413 csp_policies: &[CspPolicy],
414) -> (HeaderVerdict, Option<String>) {
415 match frame_ancestors(csp_policies) {
420 FrameAncestors::AnyOrigin => {
421 return (
422 HeaderVerdict::Weak,
423 Some(
424 "CSP frame-ancestors admits any origin, and browsers ignore X-Frame-Options when it is set; list the allowed origins (or 'none')."
425 .into(),
426 ),
427 )
428 }
429 FrameAncestors::Restricted => {
430 return match value.map(|v| v.trim().to_ascii_uppercase()) {
431 Some(v) if v == "DENY" || v == "SAMEORIGIN" => (HeaderVerdict::Strict, None),
432 None => (
433 HeaderVerdict::Present,
434 Some(
435 "No X-Frame-Options, but CSP frame-ancestors is set, which supersedes it."
436 .into(),
437 ),
438 ),
439 Some(other) => (
443 HeaderVerdict::Present,
444 Some(format!(
445 "X-Frame-Options '{other}' is not a valid value, but CSP frame-ancestors is set and supersedes it."
446 )),
447 ),
448 };
449 }
450 FrameAncestors::Unset => {}
451 }
452
453 let Some(value) = value else {
454 return (
455 HeaderVerdict::Absent,
456 Some(
457 "Add X-Frame-Options (or CSP frame-ancestors) to prevent clickjacking via framing."
458 .into(),
459 ),
460 );
461 };
462
463 match value.trim().to_ascii_uppercase().as_str() {
464 "DENY" | "SAMEORIGIN" => (HeaderVerdict::Strict, None),
465 v if v.starts_with("ALLOW-FROM") => (
466 HeaderVerdict::Weak,
467 Some(
468 "ALLOW-FROM is obsolete and ignored by modern browsers; use CSP frame-ancestors."
469 .into(),
470 ),
471 ),
472 other => (
473 HeaderVerdict::Weak,
474 Some(format!(
475 "Unrecognized value '{other}' — use DENY or SAMEORIGIN."
476 )),
477 ),
478 }
479}
480
481fn grade_content_type_options(value: Option<&str>) -> (HeaderVerdict, Option<String>) {
482 match value.map(|v| v.trim().to_ascii_lowercase()) {
483 None => (
484 HeaderVerdict::Absent,
485 Some(
486 "Add X-Content-Type-Options: nosniff to stop MIME-sniffing of user-supplied files."
487 .into(),
488 ),
489 ),
490 Some(ref v) if v == "nosniff" => (HeaderVerdict::Strict, None),
491 Some(other) => (
492 HeaderVerdict::Weak,
493 Some(format!(
494 "Value '{other}' is not recognized; the only valid value is nosniff."
495 )),
496 ),
497 }
498}
499
500fn grade_referrer_policy(value: Option<&str>) -> (HeaderVerdict, Option<String>) {
501 let Some(value) = value else {
502 return (
503 HeaderVerdict::Absent,
504 Some(
505 "Add Referrer-Policy so URLs (and any secrets in them) do not leak cross-origin."
506 .into(),
507 ),
508 );
509 };
510 let strict = [
511 "no-referrer",
512 "same-origin",
513 "strict-origin",
514 "strict-origin-when-cross-origin",
515 ];
516 let moderate = [
517 "origin",
518 "origin-when-cross-origin",
519 "no-referrer-when-downgrade",
520 ];
521
522 let effective = value
526 .split(',')
527 .map(|t| t.trim().to_ascii_lowercase())
528 .rev()
529 .find(|t| {
530 strict.contains(&t.as_str()) || moderate.contains(&t.as_str()) || t == "unsafe-url"
531 });
532
533 if effective.as_deref().is_some_and(|t| strict.contains(&t)) {
534 (HeaderVerdict::Strict, None)
535 } else if effective.as_deref().is_some_and(|t| moderate.contains(&t)) {
536 (
537 HeaderVerdict::Moderate,
538 Some("Policy still sends the origin cross-site; strict-origin-when-cross-origin is tighter.".into()),
539 )
540 } else if effective.as_deref() == Some("unsafe-url") {
541 (
542 HeaderVerdict::Weak,
543 Some(
544 "unsafe-url sends the full URL to every destination, including over plain HTTP."
545 .into(),
546 ),
547 )
548 } else {
549 (
550 HeaderVerdict::Weak,
551 Some(format!("Unrecognized policy '{}'.", value.trim())),
552 )
553 }
554}
555
556fn grade_presence(value: Option<&str>, advice: &str) -> (HeaderVerdict, Option<String>) {
558 match value {
559 Some(v) if !v.trim().is_empty() => (HeaderVerdict::Present, None),
560 _ => (HeaderVerdict::Absent, Some(advice.to_string())),
561 }
562}
563
564fn grade_enum(
566 value: Option<&str>,
567 table: &[(&str, HeaderVerdict)],
568 absent_advice: &str,
569) -> (HeaderVerdict, Option<String>) {
570 let Some(value) = value else {
571 return (HeaderVerdict::Absent, Some(absent_advice.to_string()));
572 };
573 let v = value
576 .split(';')
577 .next()
578 .unwrap_or_default()
579 .trim()
580 .to_ascii_lowercase();
581 match table.iter().find(|(k, _)| *k == v) {
582 Some((_, verdict)) => (*verdict, None),
583 None => (
584 HeaderVerdict::Weak,
585 Some(format!("Unrecognized value '{}'.", value.trim())),
586 ),
587 }
588}
589
590fn grade_cookies(cookies: &[String]) -> Vec<CookieFinding> {
592 cookies
593 .iter()
594 .map(|raw| {
595 let attrs = parse_attributes(raw.split_once(';').map_or("", |(_, rest)| rest));
599 let name = raw
603 .split(';')
604 .next()
605 .map(|first| first.split_once('=').map_or(first, |(k, _)| k).trim())
606 .filter(|n| !n.is_empty())
607 .unwrap_or("(unnamed)")
608 .to_string();
609
610 let secure = attr(&attrs, "secure").is_some();
611 let http_only = attr(&attrs, "httponly").is_some();
612 let same_site = attr(&attrs, "samesite").map(|v| v.to_string());
613
614 let mut issues = Vec::new();
615 if !secure {
616 issues.push("missing Secure (may be sent over plain HTTP)".to_string());
617 }
618 if !http_only {
619 issues.push("missing HttpOnly (readable by JavaScript/XSS)".to_string());
620 }
621 match same_site.as_deref().map(|s| s.to_ascii_lowercase()) {
622 None => issues.push("missing SameSite (CSRF exposure)".to_string()),
623 Some(ref s) if s == "none" && !secure => {
624 issues.push("SameSite=None without Secure is rejected by browsers".to_string());
625 }
626 _ => {}
627 }
628
629 let verdict = match issues.len() {
630 0 => HeaderVerdict::Strict,
631 1 => HeaderVerdict::Moderate,
632 _ => HeaderVerdict::Weak,
633 };
634
635 CookieFinding {
636 name,
637 secure,
638 http_only,
639 same_site,
640 verdict,
641 issues,
642 }
643 })
644 .collect()
645}
646
647fn looks_versioned(value: &str) -> bool {
650 let bytes = value.as_bytes();
651 bytes.windows(2).any(|w| {
652 (w[0] == b'/' && w[1].is_ascii_digit())
654 || (w[0].is_ascii_digit() && w[1] == b'.')
655 || (w[0] == b'.' && w[1].is_ascii_digit())
656 })
657}
658
659const DISCLOSURE_HEADERS: &[&str] = &[
661 "server",
662 "x-powered-by",
663 "x-aspnet-version",
664 "x-aspnetmvc-version",
665 "x-generator",
666 "x-drupal-cache",
667];
668
669fn collect_disclosures(response: &ResponseHead) -> Vec<Disclosure> {
670 DISCLOSURE_HEADERS
671 .iter()
672 .filter_map(|name| {
673 let value = response.header(name)?;
674 if value.trim().is_empty() {
675 return None;
676 }
677 Some(Disclosure {
678 header: (*name).to_string(),
679 value: value.to_string(),
680 versioned: looks_versioned(value),
681 })
682 })
683 .collect()
684}
685
686fn compute_score(
688 headers: &[HeaderFinding],
689 cookies: &[CookieFinding],
690 disclosures: &[Disclosure],
691) -> u32 {
692 let earned: u32 = headers
693 .iter()
694 .map(|f| {
695 let weight = HEADER_WEIGHTS
696 .iter()
697 .find(|(name, _)| *name == f.header)
698 .map(|(_, w)| *w)
699 .unwrap_or(0);
700 weight * f.verdict.score_pct() / 100
701 })
702 .sum();
703
704 let cookie_penalty = (cookies.iter().filter(|c| !c.issues.is_empty()).count() as u32
705 * COOKIE_PENALTY)
706 .min(COOKIE_PENALTY_CAP);
707
708 let disclosure_penalty = (disclosures.iter().filter(|d| d.versioned).count() as u32
709 * DISCLOSURE_PENALTY)
710 .min(DISCLOSURE_PENALTY_CAP);
711
712 earned.saturating_sub(cookie_penalty + disclosure_penalty)
713}
714
715fn build_notes(
717 headers: &[HeaderFinding],
718 cookies: &[CookieFinding],
719 disclosures: &[Disclosure],
720) -> Vec<String> {
721 let mut notes = Vec::new();
722
723 for finding in headers {
726 if let Some(note) = &finding.note {
727 notes.push(format!("{}: {}", finding.header, note));
728 }
729 }
730
731 for cookie in cookies {
732 if !cookie.issues.is_empty() {
733 notes.push(format!(
734 "cookie '{}': {}",
735 cookie.name,
736 cookie.issues.join("; ")
737 ));
738 }
739 }
740
741 for d in disclosures.iter().filter(|d| d.versioned) {
742 notes.push(format!(
743 "{} discloses a version ('{}') — an attacker can match it against known CVEs.",
744 d.header, d.value
745 ));
746 }
747
748 notes
749}
750
751pub(crate) fn build_report(domain: String, response: &ResponseHead) -> HeaderReport {
754 let csp_policies = parse_csp_policies(response.header_all("content-security-policy"));
755 let csp_report_only = response
756 .header("content-security-policy-report-only")
757 .is_some();
758 let over_https = response
759 .final_url
760 .get(..8)
761 .is_some_and(|scheme| scheme.eq_ignore_ascii_case("https://"));
762
763 let graded: Vec<(&str, (HeaderVerdict, Option<String>))> = vec![
764 (
765 "strict-transport-security",
766 grade_hsts(response.header("strict-transport-security"), over_https),
767 ),
768 (
769 "content-security-policy",
770 grade_csp(&csp_policies, csp_report_only),
771 ),
772 (
773 "x-frame-options",
774 grade_frame_options(response.header("x-frame-options"), &csp_policies),
775 ),
776 (
777 "x-content-type-options",
778 grade_content_type_options(response.header("x-content-type-options")),
779 ),
780 (
781 "referrer-policy",
782 grade_referrer_policy(response.header("referrer-policy")),
783 ),
784 (
785 "permissions-policy",
786 grade_presence(
787 response.header("permissions-policy"),
788 "Add Permissions-Policy to disable powerful browser features the site does not use.",
789 ),
790 ),
791 (
792 "cross-origin-opener-policy",
793 grade_enum(
794 response.header("cross-origin-opener-policy"),
795 &[
796 ("same-origin", HeaderVerdict::Strict),
797 ("same-origin-allow-popups", HeaderVerdict::Moderate),
798 ("unsafe-none", HeaderVerdict::Weak),
799 ],
800 "Add Cross-Origin-Opener-Policy: same-origin to isolate the browsing context.",
801 ),
802 ),
803 (
804 "cross-origin-embedder-policy",
805 grade_enum(
806 response.header("cross-origin-embedder-policy"),
807 &[
808 ("require-corp", HeaderVerdict::Strict),
809 ("credentialless", HeaderVerdict::Strict),
810 ("unsafe-none", HeaderVerdict::Weak),
811 ],
812 "Add Cross-Origin-Embedder-Policy: require-corp to block un-opted-in cross-origin loads.",
813 ),
814 ),
815 (
816 "cross-origin-resource-policy",
817 grade_enum(
818 response.header("cross-origin-resource-policy"),
819 &[
820 ("same-origin", HeaderVerdict::Strict),
821 ("same-site", HeaderVerdict::Moderate),
822 ("cross-origin", HeaderVerdict::Weak),
823 ],
824 "Add Cross-Origin-Resource-Policy to control who may embed this origin's resources.",
825 ),
826 ),
827 ];
828
829 let headers: Vec<HeaderFinding> = HEADER_WEIGHTS
831 .iter()
832 .filter_map(|(name, _)| {
833 let (_, (verdict, note)) = graded.iter().find(|(n, _)| n == name)?;
834 let values: Vec<&str> = response.header_all(name).collect();
838 let value = (!values.is_empty()).then(|| values.join(", "));
839 Some(HeaderFinding {
840 header: (*name).to_string(),
841 present: value.is_some(),
842 value,
843 verdict: *verdict,
844 note: note.clone(),
845 })
846 })
847 .collect();
848
849 let set_cookies: Vec<String> = response
850 .header_all("set-cookie")
851 .map(|v| v.to_string())
852 .collect();
853 let cookies = grade_cookies(&set_cookies);
854 let disclosures = collect_disclosures(response);
855
856 let score = compute_score(&headers, &cookies, &disclosures);
857 let notes = build_notes(&headers, &cookies, &disclosures);
858
859 HeaderReport {
860 domain,
861 url: response.final_url.clone(),
862 status: response.status,
863 redirects: response.redirects,
864 grade: crate::grade::Letter::from_score(score).as_str().to_string(),
865 score,
866 headers,
867 cookies,
868 disclosures,
869 notes,
870 }
871}
872
873pub async fn audit_headers(domain: &str, timeout: Duration) -> Result<HeaderReport> {
897 let domain = normalize_host(domain)?;
900 let fetcher = GuardedFetcher::new().with_timeout(timeout);
901 Ok(audit_headers_with(&fetcher, &domain).await?)
902}
903
904pub(crate) async fn audit_headers_with(
908 fetcher: &GuardedFetcher,
909 host: &str,
910) -> std::result::Result<HeaderReport, crate::http::SendError> {
911 fetcher
914 .get_head_traced(&format!("https://{host}/"))
915 .await
916 .result
917 .map(|response| build_report(host.to_string(), &response))
918}
919
920#[cfg(test)]
921mod tests {
922 use super::*;
923
924 fn response_with(headers: &[(&str, &str)]) -> ResponseHead {
927 ResponseHead {
928 final_url: "https://example.com/".to_string(),
929 status: 200,
930 headers: headers
931 .iter()
932 .map(|(k, v)| (k.to_ascii_lowercase(), v.to_string()))
933 .collect(),
934 redirects: 0,
935 }
936 }
937
938 fn csp(values: &[&str]) -> Vec<CspPolicy> {
940 parse_csp_policies(values.iter().copied())
941 }
942
943 #[test]
944 fn hsts_grading_bands() {
945 assert_eq!(grade_hsts(None, true).0, HeaderVerdict::Absent);
946 assert_eq!(
947 grade_hsts(Some("max-age=31536000; includeSubDomains"), true).0,
948 HeaderVerdict::Strict
949 );
950 assert_eq!(
952 grade_hsts(Some("max-age=31536000"), true).0,
953 HeaderVerdict::Moderate
954 );
955 assert_eq!(
956 grade_hsts(Some("max-age=15552000; includeSubDomains"), true).0,
957 HeaderVerdict::Moderate
958 );
959 assert_eq!(grade_hsts(Some("max-age=300"), true).0, HeaderVerdict::Weak);
960 assert_eq!(grade_hsts(Some("max-age=0"), true).0, HeaderVerdict::Weak);
962 assert_eq!(
963 grade_hsts(Some("includeSubDomains"), true).0,
964 HeaderVerdict::Weak
965 );
966 }
967
968 #[test]
971 fn hsts_max_age_beyond_u64_is_a_long_max_age() {
972 let huge = "max-age=99999999999999999999999999; includeSubDomains";
973 assert_eq!(grade_hsts(Some(huge), true).0, HeaderVerdict::Strict);
974 assert_eq!(
976 grade_hsts(Some("max-age=1e9; includeSubDomains"), true).0,
977 HeaderVerdict::Weak
978 );
979 assert_eq!(
980 grade_hsts(Some("max-age=-1; includeSubDomains"), true).0,
981 HeaderVerdict::Weak
982 );
983 }
984
985 #[test]
986 fn csp_unsafe_directives_are_weak() {
987 assert_eq!(
988 grade_csp(&csp(&["default-src 'self' 'unsafe-inline'"]), false).0,
989 HeaderVerdict::Weak
990 );
991 assert_eq!(
992 grade_csp(&csp(&["script-src 'unsafe-eval'"]), false).0,
993 HeaderVerdict::Weak
994 );
995 assert_eq!(
996 grade_csp(&csp(&["default-src 'self'"]), false).0,
997 HeaderVerdict::Strict
998 );
999 assert_eq!(
1001 grade_csp(&csp(&["img-src 'self'"]), false).0,
1002 HeaderVerdict::Moderate
1003 );
1004 assert_eq!(grade_csp(&[], false).0, HeaderVerdict::Absent);
1005 assert_eq!(grade_csp(&[], true).0, HeaderVerdict::Weak);
1007 }
1008
1009 #[test]
1011 fn csp_any_origin_script_sources_are_weak() {
1012 for policy in [
1013 "script-src *",
1014 "script-src 'self' https:",
1015 "script-src 'self' http:",
1016 "script-src 'self' data:",
1017 "default-src 'self' *",
1019 ] {
1020 let (verdict, note) = grade_csp(&csp(&[policy]), false);
1021 assert_eq!(verdict, HeaderVerdict::Weak, "{policy}");
1022 assert!(note.is_some(), "{policy}");
1023 }
1024 assert_eq!(
1027 grade_csp(&csp(&["default-src *; script-src 'self'"]), false).0,
1028 HeaderVerdict::Strict
1029 );
1030 assert_eq!(
1032 grade_csp(&csp(&["script-src 'self' https://cdn.example.com"]), false).0,
1033 HeaderVerdict::Strict
1034 );
1035 assert_eq!(
1037 grade_csp(
1038 &csp(&["script-src 'nonce-r4nd' 'strict-dynamic' https: http:"]),
1039 false
1040 )
1041 .0,
1042 HeaderVerdict::Strict
1043 );
1044 }
1045
1046 #[test]
1049 fn any_origin_frame_ancestors_does_not_protect() {
1050 for fa in ["frame-ancestors *", "frame-ancestors https:"] {
1051 assert_eq!(
1052 grade_frame_options(Some("DENY"), &csp(&[fa])).0,
1053 HeaderVerdict::Weak,
1054 "{fa}"
1055 );
1056 assert_eq!(
1057 grade_frame_options(None, &csp(&[fa])).0,
1058 HeaderVerdict::Weak
1059 );
1060 }
1061 assert_eq!(
1063 grade_frame_options(None, &csp(&["frame-ancestors *", "frame-ancestors 'self'"])).0,
1064 HeaderVerdict::Present
1065 );
1066 }
1067
1068 #[test]
1069 fn csp_frame_ancestors_substitutes_for_x_frame_options() {
1070 let (verdict, note) =
1073 grade_frame_options(None, &csp(&["default-src 'self'; frame-ancestors 'none'"]));
1074 assert_eq!(verdict, HeaderVerdict::Present);
1075 assert!(note.unwrap_or_default().contains("frame-ancestors"));
1076
1077 assert_eq!(
1079 grade_frame_options(None, &csp(&["default-src 'self'"])).0,
1080 HeaderVerdict::Absent
1081 );
1082 assert_eq!(
1083 grade_frame_options(Some("DENY"), &[]).0,
1084 HeaderVerdict::Strict
1085 );
1086 assert_eq!(
1087 grade_frame_options(Some("SAMEORIGIN"), &[]).0,
1088 HeaderVerdict::Strict
1089 );
1090 assert_eq!(
1092 grade_frame_options(Some("ALLOW-FROM https://x.com"), &[]).0,
1093 HeaderVerdict::Weak
1094 );
1095 }
1096
1097 #[test]
1098 fn hsts_quoted_max_age_and_insecure_transport() {
1099 assert_eq!(
1101 grade_hsts(Some("max-age=\"31536000\"; includeSubDomains"), true).0,
1102 HeaderVerdict::Strict
1103 );
1104 assert_eq!(
1106 grade_hsts(Some("max-age=31536000; includeSubDomains"), false).0,
1107 HeaderVerdict::Absent
1108 );
1109 let mut response = response_with(&[(
1110 "Strict-Transport-Security",
1111 "max-age=31536000; includeSubDomains",
1112 )]);
1113 response.final_url = "http://example.com/".to_string();
1114 let report = build_report("example.com".into(), &response);
1115 let hsts = report
1116 .headers
1117 .iter()
1118 .find(|h| h.header == "strict-transport-security")
1119 .unwrap();
1120 assert_eq!(hsts.verdict, HeaderVerdict::Absent);
1121 }
1122
1123 #[test]
1124 fn csp_nonce_hash_and_strict_dynamic_neutralize_unsafe_inline() {
1125 assert_eq!(
1128 grade_csp(
1129 &csp(&["script-src 'nonce-r4nd' 'unsafe-inline' 'strict-dynamic' https:; object-src 'none'; base-uri 'none'"]),
1130 false
1131 )
1132 .0,
1133 HeaderVerdict::Strict
1134 );
1135 assert_eq!(
1136 grade_csp(&csp(&["script-src 'sha256-abc=' 'unsafe-inline'"]), false).0,
1137 HeaderVerdict::Strict
1138 );
1139 assert_eq!(
1141 grade_csp(&csp(&["script-src 'nonce-x' 'unsafe-eval'"]), false).0,
1142 HeaderVerdict::Weak
1143 );
1144 assert_eq!(
1147 grade_csp(
1148 &csp(&["default-src 'self'; style-src 'self' 'unsafe-inline'"]),
1149 false
1150 )
1151 .0,
1152 HeaderVerdict::Strict
1153 );
1154 assert_eq!(
1156 grade_csp(
1157 &csp(&["default-src 'self'; script-src 'self' 'unsafe-inline'"]),
1158 false
1159 )
1160 .0,
1161 HeaderVerdict::Weak
1162 );
1163 }
1164
1165 #[test]
1166 fn csp_is_graded_across_every_delivered_policy() {
1167 let response = response_with(&[
1169 ("Content-Security-Policy", "frame-ancestors 'self'"),
1170 ("Content-Security-Policy", "default-src 'self'"),
1171 ]);
1172 let report = build_report("example.com".into(), &response);
1173 let find = |name: &str| {
1174 report
1175 .headers
1176 .iter()
1177 .find(|h| h.header == name)
1178 .unwrap()
1179 .clone()
1180 };
1181 assert_eq!(
1182 find("content-security-policy").verdict,
1183 HeaderVerdict::Strict
1184 );
1185 assert_eq!(find("x-frame-options").verdict, HeaderVerdict::Present);
1187 assert_eq!(
1189 find("content-security-policy").value.as_deref(),
1190 Some("frame-ancestors 'self', default-src 'self'")
1191 );
1192 assert_eq!(
1195 grade_csp(
1196 &csp(&["script-src 'unsafe-inline', script-src 'self'"]),
1197 false
1198 )
1199 .0,
1200 HeaderVerdict::Strict
1201 );
1202 }
1203
1204 #[test]
1205 fn invalid_xfo_is_superseded_by_frame_ancestors() {
1206 let (verdict, note) = grade_frame_options(
1208 Some("ALLOW-FROM https://x.example"),
1209 &csp(&["frame-ancestors 'self'"]),
1210 );
1211 assert_eq!(verdict, HeaderVerdict::Present);
1212 assert!(note.unwrap_or_default().contains("frame-ancestors"));
1213 assert_eq!(
1215 grade_frame_options(Some("ALLOW-FROM https://x.example"), &[]).0,
1216 HeaderVerdict::Weak
1217 );
1218 }
1219
1220 #[test]
1221 fn cross_origin_policies_ignore_structured_header_parameters() {
1222 let coep = [
1223 ("require-corp", HeaderVerdict::Strict),
1224 ("unsafe-none", HeaderVerdict::Weak),
1225 ];
1226 assert_eq!(
1227 grade_enum(Some("require-corp; report-to=\"default\""), &coep, "x").0,
1228 HeaderVerdict::Strict
1229 );
1230 let coop = [
1231 ("same-origin", HeaderVerdict::Strict),
1232 ("same-origin-allow-popups", HeaderVerdict::Moderate),
1233 ];
1234 assert_eq!(
1235 grade_enum(
1236 Some("same-origin-allow-popups; report-to=\"gws\""),
1237 &coop,
1238 "x"
1239 )
1240 .0,
1241 HeaderVerdict::Moderate
1242 );
1243 }
1244
1245 #[test]
1246 fn cookie_name_is_not_mistaken_for_an_attribute() {
1247 let findings = grade_cookies(&[
1248 "secure=1; Path=/".to_string(),
1249 "HttpOnly=yes; SameSite=Lax".to_string(),
1250 ]);
1251 assert_eq!(findings[0].name, "secure");
1252 assert!(!findings[0].secure, "a cookie NAMED secure is not Secure");
1253 assert!(!findings[1].http_only);
1254 assert_eq!(findings[1].same_site.as_deref(), Some("Lax"));
1255 }
1256
1257 #[test]
1258 fn referrer_policy_bands() {
1259 assert_eq!(
1260 grade_referrer_policy(Some("strict-origin-when-cross-origin")).0,
1261 HeaderVerdict::Strict
1262 );
1263 assert_eq!(
1264 grade_referrer_policy(Some("no-referrer")).0,
1265 HeaderVerdict::Strict
1266 );
1267 assert_eq!(
1268 grade_referrer_policy(Some("origin")).0,
1269 HeaderVerdict::Moderate
1270 );
1271 assert_eq!(
1272 grade_referrer_policy(Some("unsafe-url")).0,
1273 HeaderVerdict::Weak
1274 );
1275 assert_eq!(
1278 grade_referrer_policy(Some("no-referrer, strict-origin-when-cross-origin")).0,
1279 HeaderVerdict::Strict
1280 );
1281 assert_eq!(
1282 grade_referrer_policy(Some("no-referrer, unsafe-url")).0,
1283 HeaderVerdict::Weak
1284 );
1285 assert_eq!(
1286 grade_referrer_policy(Some("strict-origin, origin")).0,
1287 HeaderVerdict::Moderate
1288 );
1289 assert_eq!(
1291 grade_referrer_policy(Some("no-referrer, some-future-policy")).0,
1292 HeaderVerdict::Strict
1293 );
1294 assert_eq!(grade_referrer_policy(None).0, HeaderVerdict::Absent);
1295 }
1296
1297 #[test]
1298 fn content_type_options_only_accepts_nosniff() {
1299 assert_eq!(
1300 grade_content_type_options(Some("nosniff")).0,
1301 HeaderVerdict::Strict
1302 );
1303 assert_eq!(
1304 grade_content_type_options(Some("NOSNIFF")).0,
1305 HeaderVerdict::Strict
1306 );
1307 assert_eq!(
1308 grade_content_type_options(Some("sniff")).0,
1309 HeaderVerdict::Weak
1310 );
1311 assert_eq!(grade_content_type_options(None).0, HeaderVerdict::Absent);
1312 }
1313
1314 #[test]
1315 fn cookie_names_preserve_case() {
1316 let findings = grade_cookies(&[
1319 "JSESSIONID=abc; Secure".to_string(),
1320 "WMF-Last-Access=1; Secure; HttpOnly".to_string(),
1321 ]);
1322 assert_eq!(findings[0].name, "JSESSIONID");
1323 assert_eq!(findings[1].name, "WMF-Last-Access");
1324 assert!(findings[1].secure);
1326 assert!(findings[1].http_only);
1327 }
1328
1329 #[test]
1330 fn cookie_flags_are_graded_and_named() {
1331 let findings = grade_cookies(&[
1332 "session=abc; Secure; HttpOnly; SameSite=Strict".to_string(),
1333 "tracker=xyz".to_string(),
1334 "mixed=1; Secure".to_string(),
1335 ]);
1336 assert_eq!(findings[0].name, "session");
1337 assert_eq!(findings[0].verdict, HeaderVerdict::Strict);
1338 assert!(findings[0].issues.is_empty());
1339
1340 assert_eq!(findings[1].name, "tracker");
1342 assert_eq!(findings[1].verdict, HeaderVerdict::Weak);
1343 assert_eq!(findings[1].issues.len(), 3);
1344
1345 assert_eq!(findings[2].verdict, HeaderVerdict::Weak);
1347 assert!(findings[2].secure);
1348 assert!(!findings[2].http_only);
1349 }
1350
1351 #[test]
1352 fn same_site_none_without_secure_is_flagged() {
1353 let findings = grade_cookies(&["c=1; HttpOnly; SameSite=None".to_string()]);
1354 assert!(
1355 findings[0]
1356 .issues
1357 .iter()
1358 .any(|i| i.contains("SameSite=None without Secure")),
1359 "got {:?}",
1360 findings[0].issues
1361 );
1362 }
1363
1364 #[test]
1365 fn version_disclosure_detection() {
1366 assert!(looks_versioned("nginx/1.25.3"));
1367 assert!(looks_versioned("PHP/8.2.1"));
1368 assert!(looks_versioned("Apache/2.4"));
1369 assert!(!looks_versioned("cloudflare"));
1371 assert!(!looks_versioned("nginx"));
1372 }
1373
1374 #[test]
1375 fn perfect_headers_score_a_plus() {
1376 let response = response_with(&[
1377 (
1378 "strict-transport-security",
1379 "max-age=63072000; includeSubDomains; preload",
1380 ),
1381 ("content-security-policy", "default-src 'self'"),
1382 ("x-frame-options", "DENY"),
1383 ("x-content-type-options", "nosniff"),
1384 ("referrer-policy", "no-referrer"),
1385 ("permissions-policy", "geolocation=()"),
1386 ("cross-origin-opener-policy", "same-origin"),
1387 ("cross-origin-embedder-policy", "require-corp"),
1388 ("cross-origin-resource-policy", "same-origin"),
1389 ]);
1390 let report = build_report("example.com".to_string(), &response);
1391 assert_eq!(report.score, 100, "notes: {:?}", report.notes);
1392 assert_eq!(report.grade, "A+");
1393 assert!(report.notes.is_empty(), "got {:?}", report.notes);
1394 }
1395
1396 #[test]
1397 fn bare_response_scores_f_and_lists_every_gap() {
1398 let report = build_report("example.com".to_string(), &response_with(&[]));
1399 assert_eq!(report.score, 0);
1400 assert_eq!(report.grade, "F");
1401 assert_eq!(report.headers.len(), HEADER_WEIGHTS.len());
1403 assert_eq!(report.notes.len(), HEADER_WEIGHTS.len());
1404 assert!(report.headers.iter().all(|h| !h.present));
1405 }
1406
1407 #[test]
1408 fn penalties_apply_but_stay_bounded() {
1409 let cookies = grade_cookies(&(0..10).map(|i| format!("c{i}=1")).collect::<Vec<_>>());
1412 let disclosures = vec![
1413 Disclosure {
1414 header: "server".into(),
1415 value: "nginx/1.0".into(),
1416 versioned: true,
1417 },
1418 Disclosure {
1419 header: "x-powered-by".into(),
1420 value: "PHP/8.0".into(),
1421 versioned: true,
1422 },
1423 Disclosure {
1424 header: "x-generator".into(),
1425 value: "Drupal/9.1".into(),
1426 versioned: true,
1427 },
1428 ];
1429 let headers: Vec<HeaderFinding> = HEADER_WEIGHTS
1431 .iter()
1432 .map(|(name, _)| HeaderFinding {
1433 header: (*name).to_string(),
1434 present: true,
1435 value: None,
1436 verdict: HeaderVerdict::Strict,
1437 note: None,
1438 })
1439 .collect();
1440 let score = compute_score(&headers, &cookies, &disclosures);
1441 assert_eq!(score, 100 - COOKIE_PENALTY_CAP - DISCLOSURE_PENALTY_CAP);
1442
1443 let empty: Vec<HeaderFinding> = Vec::new();
1445 assert_eq!(compute_score(&empty, &cookies, &disclosures), 0);
1446 }
1447
1448 #[test]
1449 fn weights_sum_to_one_hundred() {
1450 let total: u32 = HEADER_WEIGHTS.iter().map(|(_, w)| *w).sum();
1453 assert_eq!(total, 100);
1454 }
1455
1456 #[test]
1457 fn report_captures_cookies_and_disclosures_from_response() {
1458 let mut response = response_with(&[
1459 ("server", "nginx/1.25.3"),
1460 ("x-powered-by", "PHP/8.2"),
1461 ("x-content-type-options", "nosniff"),
1462 ]);
1463 response.headers.push((
1465 "set-cookie".into(),
1466 "a=1; Secure; HttpOnly; SameSite=Lax".into(),
1467 ));
1468 response.headers.push(("set-cookie".into(), "b=2".into()));
1469
1470 let report = build_report("example.com".to_string(), &response);
1471 assert_eq!(report.cookies.len(), 2);
1472 assert_eq!(report.disclosures.len(), 2);
1473 assert!(report.disclosures.iter().all(|d| d.versioned));
1474 assert!(report.notes.iter().any(|n| n.contains("cookie 'b'")));
1475 assert!(report.notes.iter().any(|n| n.contains("CVE")));
1476 }
1477
1478 #[test]
1479 fn header_values_are_preserved_for_display() {
1480 let response = response_with(&[("x-frame-options", "SAMEORIGIN")]);
1481 let report = build_report("example.com".to_string(), &response);
1482 let xfo = report
1483 .headers
1484 .iter()
1485 .find(|h| h.header == "x-frame-options")
1486 .expect("x-frame-options finding");
1487 assert!(xfo.present);
1488 assert_eq!(xfo.value.as_deref(), Some("SAMEORIGIN"));
1489 }
1490}