use std::collections::HashSet;
use crate::body_scan::BodyScanValue;
use crate::detect::{self, DetectConfig, Threat};
const SSRF_SKIP: &[&str] = &["ssrf"];
pub const DEFAULT_EXCLUDED_HEADERS: &[&str] = &[
"host",
"user-agent",
"accept",
"accept-encoding",
"connection",
"origin",
"referer",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
"x-real-ip",
"x-client-ip",
"x-cluster-client-ip",
"cf-connecting-ip",
"true-client-ip",
"fly-client-ip",
"x-envoy-external-address",
];
const ADDRESS_HEADER_NAMES: &[&str] = &[
"host",
"origin",
"x-forwarded-for",
"x-forwarded-host",
"x-real-ip",
"x-client-ip",
"x-cluster-client-ip",
"cf-connecting-ip",
"true-client-ip",
"fly-client-ip",
"x-envoy-external-address",
"via",
];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DetectionExclusionConfig {
pub excluded_detection_headers: Vec<String>,
pub excluded_detection_params: Vec<String>,
pub excluded_detection_body_fields: Vec<String>,
pub enabled_detection_categories: Option<Vec<String>>,
pub detection_scan_body: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RouteDetectionExclusions {
pub excluded_detection_headers: Option<Vec<String>>,
pub excluded_detection_params: Option<Vec<String>>,
pub excluded_detection_body_fields: Option<Vec<String>>,
pub enabled_detection_categories: Option<Vec<String>>,
pub detection_scan_body: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResolvedExclusions {
pub excluded_params: HashSet<String>,
pub excluded_body_fields: HashSet<String>,
pub excluded_headers: HashSet<String>,
pub enabled_categories: Option<HashSet<String>>,
pub scan_body: bool,
}
fn extend_lowered(set: &mut HashSet<String>, values: impl IntoIterator<Item = String>) {
set.extend(values.into_iter().map(|value| value.to_ascii_lowercase()));
}
#[must_use]
pub fn resolve(
config: Option<&DetectionExclusionConfig>,
route: Option<&RouteDetectionExclusions>,
) -> ResolvedExclusions {
let mut resolved = ResolvedExclusions::default();
resolved
.excluded_headers
.extend(DEFAULT_EXCLUDED_HEADERS.iter().map(|h| (*h).to_owned()));
if let Some(config) = config {
extend_lowered(
&mut resolved.excluded_headers,
config.excluded_detection_headers.clone(),
);
}
if let Some(headers) = route.and_then(|route| route.excluded_detection_headers.as_ref()) {
extend_lowered(&mut resolved.excluded_headers, headers.clone());
}
let params: Option<Vec<String>> = route
.and_then(|route| route.excluded_detection_params.clone())
.or_else(|| config.map(|config| config.excluded_detection_params.clone()));
if let Some(params) = params {
extend_lowered(&mut resolved.excluded_params, params);
}
let body_fields: Option<Vec<String>> = route
.and_then(|route| route.excluded_detection_body_fields.clone())
.or_else(|| config.map(|config| config.excluded_detection_body_fields.clone()));
if let Some(body_fields) = body_fields {
extend_lowered(&mut resolved.excluded_body_fields, body_fields);
}
let categories: Option<Vec<String>> = route
.and_then(|route| route.enabled_detection_categories.clone())
.or_else(|| config.and_then(|config| config.enabled_detection_categories.clone()));
if let Some(categories) = categories {
let mut set = HashSet::new();
extend_lowered(&mut set, categories);
resolved.enabled_categories = Some(set);
}
resolved.scan_body = route
.and_then(|route| route.detection_scan_body)
.or_else(|| config.and_then(|config| config.detection_scan_body))
.unwrap_or(true);
resolved
}
#[must_use]
pub fn excluded_header_skip_categories(name: &str, value: &str) -> &'static [&'static str] {
let normalized = name.trim().to_ascii_lowercase();
if ADDRESS_HEADER_NAMES.contains(&normalized.as_str()) {
return SSRF_SKIP;
}
if value_looks_like_address_chain(value) {
return SSRF_SKIP;
}
&[]
}
#[must_use]
pub fn strip_forwarded_entry_port(value: &str) -> &str {
if let Some(rest) = value.strip_prefix('[') {
let Some(closing) = rest.find(']') else {
return value;
};
let remainder = &rest[closing + 1..];
let port_valid =
remainder.starts_with(':') && remainder[1..].bytes().all(|b| b.is_ascii_digit());
if !(remainder.is_empty() || port_valid) {
return value;
}
return &rest[..closing];
}
if value.matches(':').count() == 1
&& let Some((host, port)) = value.split_once(':')
&& !port.is_empty()
&& port.bytes().all(|b| b.is_ascii_digit())
{
return host;
}
value
}
#[must_use]
pub fn value_looks_like_address_chain(value: &str) -> bool {
let tokens: Vec<&str> = value
.split(',')
.map(str::trim)
.filter(|token| !token.is_empty())
.collect();
if tokens.is_empty() {
return false;
}
tokens.iter().all(|token| {
strip_forwarded_entry_port(token)
.parse::<std::net::IpAddr>()
.is_ok()
})
}
#[derive(Debug, Clone, Default)]
pub struct RequestSurfaces<'a> {
pub url_path: Option<&'a str>,
pub query_params: &'a [(String, String)],
pub headers: &'a [(String, String)],
pub content_type: &'a str,
pub raw_body: &'a str,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RequestScanVerdict {
pub is_threat: bool,
pub categories: Vec<String>,
pub reason: String,
}
#[must_use]
pub fn scan_request(
surfaces: &RequestSurfaces<'_>,
exclusions: &ResolvedExclusions,
config: &DetectConfig,
) -> RequestScanVerdict {
struct Value<'a> {
content: &'a str,
context: &'a str,
skip_categories: &'static [&'static str],
}
let mut values: Vec<Value<'_>> = Vec::new();
if let Some(path) = surfaces.url_path.filter(|path| !path.is_empty()) {
values.push(Value {
content: path,
context: "url_path",
skip_categories: &[],
});
}
for (name, value) in surfaces.query_params {
if exclusions
.excluded_params
.contains(&name.to_ascii_lowercase())
{
continue;
}
values.push(Value {
content: value,
context: "query_param",
skip_categories: &[],
});
}
for (name, value) in surfaces.headers {
if value.is_empty() {
continue;
}
let skip_categories = if exclusions
.excluded_headers
.contains(&name.to_ascii_lowercase())
{
excluded_header_skip_categories(name, value)
} else {
&[]
};
values.push(Value {
content: value,
context: "header",
skip_categories,
});
}
let body_values: Vec<BodyScanValue> = if exclusions.scan_body {
crate::body_scan::extract_body_scan_values_with_exclusions(
surfaces.raw_body,
surfaces.content_type,
config,
crate::body_scan::ExcludedBodyFields::new(&exclusions.excluded_body_fields),
)
} else {
Vec::new()
};
for value in &body_values {
if let Some(forced) = value.forced_category {
return forced_verdict(core::slice::from_ref(&forced));
}
values.push(Value {
content: &value.content,
context: &value.context,
skip_categories: &[],
});
}
for value in &values {
let verdict = detect::detect(value.content, value.context, config);
if !verdict.is_threat {
continue;
}
let mut categories: Vec<String> = Vec::new();
for threat in &verdict.threats {
let category = match threat {
Threat::Regex(regex) => Some(regex.category.clone()),
Threat::Semantic(_) => None,
};
let Some(category) = category else {
continue;
};
if !category_enabled(&category, exclusions.enabled_categories.as_ref())
|| value.skip_categories.contains(&category.as_str())
|| categories.contains(&category)
{
continue;
}
categories.push(category);
}
if categories.is_empty() {
return RequestScanVerdict::default();
}
categories.sort();
return verdict_from(categories);
}
RequestScanVerdict::default()
}
fn forced_verdict(categories: &[&str]) -> RequestScanVerdict {
let categories: Vec<String> = categories.iter().map(|c| (*c).to_owned()).collect();
verdict_from(categories)
}
fn verdict_from(mut categories: Vec<String>) -> RequestScanVerdict {
categories.sort();
let reason = format!("Penetration patterns detected: {}", categories.join(", "));
RequestScanVerdict {
is_threat: true,
categories,
reason,
}
}
fn category_enabled(category: &str, enabled: Option<&HashSet<String>>) -> bool {
enabled.is_none_or(|set| set.contains(category))
}
#[must_use]
pub fn detection_enabled(global_enabled: bool, route_enabled: Option<bool>) -> bool {
route_enabled.unwrap_or(global_enabled)
}
#[must_use]
pub fn check_applies(
global_enabled: bool,
route_toggles: impl IntoIterator<Item = bool>,
dynamic_rules: bool,
) -> bool {
global_enabled || route_toggles.into_iter().any(|flag| flag) || dynamic_rules
}
#[cfg(test)]
mod tests {
use super::*;
use crate::detect::DetectConfig;
fn corpus_config() -> DetectConfig {
DetectConfig {
max_content_length: 10_000,
max_full_scan_bytes: 262_144,
preserve_attack_patterns: true,
semantic_threshold: 0.7,
threat_score_threshold: 1.0,
binary_min_run_length: 16,
}
}
fn config_of(params: &[&str]) -> DetectionExclusionConfig {
DetectionExclusionConfig {
excluded_detection_params: params.iter().map(|s| (*s).to_owned()).collect(),
..DetectionExclusionConfig::default()
}
}
#[test]
fn resolve_defaults_scan_everything() {
let resolved = resolve(None, None);
assert!(resolved.excluded_params.is_empty());
assert!(resolved.excluded_body_fields.is_empty());
assert!(resolved.enabled_categories.is_none());
assert!(resolved.scan_body);
assert!(resolved.excluded_headers.contains("host"));
assert!(resolved.excluded_headers.contains("x-forwarded-for"));
assert!(!resolved.excluded_headers.contains("x-custom"));
}
#[test]
fn non_none_route_sets_replace_and_unset_inherit() {
let global = config_of(&["debug", "trace"]);
let route = RouteDetectionExclusions {
excluded_detection_params: Some(vec!["session".to_owned()]),
..RouteDetectionExclusions::default()
};
let resolved = resolve(Some(&global), Some(&route));
assert!(resolved.excluded_params.contains("session"));
assert!(!resolved.excluded_params.contains("debug"));
let inheriting = resolve(Some(&global), Some(&RouteDetectionExclusions::default()));
assert!(inheriting.excluded_params.contains("debug"));
assert!(inheriting.excluded_params.contains("trace"));
}
#[test]
fn header_set_merges_defaults_config_and_route() {
let global = DetectionExclusionConfig {
excluded_detection_headers: vec!["X-Custom-Global".to_owned()],
..DetectionExclusionConfig::default()
};
let route = RouteDetectionExclusions {
excluded_detection_headers: Some(vec!["x-custom-route".to_owned()]),
..RouteDetectionExclusions::default()
};
let resolved = resolve(Some(&global), Some(&route));
assert!(resolved.excluded_headers.contains("x-custom-global"));
assert!(resolved.excluded_headers.contains("x-custom-route"));
assert!(
resolved.excluded_headers.contains("referer"),
"defaults survive"
);
}
#[test]
fn empty_route_category_set_disables_every_category() {
let route = RouteDetectionExclusions {
enabled_detection_categories: Some(Vec::new()),
..RouteDetectionExclusions::default()
};
let resolved = resolve(None, Some(&route));
let enabled = resolved.enabled_categories.clone().expect("some");
assert!(enabled.is_empty(), "an empty Some replaces, not inherits");
assert!(!category_enabled(
"sqli",
resolved.enabled_categories.as_ref()
));
let global = DetectionExclusionConfig {
enabled_detection_categories: Some(vec!["sqli".to_owned()]),
..DetectionExclusionConfig::default()
};
let inheriting = resolve(Some(&global), None);
assert!(category_enabled(
"sqli",
inheriting.enabled_categories.as_ref()
));
assert!(!category_enabled(
"xss",
inheriting.enabled_categories.as_ref()
));
}
#[test]
fn scan_body_toggle_overrides_both_directions() {
let global = DetectionExclusionConfig {
detection_scan_body: Some(true),
..DetectionExclusionConfig::default()
};
let route = RouteDetectionExclusions {
detection_scan_body: Some(false),
..RouteDetectionExclusions::default()
};
assert!(!resolve(Some(&global), Some(&route)).scan_body);
let global = DetectionExclusionConfig {
detection_scan_body: Some(false),
..DetectionExclusionConfig::default()
};
let route = RouteDetectionExclusions {
detection_scan_body: Some(true),
..RouteDetectionExclusions::default()
};
assert!(resolve(Some(&global), Some(&route)).scan_body);
let global = DetectionExclusionConfig {
detection_scan_body: Some(false),
..DetectionExclusionConfig::default()
};
assert!(!resolve(Some(&global), None).scan_body);
}
#[test]
fn scan_body_false_still_scans_headers_params_and_path() {
let route = RouteDetectionExclusions {
detection_scan_body: Some(false),
..RouteDetectionExclusions::default()
};
let exclusions = resolve(None, Some(&route));
let surfaces = RequestSurfaces {
url_path: Some("/files?x=../../etc/passwd"),
query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
headers: &[(
"x-payload".to_owned(),
"<script>alert(1)</script>".to_owned(),
)],
content_type: "application/x-www-form-urlencoded",
raw_body: "payload=<img src=x onerror=alert(1)>",
};
let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
assert!(
verdict.is_threat,
"the body surface is skipped, the rest still detect"
);
}
#[test]
fn excluded_param_skips_only_itself() {
let global = config_of(&["debug"]);
let exclusions = resolve(Some(&global), None);
let surfaces = RequestSurfaces {
url_path: Some("/search"),
query_params: &[
("debug".to_owned(), "1 UNION SELECT password".to_owned()),
("q".to_owned(), "<script>alert(1)</script>".to_owned()),
],
headers: &[],
content_type: "",
raw_body: "",
};
let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
assert!(verdict.is_threat);
assert!(
!verdict.categories.contains(&"sqli".to_owned()),
"the excluded param did not contribute"
);
let surfaces = RequestSurfaces {
url_path: Some("/search"),
query_params: &[("DEBUG".to_owned(), "1 UNION SELECT password".to_owned())],
headers: &[],
content_type: "",
raw_body: "",
};
assert!(!scan_request(&surfaces, &exclusions, &corpus_config()).is_threat);
}
#[test]
fn excluded_body_field_skips_only_that_field() {
let global = DetectionExclusionConfig {
excluded_detection_body_fields: vec!["secret".to_owned()],
..DetectionExclusionConfig::default()
};
let exclusions = resolve(Some(&global), None);
let surfaces = RequestSurfaces {
url_path: Some("/api"),
query_params: &[],
headers: &[],
content_type: "application/x-www-form-urlencoded",
raw_body: "secret=1 UNION SELECT password&safe=<script>alert(1)</script>",
};
let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
assert!(verdict.is_threat, "the untouched field still scans");
assert_eq!(verdict.categories, vec!["xss"]);
let surfaces = RequestSurfaces {
url_path: Some("/api"),
query_params: &[],
headers: &[],
content_type: "application/x-www-form-urlencoded",
raw_body: "secret=1 UNION SELECT password",
};
assert!(!scan_request(&surfaces, &exclusions, &corpus_config()).is_threat);
}
#[test]
fn excluded_header_with_attack_payload_still_detects() {
let exclusions = resolve(None, None);
let surfaces = RequestSurfaces {
url_path: Some("/x"),
query_params: &[],
headers: &[(
"x-custom".to_owned(),
"<script>alert(1)</script>".to_owned(),
)],
content_type: "",
raw_body: "",
};
let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
assert!(verdict.is_threat, "an excluded header is not a skip hole");
assert_eq!(verdict.categories, vec!["xss"]);
}
#[test]
fn address_chain_headers_skip_ssrf_only() {
let chain = "10.0.0.5, 172.16.0.1";
assert_eq!(
excluded_header_skip_categories("x-custom", chain),
&["ssrf"][..]
);
assert_eq!(
excluded_header_skip_categories("via", chain),
&["ssrf"][..],
"the address-carrying name skips for any value"
);
assert_eq!(
excluded_header_skip_categories("x-custom", "<script>alert(1)</script>"),
&[] as &[&str],
"a non-address value skips nothing"
);
assert_eq!(
excluded_header_skip_categories("x-custom", "10.0.0.5, not-an-ip"),
&[] as &[&str],
"one non-address token breaks the chain"
);
}
#[test]
fn port_stripping_feeds_the_address_parser() {
assert_eq!(strip_forwarded_entry_port("1.2.3.4:8080"), "1.2.3.4");
assert_eq!(strip_forwarded_entry_port("[::1]:8080"), "::1");
assert_eq!(strip_forwarded_entry_port("::1"), "::1");
assert_eq!(strip_forwarded_entry_port("not-an-ip"), "not-an-ip");
assert_eq!(
strip_forwarded_entry_port("[::1]:nope"),
"[::1]:nope",
"a non-numeric port keeps the value unparsed"
);
assert!(
value_looks_like_address_chain("1.2.3.4:8080, [::1]:8080"),
"socket-literal entries parse once the port is stripped"
);
}
#[test]
fn filtered_out_threat_ends_the_scan_clean() {
let route = RouteDetectionExclusions {
enabled_detection_categories: Some(vec!["xss".to_owned()]),
..RouteDetectionExclusions::default()
};
let exclusions = resolve(None, Some(&route));
let surfaces = RequestSurfaces {
url_path: Some("/x"),
query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
headers: &[],
content_type: "",
raw_body: "",
};
let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
assert!(!verdict.is_threat);
assert!(verdict.categories.is_empty());
}
#[test]
fn enabled_categories_route_the_verdict() {
let route = RouteDetectionExclusions {
enabled_detection_categories: Some(vec!["sqli".to_owned()]),
..RouteDetectionExclusions::default()
};
let exclusions = resolve(None, Some(&route));
let surfaces = RequestSurfaces {
url_path: Some("/x"),
query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
headers: &[(
"x-payload".to_owned(),
"<script>alert(1)</script>".to_owned(),
)],
content_type: "",
raw_body: "",
};
let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
assert!(verdict.is_threat);
assert_eq!(verdict.categories, vec!["sqli"]);
assert!(verdict.reason.contains("sqli"));
assert!(!verdict.reason.contains("xss"), "xss is not enabled here");
}
#[test]
fn mongo_operator_keys_hit_through_the_body_walk() {
let exclusions = resolve(None, None);
let surfaces = RequestSurfaces {
url_path: Some("/x"),
query_params: &[],
headers: &[],
content_type: "application/json",
raw_body: "{\"$where\": \"1 OR 1=1\"}",
};
let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
assert!(verdict.is_threat);
assert_eq!(verdict.categories, vec!["nosql"]);
}
#[test]
fn route_toggles_and_liveness_mirror_the_reference() {
assert!(detection_enabled(false, Some(true)));
assert!(!detection_enabled(true, Some(false)));
assert!(detection_enabled(true, None));
assert!(!detection_enabled(false, None));
assert!(check_applies(false, [false, true], false));
assert!(!check_applies(false, [false, false], false));
assert!(check_applies(true, [], false));
assert!(
check_applies(false, [false], true),
"dynamic rules keep it alive"
);
}
}