use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WafConfig {
pub user_agent: UserAgentRules,
pub anomaly: AnomalyRules,
}
impl WafConfig {
pub fn is_enabled(&self) -> bool {
self.user_agent.enabled || self.anomaly.enabled
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct UserAgentRules {
pub enabled: bool,
pub deny: Vec<String>,
pub allow: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AnomalyRules {
pub enabled: bool,
pub threshold: u32,
pub score_empty_user_agent: bool,
pub score_missing_accept: bool,
pub suspicious_paths: Vec<String>,
pub suspicious_path_score: u32,
}
impl Default for AnomalyRules {
fn default() -> Self {
Self {
enabled: false,
threshold: 1,
score_empty_user_agent: false,
score_missing_accept: false,
suspicious_paths: Vec::new(),
suspicious_path_score: 1,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct WafRequest<'a> {
pub user_agent: Option<&'a str>,
pub accept: Option<&'a str>,
pub path: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WafVerdict {
Allow,
Block(String),
}
pub fn evaluate(config: &WafConfig, req: &WafRequest<'_>) -> WafVerdict {
if config.user_agent.enabled {
if let Some(reason) = evaluate_user_agent(&config.user_agent, req.user_agent) {
return WafVerdict::Block(reason);
}
}
if config.anomaly.enabled {
if let Some(reason) = evaluate_anomaly(&config.anomaly, req) {
return WafVerdict::Block(reason);
}
}
WafVerdict::Allow
}
fn matches_any(patterns: &[String], haystack: &str) -> bool {
patterns.iter().any(|pat| {
regex::Regex::new(pat)
.map(|re| re.is_match(haystack))
.unwrap_or(false)
})
}
fn evaluate_user_agent(rules: &UserAgentRules, user_agent: Option<&str>) -> Option<String> {
let ua = user_agent.unwrap_or("");
if matches_any(&rules.deny, ua) {
return Some("user-agent matched a deny rule".to_string());
}
if !rules.allow.is_empty() && !matches_any(&rules.allow, ua) {
return Some("user-agent not on the allow-list".to_string());
}
None
}
fn evaluate_anomaly(rules: &AnomalyRules, req: &WafRequest<'_>) -> Option<String> {
let mut score = 0u32;
if rules.score_empty_user_agent && req.user_agent.map(str::trim).unwrap_or("").is_empty() {
score += 1;
}
if rules.score_missing_accept && req.accept.is_none() {
score += 1;
}
if !rules.suspicious_paths.is_empty() {
let weight = rules.suspicious_path_score.max(1);
for needle in &rules.suspicious_paths {
if req.path.contains(needle.as_str()) {
score += weight;
}
}
}
let threshold = rules.threshold.max(1);
(score >= threshold).then(|| format!("anomaly score {score} >= threshold {threshold}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn req<'a>(ua: Option<&'a str>, accept: Option<&'a str>, path: &'a str) -> WafRequest<'a> {
WafRequest {
user_agent: ua,
accept,
path,
}
}
#[test]
fn disabled_waf_allows_everything() {
let cfg = WafConfig::default();
assert!(!cfg.is_enabled());
assert_eq!(evaluate(&cfg, &req(None, None, "/.env")), WafVerdict::Allow);
}
#[test]
fn ua_denylist_blocks_match_only() {
let cfg = WafConfig {
user_agent: UserAgentRules {
enabled: true,
deny: vec!["(?i)badbot".into()],
allow: Vec::new(),
},
..Default::default()
};
assert!(matches!(
evaluate(&cfg, &req(Some("Mozilla BadBot/1.0"), None, "/")),
WafVerdict::Block(_)
));
assert_eq!(
evaluate(&cfg, &req(Some("Mozilla/5.0"), None, "/")),
WafVerdict::Allow
);
}
#[test]
fn ua_allowlist_blocks_non_matches() {
let cfg = WafConfig {
user_agent: UserAgentRules {
enabled: true,
deny: Vec::new(),
allow: vec!["GoodClient".into()],
},
..Default::default()
};
assert_eq!(
evaluate(&cfg, &req(Some("GoodClient/2"), None, "/")),
WafVerdict::Allow
);
assert!(matches!(
evaluate(&cfg, &req(Some("anything-else"), None, "/")),
WafVerdict::Block(_)
));
}
#[test]
fn anomaly_sums_signals_to_threshold() {
let cfg = WafConfig {
anomaly: AnomalyRules {
enabled: true,
threshold: 2,
score_empty_user_agent: true,
score_missing_accept: true,
suspicious_paths: vec!["/.env".into()],
suspicious_path_score: 1,
},
..Default::default()
};
assert!(matches!(
evaluate(&cfg, &req(None, None, "/")),
WafVerdict::Block(_)
));
assert_eq!(
evaluate(&cfg, &req(Some("UA"), None, "/")),
WafVerdict::Allow
);
assert!(matches!(
evaluate(&cfg, &req(Some("UA"), None, "/.env")),
WafVerdict::Block(_)
));
}
#[test]
fn features_are_independent() {
let cfg = WafConfig {
user_agent: UserAgentRules {
enabled: false,
deny: vec!["BadBot".into()],
..Default::default()
},
anomaly: AnomalyRules {
enabled: true,
threshold: 1,
score_empty_user_agent: true,
..Default::default()
},
};
assert_eq!(
evaluate(&cfg, &req(Some("BadBot"), Some("*/*"), "/")),
WafVerdict::Allow,
"UA rules disabled → not enforced"
);
assert!(
matches!(
evaluate(&cfg, &req(None, Some("*/*"), "/")),
WafVerdict::Block(_)
),
"anomaly still fires on empty UA"
);
}
#[test]
fn invalid_regex_is_ignored_not_fatal() {
let cfg = WafConfig {
user_agent: UserAgentRules {
enabled: true,
deny: vec!["(unclosed".into()],
allow: Vec::new(),
},
..Default::default()
};
assert_eq!(
evaluate(&cfg, &req(Some("anything"), None, "/")),
WafVerdict::Allow
);
}
}