use serde::{Deserialize, Serialize};
use crate::{WafDecision, WafRequest};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CustomRuleAction {
#[default]
Block,
Log,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MatchConfig {
pub path: Option<String>,
pub method: Option<String>,
pub header_present: Option<String>,
pub header_missing: Option<String>,
pub header_value: Option<(String, String)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomRule {
pub name: String,
pub match_config: MatchConfig,
#[serde(default)]
pub action: CustomRuleAction,
#[serde(default = "default_status")]
pub status: u16,
#[serde(default)]
pub reason: Option<String>,
}
fn default_status() -> u16 {
403
}
pub struct CustomRuleSet {
rules: Vec<CustomRule>,
}
impl CustomRuleSet {
pub fn new(rules: Vec<CustomRule>) -> Self {
Self { rules }
}
pub fn rules(&self) -> &[CustomRule] {
&self.rules
}
pub fn check(&self, req: &WafRequest) -> Option<WafDecision> {
for rule in &self.rules {
if matches_rule(rule, req) {
match rule.action {
CustomRuleAction::Block => {
let reason = rule
.reason
.clone()
.unwrap_or_else(|| format!("blocked by custom rule: {}", rule.name));
return Some(WafDecision::Block {
status: rule.status,
reason,
rule: format!("custom:{}", rule.name),
});
}
CustomRuleAction::Log => {
tracing::info!(
rule = %rule.name,
path = %req.path,
method = %req.method,
"custom rule matched (log-only)"
);
}
}
}
}
None
}
}
fn matches_rule(rule: &CustomRule, req: &WafRequest) -> bool {
if let Some(ref m) = rule.match_config.method {
if !m.eq_ignore_ascii_case(&req.method) {
return false;
}
}
if let Some(ref pattern) = rule.match_config.path {
if !glob_match(pattern, &req.path) {
return false;
}
}
if let Some(ref hdr) = rule.match_config.header_present {
let key = hdr.to_lowercase();
if !req.headers.keys().any(|k| k.to_lowercase() == key) {
return false;
}
}
if let Some(ref hdr) = rule.match_config.header_missing {
let key = hdr.to_lowercase();
if req.headers.keys().any(|k| k.to_lowercase() == key) {
return false;
}
}
if let Some((ref hdr_name, ref hdr_val)) = rule.match_config.header_value {
let key = hdr_name.to_lowercase();
let found = req
.headers
.iter()
.any(|(k, v)| k.to_lowercase() == key && v == hdr_val);
if !found {
return false;
}
}
true
}
fn glob_match(pattern: &str, path: &str) -> bool {
let pat_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
glob_match_parts(&pat_parts, &path_parts)
}
fn glob_match_parts(pattern: &[&str], path: &[&str]) -> bool {
if pattern.is_empty() {
return path.is_empty();
}
if pattern[0] == "**" {
let rest_pattern = &pattern[1..];
for i in 0..=path.len() {
if glob_match_parts(rest_pattern, &path[i..]) {
return true;
}
}
return false;
}
if path.is_empty() {
return false;
}
if segment_match(pattern[0], path[0]) {
glob_match_parts(&pattern[1..], &path[1..])
} else {
false
}
}
fn segment_match(pattern: &str, segment: &str) -> bool {
if pattern == "*" {
return true;
}
if pattern.contains('*') {
let parts: Vec<&str> = pattern.split('*').collect();
let mut pos = 0;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
match segment[pos..].find(part) {
Some(found) => {
if i == 0 && found != 0 {
return false;
}
pos += found + part.len();
}
None => return false,
}
}
if let Some(last) = parts.last() {
if !last.is_empty() && !segment.ends_with(last) {
return false;
}
}
true
} else {
pattern == segment
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_req(method: &str, path: &str, headers: Vec<(&str, &str)>) -> WafRequest {
WafRequest {
client_ip: "127.0.0.1".parse().unwrap(),
method: method.into(),
path: path.into(),
query: None,
headers: headers
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
body: None,
user_agent: None,
}
}
#[test]
fn block_by_path() {
let rules = CustomRuleSet::new(vec![CustomRule {
name: "block-admin".into(),
match_config: MatchConfig {
path: Some("/admin/**".into()),
..Default::default()
},
action: CustomRuleAction::Block,
status: 403,
reason: None,
}]);
assert!(rules
.check(&make_req("GET", "/admin/settings", vec![]))
.is_some());
assert!(rules
.check(&make_req("GET", "/api/users", vec![]))
.is_none());
}
#[test]
fn block_by_method() {
let rules = CustomRuleSet::new(vec![CustomRule {
name: "block-delete".into(),
match_config: MatchConfig {
method: Some("DELETE".into()),
..Default::default()
},
action: CustomRuleAction::Block,
status: 405,
reason: Some("DELETE not allowed".into()),
}]);
assert!(rules
.check(&make_req("DELETE", "/api/resource", vec![]))
.is_some());
assert!(rules
.check(&make_req("GET", "/api/resource", vec![]))
.is_none());
}
#[test]
fn block_by_header_present() {
let rules = CustomRuleSet::new(vec![CustomRule {
name: "require-no-debug".into(),
match_config: MatchConfig {
header_present: Some("X-Debug".into()),
..Default::default()
},
action: CustomRuleAction::Block,
status: 403,
reason: None,
}]);
assert!(rules
.check(&make_req("GET", "/", vec![("X-Debug", "true")]))
.is_some());
assert!(rules.check(&make_req("GET", "/", vec![])).is_none());
}
#[test]
fn block_by_header_missing() {
let rules = CustomRuleSet::new(vec![CustomRule {
name: "require-auth".into(),
match_config: MatchConfig {
header_missing: Some("Authorization".into()),
..Default::default()
},
action: CustomRuleAction::Block,
status: 401,
reason: Some("Authorization required".into()),
}]);
assert!(rules.check(&make_req("GET", "/api/data", vec![])).is_some());
assert!(rules
.check(&make_req(
"GET",
"/api/data",
vec![("Authorization", "Bearer token")]
))
.is_none());
}
#[test]
fn block_by_header_value() {
let rules = CustomRuleSet::new(vec![CustomRule {
name: "block-bad-origin".into(),
match_config: MatchConfig {
header_value: Some(("Origin".into(), "http://evil.com".into())),
..Default::default()
},
action: CustomRuleAction::Block,
status: 403,
reason: None,
}]);
assert!(rules
.check(&make_req("GET", "/", vec![("Origin", "http://evil.com")]))
.is_some());
assert!(rules
.check(&make_req("GET", "/", vec![("Origin", "http://good.com")]))
.is_none());
}
#[test]
fn log_action_does_not_block() {
let rules = CustomRuleSet::new(vec![CustomRule {
name: "log-api".into(),
match_config: MatchConfig {
path: Some("/api/**".into()),
..Default::default()
},
action: CustomRuleAction::Log,
status: 200,
reason: None,
}]);
assert!(rules
.check(&make_req("GET", "/api/users", vec![]))
.is_none());
}
#[test]
fn combined_conditions() {
let rules = CustomRuleSet::new(vec![CustomRule {
name: "block-post-admin".into(),
match_config: MatchConfig {
path: Some("/admin/**".into()),
method: Some("POST".into()),
..Default::default()
},
action: CustomRuleAction::Block,
status: 403,
reason: None,
}]);
assert!(rules
.check(&make_req("POST", "/admin/settings", vec![]))
.is_some());
assert!(rules
.check(&make_req("GET", "/admin/settings", vec![]))
.is_none());
assert!(rules
.check(&make_req("POST", "/api/users", vec![]))
.is_none());
}
#[test]
fn evaluation_order_first_match_wins() {
let rules = CustomRuleSet::new(vec![
CustomRule {
name: "allow-health".into(),
match_config: MatchConfig {
path: Some("/health".into()),
..Default::default()
},
action: CustomRuleAction::Log, status: 200,
reason: None,
},
CustomRule {
name: "block-all".into(),
match_config: MatchConfig::default(), action: CustomRuleAction::Block,
status: 403,
reason: None,
},
]);
assert!(rules.check(&make_req("GET", "/health", vec![])).is_some());
}
#[test]
fn glob_wildcard_single_segment() {
let rules = CustomRuleSet::new(vec![CustomRule {
name: "block-user-export".into(),
match_config: MatchConfig {
path: Some("/api/*/export".into()),
..Default::default()
},
action: CustomRuleAction::Block,
status: 403,
reason: None,
}]);
assert!(rules
.check(&make_req("GET", "/api/users/export", vec![]))
.is_some());
assert!(rules
.check(&make_req("GET", "/api/orders/export", vec![]))
.is_some());
assert!(rules
.check(&make_req("GET", "/api/users/list", vec![]))
.is_none());
}
#[test]
fn glob_double_star_matches_deep() {
assert!(glob_match("/admin/**", "/admin/a/b/c"));
assert!(glob_match("/admin/**", "/admin"));
assert!(!glob_match("/admin/**", "/api/admin"));
}
#[test]
fn glob_exact_match() {
assert!(glob_match("/health", "/health"));
assert!(!glob_match("/health", "/healthz"));
}
}