use std::fmt;
pub const DEFAULT_MAX_CONSECUTIVE_FAILURES: u32 = 5;
#[must_use]
pub fn max_consecutive_failures() -> u32 {
crate::config::tuning_u32_in_range(
"net.waf.max_consecutive_failures",
DEFAULT_MAX_CONSECUTIVE_FAILURES,
1,
100,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WafVendor {
Cloudflare,
Akamai,
DataDome,
PerimeterX,
Imperva,
Kasada,
AwsWaf,
}
impl WafVendor {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Cloudflare => "cloudflare",
Self::Akamai => "akamai",
Self::DataDome => "datadome",
Self::PerimeterX => "perimeterx",
Self::Imperva => "imperva",
Self::Kasada => "kasada",
Self::AwsWaf => "aws-waf",
}
}
#[must_use]
pub fn from_token(token: &str) -> Option<Self> {
match token.trim().to_ascii_lowercase().as_str() {
"cloudflare" => Some(Self::Cloudflare),
"akamai" => Some(Self::Akamai),
"datadome" => Some(Self::DataDome),
"perimeterx" => Some(Self::PerimeterX),
"imperva" => Some(Self::Imperva),
"kasada" => Some(Self::Kasada),
"aws-waf" => Some(Self::AwsWaf),
_ => None,
}
}
}
impl fmt::Display for WafVendor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WafSignal {
Header(String),
Cookie(String),
}
impl WafSignal {
#[must_use]
pub fn name(&self) -> &str {
match self {
Self::Header(n) | Self::Cookie(n) => n,
}
}
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::Header(_) => "header",
Self::Cookie(_) => "cookie",
}
}
}
impl fmt::Display for WafSignal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.kind(), self.name())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WafDetection {
pub vendor: WafVendor,
pub signal: WafSignal,
}
const HEADER_SIGNATURES: &[(&str, WafVendor)] = &[
("cf-ray", WafVendor::Cloudflare),
("cf-cache-status", WafVendor::Cloudflare),
("cf-mitigated", WafVendor::Cloudflare),
("akamai-origin-hop", WafVendor::Akamai),
("akamai-grn", WafVendor::Akamai),
("x-datadome", WafVendor::DataDome),
("x-datadome-cid", WafVendor::DataDome),
("x-iinfo", WafVendor::Imperva),
("x-cdn", WafVendor::Imperva),
("x-kpsdk-ct", WafVendor::Kasada),
];
const HEADER_PREFIX_SIGNATURES: &[(&str, WafVendor)] = &[
("x-akamai-", WafVendor::Akamai),
("x-px-", WafVendor::PerimeterX),
("x-amzn-waf-", WafVendor::AwsWaf),
];
const COOKIE_SIGNATURES: &[(&str, WafVendor)] = &[
("cf_clearance", WafVendor::Cloudflare),
("__cf_bm", WafVendor::Cloudflare),
("__cflb", WafVendor::Cloudflare),
("ak_bmsc", WafVendor::Akamai),
("bm_sv", WafVendor::Akamai),
("_abck", WafVendor::Akamai),
("datadome", WafVendor::DataDome),
("_px3", WafVendor::PerimeterX),
("_pxhd", WafVendor::PerimeterX),
("_pxvid", WafVendor::PerimeterX),
("___utmvc", WafVendor::Imperva),
("x-kpsdk-ct", WafVendor::Kasada),
("aws-waf-token", WafVendor::AwsWaf),
];
const COOKIE_PREFIX_SIGNATURES: &[(&str, WafVendor)] = &[
("incap_ses_", WafVendor::Imperva),
("visid_incap_", WafVendor::Imperva),
("nlbi_", WafVendor::Imperva),
];
pub const DEFAULT_CHALLENGE_COOKIES: &[&str] = &[
"cf_clearance",
"__cf_bm",
"datadome",
"_px3",
"ak_bmsc",
"_abck",
"aws-waf-token",
];
#[must_use]
pub fn detect(headers: &[(String, String)], cookie_names: &[String]) -> Option<WafDetection> {
for (name, _) in headers {
let lower = name.to_ascii_lowercase();
if let Some((_, vendor)) = header_signatures().iter().find(|(sig, _)| *sig == lower) {
return Some(WafDetection {
vendor: *vendor,
signal: WafSignal::Header(lower),
});
}
}
for (name, _) in headers {
let lower = name.to_ascii_lowercase();
if let Some((_, vendor)) = header_prefix_signatures()
.iter()
.find(|(prefix, _)| lower.starts_with(prefix.as_str()))
{
return Some(WafDetection {
vendor: *vendor,
signal: WafSignal::Header(lower),
});
}
}
for name in cookie_names {
let lower = name.to_ascii_lowercase();
if let Some((_, vendor)) = cookie_signatures().iter().find(|(sig, _)| *sig == lower) {
return Some(WafDetection {
vendor: *vendor,
signal: WafSignal::Cookie(lower),
});
}
}
for name in cookie_names {
let lower = name.to_ascii_lowercase();
if let Some((_, vendor)) = cookie_prefix_signatures()
.iter()
.find(|(prefix, _)| lower.starts_with(prefix.as_str()))
{
return Some(WafDetection {
vendor: *vendor,
signal: WafSignal::Cookie(lower),
});
}
}
None
}
fn resolve_signatures(key: &str, compiled: &[(&str, WafVendor)]) -> Vec<(String, WafVendor)> {
let fallback = || -> Vec<(String, WafVendor)> {
compiled
.iter()
.map(|(sig, vendor)| ((*sig).to_string(), *vendor))
.collect()
};
let Some(entries) = crate::config::tuning_str_list(key) else {
return fallback();
};
let mut out = Vec::with_capacity(entries.len());
for entry in &entries {
match entry.split_once('=') {
Some((sig, vendor)) => match WafVendor::from_token(vendor.trim()) {
Some(vendor) if !sig.trim().is_empty() => {
out.push((sig.trim().to_ascii_lowercase(), vendor));
}
_ => tracing::warn!(key, entry, "ignoring an entry with an unknown vendor"),
},
None => tracing::warn!(
key,
entry,
"ignoring an entry that is not `signature=vendor`"
),
}
}
if out.is_empty() {
tracing::warn!(
key,
"no usable signature configured; keeping the compiled table"
);
return fallback();
}
out
}
#[must_use]
pub fn header_signatures() -> Vec<(String, WafVendor)> {
resolve_signatures("net.waf.header_signatures", HEADER_SIGNATURES)
}
#[must_use]
pub fn header_prefix_signatures() -> Vec<(String, WafVendor)> {
resolve_signatures("net.waf.header_prefix_signatures", HEADER_PREFIX_SIGNATURES)
}
#[must_use]
pub fn cookie_signatures() -> Vec<(String, WafVendor)> {
resolve_signatures("net.waf.cookie_signatures", COOKIE_SIGNATURES)
}
#[must_use]
pub fn cookie_prefix_signatures() -> Vec<(String, WafVendor)> {
resolve_signatures("net.waf.cookie_prefix_signatures", COOKIE_PREFIX_SIGNATURES)
}
#[must_use]
pub fn challenge_cookies() -> Vec<String> {
crate::config::tuning_str_list_or("net.waf.challenge_cookies", DEFAULT_CHALLENGE_COOKIES)
}
#[must_use]
pub fn is_challenge_cookie(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
challenge_cookies().contains(&lower)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EscalationVerdict {
Retry,
Abort,
}
#[derive(Debug, Clone)]
pub struct EscalationPolicy {
max_consecutive_failures: u32,
current: Option<WafVendor>,
consecutive: u32,
}
impl Default for EscalationPolicy {
fn default() -> Self {
Self::new(max_consecutive_failures())
}
}
impl EscalationPolicy {
#[must_use]
pub const fn new(max_consecutive_failures: u32) -> Self {
Self {
max_consecutive_failures: if max_consecutive_failures == 0 {
1
} else {
max_consecutive_failures
},
current: None,
consecutive: 0,
}
}
#[must_use]
pub const fn budget(&self) -> u32 {
self.max_consecutive_failures
}
#[must_use]
pub const fn consecutive_failures(&self) -> u32 {
self.consecutive
}
#[must_use]
pub const fn current_vendor(&self) -> Option<WafVendor> {
self.current
}
pub fn record_failure(&mut self, vendor: WafVendor) -> EscalationVerdict {
if self.current != Some(vendor) {
self.current = Some(vendor);
self.consecutive = 0;
}
self.consecutive = self.consecutive.saturating_add(1);
if self.consecutive >= self.max_consecutive_failures {
EscalationVerdict::Abort
} else {
EscalationVerdict::Retry
}
}
pub fn record_unclassified_failure(&mut self) -> EscalationVerdict {
self.consecutive = self.consecutive.saturating_add(1);
if self.consecutive >= self.max_consecutive_failures {
EscalationVerdict::Abort
} else {
EscalationVerdict::Retry
}
}
pub fn record_success(&mut self) {
self.current = None;
self.consecutive = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn h(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect()
}
fn c(names: &[&str]) -> Vec<String> {
names.iter().map(|n| (*n).to_owned()).collect()
}
#[test]
fn cloudflare_is_detected_by_header() {
for name in ["cf-ray", "CF-Ray", "cf-cache-status", "cf-mitigated"] {
let hit = detect(&h(&[(name, "x")]), &[]).expect("must detect");
assert_eq!(hit.vendor, WafVendor::Cloudflare, "header {name}");
assert_eq!(hit.signal.kind(), "header");
}
}
#[test]
fn cloudflare_is_detected_by_cookie() {
for name in ["cf_clearance", "__cf_bm", "__cflb"] {
let hit = detect(&[], &c(&[name])).expect("must detect");
assert_eq!(hit.vendor, WafVendor::Cloudflare, "cookie {name}");
assert_eq!(hit.signal, WafSignal::Cookie(name.to_owned()));
}
}
#[test]
fn akamai_is_detected_by_header_prefix_and_cookie() {
assert_eq!(
detect(&h(&[("X-Akamai-Transformed", "9")]), &[])
.expect("must detect")
.vendor,
WafVendor::Akamai
);
assert_eq!(
detect(&h(&[("akamai-origin-hop", "2")]), &[])
.expect("must detect")
.vendor,
WafVendor::Akamai
);
assert_eq!(
detect(&[], &c(&["ak_bmsc"])).expect("must detect").vendor,
WafVendor::Akamai
);
}
#[test]
fn datadome_is_detected_by_cookie() {
let hit = detect(&[], &c(&["datadome"])).expect("must detect");
assert_eq!(hit.vendor, WafVendor::DataDome);
assert_eq!(hit.signal, WafSignal::Cookie("datadome".to_owned()));
}
#[test]
fn perimeterx_is_detected_by_cookies_and_header_prefix() {
for name in ["_px3", "_pxhd", "_pxvid"] {
assert_eq!(
detect(&[], &c(&[name])).expect("must detect").vendor,
WafVendor::PerimeterX,
"cookie {name}"
);
}
assert_eq!(
detect(&h(&[("x-px-block", "1")]), &[])
.expect("must detect")
.vendor,
WafVendor::PerimeterX
);
}
#[test]
fn imperva_is_detected_by_suffixed_cookies() {
for name in ["incap_ses_1234_5678", "visid_incap_5678", "___utmvc"] {
assert_eq!(
detect(&[], &c(&[name])).expect("must detect").vendor,
WafVendor::Imperva,
"cookie {name}"
);
}
}
#[test]
fn kasada_is_detected_by_cookie() {
assert_eq!(
detect(&[], &c(&["x-kpsdk-ct"]))
.expect("must detect")
.vendor,
WafVendor::Kasada
);
}
#[test]
fn aws_waf_is_detected_by_header_prefix() {
assert_eq!(
detect(&h(&[("x-amzn-waf-action", "block")]), &[])
.expect("must detect")
.vendor,
WafVendor::AwsWaf
);
}
#[test]
fn a_bare_403_names_no_vendor() {
let ordinary = h(&[
("content-type", "text/html"),
("server", "nginx"),
("x-frame-options", "DENY"),
]);
assert_eq!(detect(&ordinary, &c(&["session_id", "csrftoken"])), None);
}
#[test]
fn header_signal_wins_over_cookie_signal() {
let hit = detect(&h(&[("cf-ray", "abc")]), &c(&["datadome"])).expect("must detect");
assert_eq!(hit.vendor, WafVendor::Cloudflare);
assert_eq!(hit.signal.kind(), "header");
}
#[test]
fn challenge_cookies_are_recognised() {
assert!(is_challenge_cookie("cf_clearance"));
assert!(is_challenge_cookie("CF_CLEARANCE"));
assert!(is_challenge_cookie("datadome"));
assert!(!is_challenge_cookie("session_id"));
}
#[test]
fn escalation_aborts_at_the_budget() {
let mut policy = EscalationPolicy::new(3);
assert_eq!(policy.budget(), 3);
assert_eq!(
policy.record_failure(WafVendor::Cloudflare),
EscalationVerdict::Retry
);
assert_eq!(
policy.record_failure(WafVendor::Cloudflare),
EscalationVerdict::Retry
);
assert_eq!(
policy.record_failure(WafVendor::Cloudflare),
EscalationVerdict::Abort
);
assert_eq!(policy.consecutive_failures(), 3);
}
#[test]
fn switching_vendor_restarts_the_budget() {
let mut policy = EscalationPolicy::new(2);
assert_eq!(
policy.record_failure(WafVendor::Cloudflare),
EscalationVerdict::Retry
);
assert_eq!(
policy.record_failure(WafVendor::DataDome),
EscalationVerdict::Retry
);
assert_eq!(policy.current_vendor(), Some(WafVendor::DataDome));
assert_eq!(policy.consecutive_failures(), 1);
}
#[test]
fn success_clears_the_counter() {
let mut policy = EscalationPolicy::new(2);
let _ = policy.record_failure(WafVendor::Kasada);
policy.record_success();
assert_eq!(policy.consecutive_failures(), 0);
assert_eq!(policy.current_vendor(), None);
assert_eq!(
policy.record_failure(WafVendor::Kasada),
EscalationVerdict::Retry
);
}
#[test]
fn zero_budget_still_allows_one_attempt_before_aborting() {
let mut policy = EscalationPolicy::new(0);
assert_eq!(policy.budget(), 1);
assert_eq!(
policy.record_failure(WafVendor::Imperva),
EscalationVerdict::Abort
);
}
#[test]
fn default_budget_matches_the_named_constant() {
assert_eq!(
EscalationPolicy::default().budget(),
max_consecutive_failures()
);
}
#[test]
fn unclassified_failures_are_counted() {
let mut policy = EscalationPolicy::new(2);
assert_eq!(
policy.record_unclassified_failure(),
EscalationVerdict::Retry
);
assert_eq!(
policy.record_unclassified_failure(),
EscalationVerdict::Abort
);
}
}