use super::ja4::{HttpHeaders, Ja4Fingerprint, Ja4Protocol};
use std::borrow::Cow;
pub const MAX_USER_AGENT_LENGTH: usize = 512;
pub const MAX_HEADER_LENGTH: usize = 256;
pub const MAX_SEC_CH_UA_LENGTH: usize = 1024;
#[derive(Debug, Clone, Default)]
pub struct IntegrityAnalysis {
pub suspicion_score: u8,
pub inconsistencies: Vec<Cow<'static, str>>,
pub has_client_hints: bool,
pub has_fetch_metadata: bool,
pub input_truncated: bool,
}
#[inline]
fn saturating_add_score(score: &mut u8, delta: u8) {
*score = score.saturating_add(delta).min(100);
}
#[inline]
fn truncate_header(value: &str, max_len: usize) -> (&str, bool) {
if value.len() > max_len {
let truncated = &value[..value.floor_char_boundary(max_len)];
(truncated, true)
} else {
(value, false)
}
}
pub fn analyze_integrity(request: &HttpHeaders<'_>) -> IntegrityAnalysis {
let mut result = IntegrityAnalysis::default();
let mut ua = "";
let mut sec_ch_ua = "";
let mut sec_fetch_site = "";
let mut sec_fetch_mode = "";
let mut referer = "";
let mut host = "";
let mut any_truncated = false;
for (name, value) in request.headers {
let Ok(value_str) = value.to_str() else {
continue;
};
match name.as_str() {
"user-agent" => {
let (truncated, was_truncated) = truncate_header(value_str, MAX_USER_AGENT_LENGTH);
ua = truncated;
any_truncated |= was_truncated;
}
"sec-ch-ua" => {
let (truncated, was_truncated) = truncate_header(value_str, MAX_SEC_CH_UA_LENGTH);
sec_ch_ua = truncated;
any_truncated |= was_truncated;
result.has_client_hints = true;
}
"sec-fetch-site" => {
let (truncated, was_truncated) = truncate_header(value_str, MAX_HEADER_LENGTH);
sec_fetch_site = truncated;
any_truncated |= was_truncated;
result.has_fetch_metadata = true;
}
"sec-fetch-mode" => {
let (truncated, was_truncated) = truncate_header(value_str, MAX_HEADER_LENGTH);
sec_fetch_mode = truncated;
any_truncated |= was_truncated;
}
"referer" => {
let (truncated, was_truncated) = truncate_header(value_str, MAX_HEADER_LENGTH);
referer = truncated;
any_truncated |= was_truncated;
}
"host" => {
let (truncated, was_truncated) = truncate_header(value_str, MAX_HEADER_LENGTH);
host = truncated;
any_truncated |= was_truncated;
}
_ => {}
}
}
result.input_truncated = any_truncated;
if any_truncated {
result
.inconsistencies
.push(Cow::Borrowed("Header exceeds maximum allowed length"));
saturating_add_score(&mut result.suspicion_score, 20);
}
if !result.has_client_hints && (ua.contains("Chrome/") || ua.contains("Edg/")) {
if ua.contains("Chrome/12") || ua.contains("Chrome/13") {
result
.inconsistencies
.push(Cow::Borrowed("Missing Client Hints for modern Chrome/Edge"));
saturating_add_score(&mut result.suspicion_score, 30);
}
}
if result.has_client_hints {
if ua.contains("Firefox") && !ua.contains("Seamonkey") {
if sec_ch_ua.contains("Chromium") {
result.inconsistencies.push(Cow::Borrowed(
"Firefox User-Agent sent Chromium Client Hints",
));
saturating_add_score(&mut result.suspicion_score, 50);
}
}
}
if result.has_fetch_metadata {
if sec_fetch_site == "same-origin" && !referer.is_empty() {
if !referer.contains(host) && !host.is_empty() {
result.inconsistencies.push(Cow::Borrowed(
"Sec-Fetch-Site: same-origin but Referer mismatch",
));
saturating_add_score(&mut result.suspicion_score, 40);
}
}
if sec_fetch_mode == "navigate"
&& request.headers.iter().any(|(name, value)| {
name.as_str() == "sec-fetch-dest"
&& value
.to_str()
.ok()
.map(|v| v != "document")
.unwrap_or(false)
})
{
}
}
result
}
#[derive(Debug)]
pub struct BrowserJa4Profile {
pub min_tls_version: u8,
pub max_tls_version: u8,
pub min_ciphers: u8,
pub max_ciphers: u8,
pub min_extensions: u8,
pub max_extensions: u8,
pub expected_alpn: &'static [&'static str],
}
const CHROME_PROFILE: BrowserJa4Profile = BrowserJa4Profile {
min_tls_version: 12,
max_tls_version: 13,
min_ciphers: 10,
max_ciphers: 25,
min_extensions: 12,
max_extensions: 25,
expected_alpn: &["h2", "h3"],
};
const FIREFOX_PROFILE: BrowserJa4Profile = BrowserJa4Profile {
min_tls_version: 12,
max_tls_version: 13,
min_ciphers: 8,
max_ciphers: 20,
min_extensions: 10,
max_extensions: 22,
expected_alpn: &["h2", "h3", "http/1.1"],
};
const SAFARI_PROFILE: BrowserJa4Profile = BrowserJa4Profile {
min_tls_version: 12,
max_tls_version: 13,
min_ciphers: 8,
max_ciphers: 20,
min_extensions: 8,
max_extensions: 18,
expected_alpn: &["h2", "http/1.1"],
};
const EDGE_PROFILE: BrowserJa4Profile = BrowserJa4Profile {
min_tls_version: 12,
max_tls_version: 13,
min_ciphers: 10,
max_ciphers: 25,
min_extensions: 12,
max_extensions: 25,
expected_alpn: &["h2", "h3"],
};
#[derive(Debug, Clone, Default)]
pub struct Ja4SpoofingAnalysis {
pub spoofing_confidence: u8,
pub likely_spoofed: bool,
pub inconsistencies: Vec<Cow<'static, str>>,
pub claimed_browser: String,
pub estimated_actual: String,
}
pub fn analyze_ja4_spoofing(ja4: &Ja4Fingerprint, user_agent: &str) -> Ja4SpoofingAnalysis {
let mut result = Ja4SpoofingAnalysis::default();
let (ua, truncated) = truncate_header(user_agent, MAX_USER_AGENT_LENGTH);
if truncated {
result
.inconsistencies
.push(Cow::Borrowed("User-Agent exceeds maximum length"));
saturating_add_score(&mut result.spoofing_confidence, 10);
}
let claimed_browser = detect_browser_from_ua(ua);
result.claimed_browser = claimed_browser.clone();
let profile = match claimed_browser.as_str() {
"chrome" => Some(&CHROME_PROFILE),
"firefox" => Some(&FIREFOX_PROFILE),
"safari" => Some(&SAFARI_PROFILE),
"edge" => Some(&EDGE_PROFILE),
_ => None,
};
if let Some(profile) = profile {
validate_against_profile(ja4, profile, &claimed_browser, &mut result);
} else {
validate_generic_client(ja4, &mut result);
}
result.estimated_actual = estimate_actual_client(ja4);
if !claimed_browser.is_empty()
&& claimed_browser != "unknown"
&& result.estimated_actual != "unknown"
&& !result.estimated_actual.contains(&claimed_browser)
&& claimed_browser != result.estimated_actual
{
result.inconsistencies.push(Cow::Owned(format!(
"Claimed {} but JA4 indicates {}",
claimed_browser, result.estimated_actual
)));
saturating_add_score(&mut result.spoofing_confidence, 25);
}
result.likely_spoofed = result.spoofing_confidence >= 50;
result
}
fn detect_browser_from_ua(ua: &str) -> String {
let ua_lower = ua.to_lowercase();
if ua_lower.contains("edg/") || ua_lower.contains("edge/") {
return "edge".to_string();
}
if ua_lower.contains("chrome/") && !ua_lower.contains("chromium") {
return "chrome".to_string();
}
if ua_lower.contains("firefox/") {
return "firefox".to_string();
}
if ua_lower.contains("safari/") && !ua_lower.contains("chrome") {
return "safari".to_string();
}
if ua_lower.contains("curl/") || ua_lower.contains("wget/") {
return "cli-tool".to_string();
}
if ua_lower.contains("python") || ua_lower.contains("requests/") {
return "python".to_string();
}
if ua_lower.contains("go-http-client") || ua_lower.contains("golang") {
return "golang".to_string();
}
"unknown".to_string()
}
fn validate_against_profile(
ja4: &Ja4Fingerprint,
profile: &BrowserJa4Profile,
browser_name: &str,
result: &mut Ja4SpoofingAnalysis,
) {
if ja4.tls_version < profile.min_tls_version {
result.inconsistencies.push(Cow::Owned(format!(
"TLS 1.{} too old for modern {} (expected 1.{}-1.{})",
ja4.tls_version - 10,
browser_name,
profile.min_tls_version - 10,
profile.max_tls_version - 10
)));
saturating_add_score(&mut result.spoofing_confidence, 30);
}
if ja4.cipher_count < profile.min_ciphers {
result.inconsistencies.push(Cow::Owned(format!(
"Only {} ciphers offered, {} typically offers {}-{}",
ja4.cipher_count, browser_name, profile.min_ciphers, profile.max_ciphers
)));
saturating_add_score(&mut result.spoofing_confidence, 25);
}
if ja4.ext_count < profile.min_extensions {
result.inconsistencies.push(Cow::Owned(format!(
"Only {} extensions offered, {} typically offers {}-{}",
ja4.ext_count, browser_name, profile.min_extensions, profile.max_extensions
)));
saturating_add_score(&mut result.spoofing_confidence, 25);
}
let alpn_matches = profile
.expected_alpn
.iter()
.any(|&a| ja4.alpn.contains(a) || a == ja4.alpn);
if !alpn_matches && ja4.alpn != "unknown" {
result.inconsistencies.push(Cow::Owned(format!(
"ALPN '{}' unexpected for {} (expected {:?})",
ja4.alpn, browser_name, profile.expected_alpn
)));
saturating_add_score(&mut result.spoofing_confidence, 15);
}
if ja4.protocol == Ja4Protocol::QUIC
&& (browser_name == "chrome" || browser_name == "edge")
&& ja4.alpn != "h3"
{
result.inconsistencies.push(Cow::Borrowed(
"QUIC connection without H3 ALPN for Chromium browser",
));
saturating_add_score(&mut result.spoofing_confidence, 20);
}
}
fn validate_generic_client(ja4: &Ja4Fingerprint, result: &mut Ja4SpoofingAnalysis) {
if ja4.cipher_count < 3 {
result.inconsistencies.push(Cow::Borrowed(
"Extremely low cipher count (<3) indicates minimal TLS client",
));
saturating_add_score(&mut result.spoofing_confidence, 40);
}
if ja4.ext_count < 3 {
result.inconsistencies.push(Cow::Borrowed(
"Extremely low extension count (<3) indicates minimal TLS client",
));
saturating_add_score(&mut result.spoofing_confidence, 40);
}
if ja4.tls_version < 12 {
result.inconsistencies.push(Cow::Owned(format!(
"TLS 1.{} is deprecated and insecure",
ja4.tls_version - 10
)));
saturating_add_score(&mut result.spoofing_confidence, 30);
}
}
fn estimate_actual_client(ja4: &Ja4Fingerprint) -> String {
if ja4.cipher_count < 5 && ja4.ext_count < 5 {
return "minimal-client".to_string();
}
if ja4.tls_version < 12 {
return "legacy-client".to_string();
}
if ja4.tls_version >= 12 && ja4.cipher_count >= 10 && ja4.ext_count >= 10 {
if ja4.alpn == "h2" || ja4.alpn == "h3" {
return "modern-browser".to_string();
}
return "modern-client".to_string();
}
if ja4.cipher_count >= 5 && ja4.ext_count >= 5 {
return "api-client".to_string();
}
"unknown".to_string()
}
pub fn analyze_integrity_with_ja4(
request: &HttpHeaders<'_>,
ja4: Option<&Ja4Fingerprint>,
) -> IntegrityAnalysis {
let mut result = analyze_integrity(request);
if let Some(ja4) = ja4 {
let user_agent = request
.headers
.iter()
.find(|(name, _)| name.as_str() == "user-agent")
.and_then(|(_, value)| value.to_str().ok())
.unwrap_or("");
let ja4_analysis = analyze_ja4_spoofing(ja4, user_agent);
for inconsistency in ja4_analysis.inconsistencies {
result.inconsistencies.push(inconsistency);
}
saturating_add_score(
&mut result.suspicion_score,
ja4_analysis.spoofing_confidence / 2,
);
if ja4_analysis.likely_spoofed {
saturating_add_score(&mut result.suspicion_score, 30);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use http::header::{HeaderName, HeaderValue};
fn header(name: &str, value: &str) -> (HeaderName, HeaderValue) {
let header_name = HeaderName::from_bytes(name.as_bytes()).expect("valid header name");
let header_value = HeaderValue::from_str(value).expect("valid header value");
(header_name, header_value)
}
#[test]
fn test_chrome_missing_hints() {
let headers = vec![
header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
),
];
let req = HttpHeaders {
headers: &headers,
method: "GET",
http_version: "1.1",
};
let result = analyze_integrity(&req);
assert!(result.suspicion_score > 0);
let all_inconsistencies: String =
result.inconsistencies.iter().map(|c| c.as_ref()).collect();
assert!(all_inconsistencies.contains("Missing Client Hints"));
}
#[test]
fn test_consistent_chrome() {
let headers = vec![
header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
),
header("Sec-CH-UA", "\"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\""),
];
let req = HttpHeaders {
headers: &headers,
method: "GET",
http_version: "1.1",
};
let result = analyze_integrity(&req);
assert_eq!(result.suspicion_score, 0);
assert!(result.has_client_hints);
}
#[test]
fn test_oversized_user_agent_truncated() {
let oversized_ua = "A".repeat(MAX_USER_AGENT_LENGTH + 100);
let headers = vec![header("User-Agent", &oversized_ua)];
let req = HttpHeaders {
headers: &headers,
method: "GET",
http_version: "1.1",
};
let result = analyze_integrity(&req);
assert!(result.input_truncated);
assert!(result.suspicion_score >= 20);
let all_inconsistencies: String =
result.inconsistencies.iter().map(|c| c.as_ref()).collect();
assert!(all_inconsistencies.contains("exceeds maximum"));
}
#[test]
fn test_suspicion_score_saturates_at_100() {
let mut score: u8 = 90;
saturating_add_score(&mut score, 50);
assert_eq!(score, 100);
}
fn make_test_ja4(
tls_version: u8,
cipher_count: u8,
ext_count: u8,
alpn: &str,
) -> Ja4Fingerprint {
Ja4Fingerprint {
raw: format!(
"t{}d{:02x}{:02x}{}_{}_{}",
tls_version, cipher_count, ext_count, alpn, "aabbccddeeff", "112233445566"
),
protocol: Ja4Protocol::TCP,
tls_version,
sni_type: super::super::ja4::Ja4SniType::Domain,
cipher_count,
ext_count,
alpn: alpn.to_string(),
cipher_hash: "aabbccddeeff".to_string(),
ext_hash: "112233445566".to_string(),
}
}
#[test]
fn test_ja4_spoofing_chrome_with_minimal_tls() {
let ja4 = make_test_ja4(12, 3, 3, "h1"); let chrome_ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
let result = analyze_ja4_spoofing(&ja4, chrome_ua);
assert!(result.likely_spoofed, "Should detect spoofing");
assert!(
result.spoofing_confidence >= 50,
"Confidence should be >= 50: {}",
result.spoofing_confidence
);
assert_eq!(result.claimed_browser, "chrome");
assert!(
!result.inconsistencies.is_empty(),
"Should have inconsistencies"
);
}
#[test]
fn test_ja4_legitimate_chrome() {
let ja4 = make_test_ja4(13, 16, 18, "h2"); let chrome_ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
let result = analyze_ja4_spoofing(&ja4, chrome_ua);
assert!(
!result.likely_spoofed,
"Should not flag legitimate Chrome: {:?}",
result.inconsistencies
);
assert!(
result.spoofing_confidence < 50,
"Confidence should be < 50: {}",
result.spoofing_confidence
);
assert_eq!(result.claimed_browser, "chrome");
}
#[test]
fn test_ja4_firefox_with_chromium_fingerprint() {
let ja4 = make_test_ja4(13, 20, 22, "h2");
let firefox_ua =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0";
let result = analyze_ja4_spoofing(&ja4, firefox_ua);
assert_eq!(result.claimed_browser, "firefox");
}
#[test]
fn test_ja4_old_tls_for_modern_browser() {
let ja4 = make_test_ja4(10, 15, 15, "h1"); let chrome_ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
let result = analyze_ja4_spoofing(&ja4, chrome_ua);
assert!(result.likely_spoofed, "Should detect old TLS as spoofing");
assert!(
result
.inconsistencies
.iter()
.any(|i| i.as_ref().contains("too old")),
"Should mention old TLS: {:?}",
result.inconsistencies
);
}
#[test]
fn test_ja4_cli_tool_with_browser_fingerprint() {
let ja4 = make_test_ja4(13, 16, 18, "h2"); let curl_ua = "curl/8.4.0";
let result = analyze_ja4_spoofing(&ja4, curl_ua);
assert_eq!(result.claimed_browser, "cli-tool");
assert_eq!(result.estimated_actual, "modern-browser");
}
#[test]
fn test_ja4_python_minimal_tls() {
let ja4 = make_test_ja4(12, 4, 4, "h1"); let python_ua = "python-requests/2.31.0";
let result = analyze_ja4_spoofing(&ja4, python_ua);
assert_eq!(result.claimed_browser, "python");
assert_eq!(result.estimated_actual, "minimal-client");
}
#[test]
fn test_detect_browser_from_ua() {
assert_eq!(
detect_browser_from_ua("Mozilla/5.0 Chrome/120.0.0.0"),
"chrome"
);
assert_eq!(
detect_browser_from_ua("Mozilla/5.0 Firefox/121.0"),
"firefox"
);
assert_eq!(
detect_browser_from_ua("Mozilla/5.0 Safari/537.36"),
"safari"
);
assert_eq!(detect_browser_from_ua("Mozilla/5.0 Edg/120.0.0.0"), "edge");
assert_eq!(detect_browser_from_ua("curl/8.4.0"), "cli-tool");
assert_eq!(detect_browser_from_ua("python-requests/2.31.0"), "python");
assert_eq!(detect_browser_from_ua("Go-http-client/1.1"), "golang");
assert_eq!(detect_browser_from_ua("SomeRandomBot/1.0"), "unknown");
}
#[test]
fn test_estimate_actual_client() {
let modern = make_test_ja4(13, 16, 18, "h2");
assert_eq!(estimate_actual_client(&modern), "modern-browser");
let minimal = make_test_ja4(12, 2, 2, "h1");
assert_eq!(estimate_actual_client(&minimal), "minimal-client");
let legacy = make_test_ja4(10, 10, 10, "h1");
assert_eq!(estimate_actual_client(&legacy), "legacy-client");
let api = make_test_ja4(12, 8, 8, "h1");
assert_eq!(estimate_actual_client(&api), "api-client");
}
#[test]
fn test_analyze_integrity_with_ja4() {
let headers = vec![
header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
),
header("Sec-CH-UA", "\"Chromium\";v=\"120\""),
];
let req = HttpHeaders {
headers: &headers,
method: "GET",
http_version: "1.1",
};
let legitimate_ja4 = make_test_ja4(13, 16, 18, "h2");
let result = analyze_integrity_with_ja4(&req, Some(&legitimate_ja4));
assert!(
result.suspicion_score < 30,
"Legitimate request should have low score: {}",
result.suspicion_score
);
let spoofed_ja4 = make_test_ja4(10, 2, 2, "h1");
let result = analyze_integrity_with_ja4(&req, Some(&spoofed_ja4));
assert!(
result.suspicion_score >= 30,
"Spoofed request should have high score: {}",
result.suspicion_score
);
}
}