use serde::Deserialize;
use std::collections::HashSet;
use std::net::IpAddr;
use std::str::FromStr;
use std::sync::OnceLock;
use crate::util::{bounded_text, is_routable_ip};
use crate::OriginCandidate;
use gossan_core::Config;
#[derive(Debug, Clone, Deserialize)]
pub struct LeakHeader {
pub name: String,
pub confidence: u8,
pub description: String,
}
#[derive(Debug, Deserialize)]
struct LeakHeadersFile {
header: Vec<LeakHeader>,
}
const BUILTIN_LEAK_HEADERS: &str = include_str!("../../rules/leak_headers.toml");
static LEAK_HEADERS: OnceLock<Vec<LeakHeader>> = OnceLock::new();
pub fn builtin_leak_headers() -> &'static Vec<LeakHeader> {
LEAK_HEADERS.get_or_init(|| {
match toml::from_str::<LeakHeadersFile>(BUILTIN_LEAK_HEADERS) {
Ok(file) => file.header,
Err(e) => {
tracing::error!(error = %e, "failed to parse built-in leak_headers.toml");
vec![
LeakHeader {
name: "x-served-by".to_string(),
confidence: 85,
description: "CDN backend identifier".to_string(),
},
LeakHeader {
name: "x-backend-server".to_string(),
confidence: 90,
description: "Backend server name".to_string(),
},
LeakHeader {
name: "x-real-ip".to_string(),
confidence: 85,
description: "Original client IP".to_string(),
},
]
}
}
})
}
pub fn leak_headers() -> &'static [LeakHeader] {
builtin_leak_headers()
}
pub async fn scan(
domain: String,
config: &Config,
client: &gossan_core::ScanClient,
) -> anyhow::Result<Vec<OriginCandidate>> {
let mut candidates = Vec::new();
let mut seen_ips = HashSet::new();
let urls = [format!("https://{}", domain), format!("http://{}", domain)];
let limit = config.max_response_size.min(crate::MAX_ORIGIN_HEADER_BYTES).max(1024);
for url in &urls {
let response = match client.get(url).await {
Ok(r) => r,
Err(e) => {
tracing::warn!(url = %url, error = %e, "http_header request failed");
continue;
}
};
for header in leak_headers() {
if let Some(value) = response.headers().get(&header.name) {
let val_str = match value.to_str() {
Ok(s) => s.to_string(),
Err(e) => {
tracing::warn!(
url = %url,
header = %header.name,
error = %e,
"http_header value is not valid UTF-8"
);
continue;
}
};
let cleaned_val = val_str.replace('"', "").replace('\'', "");
for raw_token in cleaned_val.split(|c: char| c == ',' || c == ';' || c == '(' || c == ')' || c.is_whitespace()) {
let mut token = raw_token.trim().to_string();
if token.is_empty() {
continue;
}
if token.starts_with('[') && token.ends_with(']') {
token = token[1..token.len() - 1].to_string();
}
if token.starts_with('[') {
if let Some(close_bracket_idx) = token.find(']') {
let ip_part = &token[1..close_bracket_idx];
token = ip_part.to_string();
}
} else {
if let Some(colon_idx) = token.rfind(':') {
let port_part = &token[colon_idx + 1..];
let ip_part = &token[..colon_idx];
if (!ip_part.contains(':') || ip_part.starts_with('[') || IpAddr::from_str(ip_part).is_ok())
&& !port_part.is_empty()
&& port_part.chars().all(|c| c.is_ascii_digit())
{
token = ip_part.to_string();
}
}
}
if let Ok(ip) = IpAddr::from_str(&token) {
if !is_routable_ip(ip) {
continue;
}
if seen_ips.insert(ip) {
let confidence = match header.name.as_str() {
"x-backend-server" | "x-backend" | "x-origin-server" => 90,
"x-served-by" | "x-real-ip" => 85,
"x-host" | "x-forwarded-server" => 80,
"via" | "server" => 50,
_ => header.confidence,
};
candidates.push(OriginCandidate::new(
ip,
format!(
"http_header_leak ({}: {}. {})",
header.name, val_str, header.description
),
confidence,
));
}
}
}
}
}
if let Err(e) = bounded_text(response, limit).await {
tracing::debug!(error = %e, "http_header body drain failed after header parse");
}
}
Ok(candidates)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leak_headers_load_from_toml() {
let headers = leak_headers();
assert!(!headers.is_empty(), "should have leak headers from TOML");
assert!(
headers.iter().any(|h| h.name == "x-served-by"),
"should include x-served-by header"
);
}
#[test]
fn leak_headers_have_required_fields() {
for header in leak_headers() {
assert!(!header.name.is_empty(), "header name should not be empty");
assert!(header.confidence > 0, "confidence should be > 0");
assert!(header.confidence <= 100, "confidence should be <= 100");
}
}
#[test]
fn leak_headers_include_critical_ones() {
let names: Vec<_> = leak_headers().iter().map(|h| h.name.clone()).collect();
for expected in ["x-served-by", "x-backend-server", "x-real-ip", "cf-ray"] {
assert!(
names.contains(&expected.to_string()),
"missing header: {}",
expected
);
}
}
#[test]
fn high_confidence_headers_prioritized() {
let high_confidence: Vec<_> = leak_headers()
.iter()
.filter(|h| h.confidence >= 80)
.map(|h| h.name.clone())
.collect();
assert!(
high_confidence.contains(&"x-backend-server".to_string()),
"x-backend-server should have high confidence"
);
assert!(
high_confidence.contains(&"x-served-by".to_string()),
"x-served-by should have high confidence"
);
}
}