use crate::http_client::{HttpClient, HttpResponse};
use crate::types::{Confidence, ScanConfig, Severity, Vulnerability};
use anyhow::Result;
use std::sync::Arc;
use tracing::{debug, info};
pub struct SecurityHeadersScanner {
http_client: Arc<HttpClient>,
}
impl SecurityHeadersScanner {
pub fn new(http_client: Arc<HttpClient>) -> Self {
Self { http_client }
}
pub async fn scan(
&self,
url: &str,
_config: &ScanConfig,
) -> Result<(Vec<Vulnerability>, usize)> {
info!("[Security Headers] Scanning: {}", url);
let mut vulnerabilities = Vec::new();
let tests_run = 1;
match self.http_client.get(url).await {
Ok(response) => {
if response.status_code == 404 {
debug!("[Security Headers] Skipping 404 response: {}", url);
return Ok((vulnerabilities, tests_run));
}
if self.is_not_found_response(&response.body) {
debug!("[Security Headers] Skipping not-found error response: {}", url);
return Ok((vulnerabilities, tests_run));
}
if response.status_code >= 500 {
debug!("[Security Headers] Skipping server error response: {}", url);
return Ok((vulnerabilities, tests_run));
}
let is_api_response = self.is_api_or_non_html_response(&response);
self.check_hsts(&response, url, &mut vulnerabilities);
self.check_cors_headers(&response, url, &mut vulnerabilities);
if !is_api_response {
self.check_csp(&response, url, &mut vulnerabilities);
self.check_x_frame_options(&response, url, &mut vulnerabilities);
self.check_x_content_type_options(&response, url, &mut vulnerabilities);
self.check_x_xss_protection(&response, url, &mut vulnerabilities);
self.check_referrer_policy(&response, url, &mut vulnerabilities);
self.check_permissions_policy(&response, url, &mut vulnerabilities);
}
}
Err(e) => {
debug!("Failed to fetch URL for header check: {}", e);
}
}
info!(
"[SUCCESS] [Security Headers] Completed scan, found {} issues",
vulnerabilities.len()
);
Ok((vulnerabilities, tests_run))
}
fn is_api_or_non_html_response(&self, response: &HttpResponse) -> bool {
if let Some(content_type) = response.header("content-type") {
let ct_lower = content_type.to_lowercase();
if ct_lower.contains("application/json")
|| ct_lower.contains("application/xml")
|| ct_lower.contains("text/xml")
|| ct_lower.contains("text/plain")
|| ct_lower.contains("application/octet-stream")
|| ct_lower.contains("image/")
|| ct_lower.contains("font/")
|| ct_lower.contains("application/pdf")
|| ct_lower.contains("application/javascript")
{
return true;
}
}
let body_trimmed = response.body.trim();
if (body_trimmed.starts_with('{') && body_trimmed.ends_with('}'))
|| (body_trimmed.starts_with('[') && body_trimmed.ends_with(']'))
|| (body_trimmed.starts_with("<?xml") && body_trimmed.contains("?>"))
{
return true;
}
false
}
fn is_not_found_response(&self, body: &str) -> bool {
let body_lower = body.to_lowercase();
let not_found_patterns = [
"\"error\":\"not found\"",
"\"error\": \"not found\"",
"\"message\":\"the requested resource does not exist\"",
"\"message\": \"the requested resource does not exist\"",
"resource does not exist",
"endpoint not found",
"route not found",
"\"status\":\"not_found\"",
"\"status\": \"not_found\"",
"\"code\":404",
"\"code\": 404",
];
for pattern in ¬_found_patterns {
if body_lower.contains(pattern) {
return true;
}
}
if body_lower.contains("\"success\":false") || body_lower.contains("\"success\": false") {
if body_lower.contains("not found") || body_lower.contains("does not exist") {
return true;
}
}
false
}
fn check_hsts(
&self,
response: &HttpResponse,
url: &str,
vulnerabilities: &mut Vec<Vulnerability>,
) {
if let Some(hsts) = response.header("strict-transport-security") {
if hsts.contains("max-age") {
if hsts.contains("max-age=0") || hsts.contains("max-age=1") {
vulnerabilities.push(self.create_vulnerability(
"Weak HSTS Configuration",
url,
Severity::Medium,
Confidence::High,
"HSTS max-age is too short (less than 1 year recommended)",
format!("HSTS header found but weak: {}", hsts),
5.0,
));
}
}
} else if url.starts_with("https") {
vulnerabilities.push(self.create_vulnerability(
"Missing HSTS Header",
url,
Severity::Medium,
Confidence::High,
"HTTP Strict Transport Security (HSTS) header is missing",
"HTTPS site without HSTS is vulnerable to SSL stripping attacks".to_string(),
5.3,
));
}
}
fn check_csp(
&self,
response: &HttpResponse,
url: &str,
vulnerabilities: &mut Vec<Vulnerability>,
) {
if let Some(csp) = response.header("content-security-policy") {
if csp.contains("unsafe-inline") || csp.contains("unsafe-eval") {
vulnerabilities.push(self.create_vulnerability(
"Weak CSP Configuration",
url,
Severity::Medium,
Confidence::High,
"Content Security Policy allows unsafe-inline or unsafe-eval",
format!("CSP: {}", csp),
5.0,
));
}
if csp.contains("* ") || csp.contains(" *") {
vulnerabilities.push(self.create_vulnerability(
"Permissive CSP Configuration",
url,
Severity::Low,
Confidence::High,
"Content Security Policy uses wildcard (*) allowing any source",
format!("CSP contains wildcard: {}", csp),
4.0,
));
}
} else {
vulnerabilities.push(self.create_vulnerability(
"Missing CSP Header",
url,
Severity::Medium,
Confidence::High,
"Content Security Policy (CSP) header is missing",
"No CSP protection against XSS and data injection attacks".to_string(),
5.3,
));
}
}
fn check_x_frame_options(
&self,
_response: &HttpResponse,
_url: &str,
_vulnerabilities: &mut Vec<Vulnerability>,
) {
}
fn check_x_content_type_options(
&self,
response: &HttpResponse,
url: &str,
vulnerabilities: &mut Vec<Vulnerability>,
) {
if response.header("x-content-type-options").is_none() {
vulnerabilities.push(self.create_vulnerability(
"Missing X-Content-Type-Options",
url,
Severity::Low,
Confidence::High,
"X-Content-Type-Options header is missing",
"Browsers may MIME-sniff content, leading to security issues".to_string(),
3.1,
));
}
}
fn check_x_xss_protection(
&self,
response: &HttpResponse,
url: &str,
vulnerabilities: &mut Vec<Vulnerability>,
) {
if let Some(xss_protection) = response.header("x-xss-protection") {
if xss_protection == "0" {
vulnerabilities.push(self.create_vulnerability(
"XSS Protection Disabled",
url,
Severity::Medium,
Confidence::High,
"X-XSS-Protection explicitly disabled (set to 0)",
"Browser XSS filter is turned off".to_string(),
4.0,
));
}
}
}
fn check_referrer_policy(
&self,
response: &HttpResponse,
url: &str,
vulnerabilities: &mut Vec<Vulnerability>,
) {
if let Some(referrer) = response.header("referrer-policy") {
if referrer.contains("unsafe-url") {
vulnerabilities.push(self.create_vulnerability(
"Weak Referrer Policy",
url,
Severity::Low,
Confidence::High,
"Referrer-Policy set to 'unsafe-url' leaks full URLs to third parties",
format!("Referrer-Policy: {}", referrer),
3.1,
));
}
}
}
fn check_permissions_policy(
&self,
_response: &HttpResponse,
_url: &str,
_vulnerabilities: &mut Vec<Vulnerability>,
) {
}
fn check_cors_headers(
&self,
_response: &HttpResponse,
_url: &str,
_vulnerabilities: &mut Vec<Vulnerability>,
) {
}
fn create_vulnerability(
&self,
title: &str,
url: &str,
severity: Severity,
confidence: Confidence,
description: &str,
evidence: String,
cvss: f32,
) -> Vulnerability {
Vulnerability {
id: format!("header_{}", uuid::Uuid::new_v4().to_string()),
vuln_type: format!("Security Header Misconfiguration - {}", title),
severity,
confidence,
category: "Configuration".to_string(),
url: url.to_string(),
parameter: None,
payload: String::new(),
description: description.to_string(),
evidence: Some(evidence),
cwe: "CWE-16".to_string(), cvss,
verified: true,
false_positive: false,
remediation: format!(
r#"Configure proper security headers:
For {}:
- HSTS: Set Strict-Transport-Security with max-age=31536000; includeSubDomains; preload
- CSP: Implement strict Content-Security-Policy without unsafe-inline/unsafe-eval
- X-Frame-Options: Set to DENY or SAMEORIGIN, or use CSP frame-ancestors
- X-Content-Type-Options: Set to nosniff
- Referrer-Policy: Use strict-origin-when-cross-origin or no-referrer
- Permissions-Policy: Restrict unnecessary browser features
Recommended configuration (Nginx example):
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
"#,
title
),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_confidence: None,
ml_data: None,
}
}
}
mod uuid {
use rand::Rng;
pub struct Uuid;
impl Uuid {
pub fn new_v4() -> Self {
Self
}
pub fn to_string(&self) -> String {
let mut rng = rand::rng();
format!(
"{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
rng.random::<u32>(),
rng.random::<u16>(),
rng.random::<u16>(),
rng.random::<u16>(),
rng.random::<u64>() & 0xffffffffffff
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_missing_hsts() {
let scanner = SecurityHeadersScanner::new(Arc::new(HttpClient::new(5, 2).unwrap()));
let response = HttpResponse {
status_code: 200,
body: String::new(),
headers: HashMap::new(),
duration_ms: 100,
};
let mut vulns = Vec::new();
scanner.check_hsts(&response, "https://example.com", &mut vulns);
assert_eq!(vulns.len(), 1, "Should detect missing HSTS");
assert_eq!(vulns[0].severity, Severity::Medium);
}
#[test]
fn test_hsts_with_includesubdomains_no_false_positive() {
let scanner = SecurityHeadersScanner::new(Arc::new(HttpClient::new(5, 2).unwrap()));
let mut headers = HashMap::new();
headers.insert(
"strict-transport-security".to_string(),
"max-age=31536000".to_string(),
);
let response = HttpResponse {
status_code: 200,
body: String::new(),
headers,
duration_ms: 100,
};
let mut vulns = Vec::new();
scanner.check_hsts(&response, "https://example.com", &mut vulns);
assert_eq!(
vulns.len(),
0,
"Should NOT report missing includeSubDomains as a vulnerability"
);
}
#[test]
fn test_missing_csp_on_html() {
let scanner = SecurityHeadersScanner::new(Arc::new(HttpClient::new(5, 2).unwrap()));
let response = HttpResponse {
status_code: 200,
body: String::new(),
headers: HashMap::new(),
duration_ms: 100,
};
let mut vulns = Vec::new();
scanner.check_csp(&response, "https://example.com", &mut vulns);
assert_eq!(vulns.len(), 1, "Should detect missing CSP on HTML response");
}
#[test]
fn test_no_false_positives_on_api_response() {
let scanner = SecurityHeadersScanner::new(Arc::new(HttpClient::new(5, 2).unwrap()));
let mut headers = HashMap::new();
headers.insert(
"content-type".to_string(),
"application/json".to_string(),
);
let response = HttpResponse {
status_code: 200,
body: "{\"status\": \"ok\"}".to_string(),
headers,
duration_ms: 100,
};
assert!(
scanner.is_api_or_non_html_response(&response),
"JSON response should be detected as API"
);
}
#[test]
fn test_no_false_positives_on_json_body() {
let scanner = SecurityHeadersScanner::new(Arc::new(HttpClient::new(5, 2).unwrap()));
let response = HttpResponse {
status_code: 200,
body: "{\"data\": [1, 2, 3]}".to_string(),
headers: HashMap::new(),
duration_ms: 100,
};
assert!(
scanner.is_api_or_non_html_response(&response),
"JSON body should be detected as API even without content-type"
);
}
#[test]
fn test_missing_referrer_policy_not_reported() {
let scanner = SecurityHeadersScanner::new(Arc::new(HttpClient::new(5, 2).unwrap()));
let response = HttpResponse {
status_code: 200,
body: String::new(),
headers: HashMap::new(),
duration_ms: 100,
};
let mut vulns = Vec::new();
scanner.check_referrer_policy(&response, "https://example.com", &mut vulns);
assert_eq!(
vulns.len(),
0,
"Should NOT report missing Referrer-Policy (browsers have safe defaults)"
);
}
#[test]
fn test_unsafe_url_referrer_policy_reported() {
let scanner = SecurityHeadersScanner::new(Arc::new(HttpClient::new(5, 2).unwrap()));
let mut headers = HashMap::new();
headers.insert(
"referrer-policy".to_string(),
"unsafe-url".to_string(),
);
let response = HttpResponse {
status_code: 200,
body: String::new(),
headers,
duration_ms: 100,
};
let mut vulns = Vec::new();
scanner.check_referrer_policy(&response, "https://example.com", &mut vulns);
assert_eq!(
vulns.len(),
1,
"Should report explicitly dangerous unsafe-url Referrer-Policy"
);
}
#[test]
fn test_missing_permissions_policy_not_reported() {
let scanner = SecurityHeadersScanner::new(Arc::new(HttpClient::new(5, 2).unwrap()));
let response = HttpResponse {
status_code: 200,
body: String::new(),
headers: HashMap::new(),
duration_ms: 100,
};
let mut vulns = Vec::new();
scanner.check_permissions_policy(&response, "https://example.com", &mut vulns);
assert_eq!(
vulns.len(),
0,
"Should NOT report missing Permissions-Policy (not a vulnerability)"
);
}
#[test]
fn test_weak_csp() {
let scanner = SecurityHeadersScanner::new(Arc::new(HttpClient::new(5, 2).unwrap()));
let mut headers = HashMap::new();
headers.insert(
"content-security-policy".to_string(),
"default-src 'self' 'unsafe-inline'".to_string(),
);
let response = HttpResponse {
status_code: 200,
body: String::new(),
headers,
duration_ms: 100,
};
let mut vulns = Vec::new();
scanner.check_csp(&response, "https://example.com", &mut vulns);
assert_eq!(vulns.len(), 1, "Should detect unsafe-inline in CSP");
}
#[test]
fn test_cors_handled_by_dedicated_scanner() {
let scanner = SecurityHeadersScanner::new(Arc::new(HttpClient::new(5, 2).unwrap()));
let mut headers = HashMap::new();
headers.insert("access-control-allow-origin".to_string(), "*".to_string());
headers.insert(
"access-control-allow-credentials".to_string(),
"true".to_string(),
);
let response = HttpResponse {
status_code: 200,
body: String::new(),
headers,
duration_ms: 100,
};
let mut vulns = Vec::new();
scanner.check_cors_headers(&response, "https://example.com", &mut vulns);
assert_eq!(
vulns.len(),
0,
"CORS should not be reported here - handled by dedicated CorsScanner"
);
}
}