pub mod crlf;
pub mod custom;
pub mod deserialization;
pub mod log4shell;
pub mod nosql;
pub mod protocol;
pub mod prototype;
pub mod scanner;
pub mod sensitive_path;
pub mod shell;
pub mod sqli;
pub mod ssi;
pub mod ssti;
pub mod traversal;
pub mod xss;
pub mod xxe;
use serde::{Deserialize, Serialize};
use crate::{WafDecision, WafRequest};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleConfig {
#[serde(default = "default_true")]
pub sql_injection: bool,
#[serde(default = "default_true")]
pub xss: bool,
#[serde(default = "default_true")]
pub path_traversal: bool,
#[serde(default = "default_true")]
pub shell_injection: bool,
#[serde(default = "default_true")]
pub protocol_violation: bool,
#[serde(default = "default_true")]
pub scanner_detection: bool,
#[serde(default = "default_true")]
pub sensitive_path: bool,
#[serde(default = "default_true")]
pub crlf_injection: bool,
#[serde(default = "default_true")]
pub method_override: bool,
#[serde(default)]
pub log_only: bool,
}
fn default_true() -> bool {
true
}
impl Default for RuleConfig {
fn default() -> Self {
Self {
sql_injection: true,
xss: true,
path_traversal: true,
shell_injection: true,
protocol_violation: true,
scanner_detection: true,
sensitive_path: true,
crlf_injection: true,
method_override: true,
log_only: false,
}
}
}
#[derive(Debug, Clone)]
pub struct WafRuleMatch {
pub rule_name: String,
pub matched_pattern: String,
pub matched_input: String,
}
pub struct RuleEngine {
config: RuleConfig,
custom_rules: custom::CustomRuleSet,
}
impl RuleEngine {
pub fn new(config: RuleConfig, custom_rules: Vec<custom::CustomRule>) -> Self {
Self {
config,
custom_rules: custom::CustomRuleSet::new(custom_rules),
}
}
pub fn inspect(&self, req: &WafRequest) -> Option<WafDecision> {
if let Some(decision) = self.custom_rules.check(req) {
return Some(decision);
}
let inputs_to_check = self.collect_inputs(req);
if self.config.sql_injection {
for (label, input) in &inputs_to_check {
if let Some(desc) = sqli::check_sqli(input) {
return self.make_decision("sql_injection", &desc, label);
}
}
}
if self.config.xss {
for (label, input) in &inputs_to_check {
if let Some(desc) = xss::check_xss(input) {
return self.make_decision("xss", &desc, label);
}
}
}
if self.config.path_traversal {
if let Some(desc) = traversal::check_traversal(&req.path) {
return self.make_decision("path_traversal", &desc, "path");
}
if let Some(ref q) = req.query {
if let Some(desc) = traversal::check_traversal(q) {
return self.make_decision("path_traversal", &desc, "query");
}
}
}
if self.config.shell_injection {
for (label, input) in &inputs_to_check {
if let Some(desc) = shell::check_shell(input) {
return self.make_decision("shell_injection", &desc, label);
}
}
}
if self.config.protocol_violation {
if let Some(desc) = protocol::check_protocol(req) {
return self.make_decision("protocol_violation", &desc, "request");
}
}
if let Some(ref query) = req.query {
if let Some(finding) = check_ssrf(query) {
return self.make_decision("ssrf", &finding, "query");
}
}
if self.config.sensitive_path {
if let Some(desc) = sensitive_path::check_sensitive_path(&req.path) {
return self.make_decision("sensitive_path", &desc, "path");
}
}
if self.config.crlf_injection {
if req.path.contains('%') || req.path.contains('\r') || req.path.contains('\n') {
if let Some(desc) = crlf::check_crlf(&req.path) {
return self.make_decision("crlf_injection", &desc, "path");
}
}
if let Some(ref q) = req.query {
if q.contains('%') || q.contains('\r') || q.contains('\n') {
if let Some(desc) = crlf::check_crlf(q) {
return self.make_decision("crlf_injection", &desc, "query");
}
}
}
}
if self.config.method_override {
if let Some(desc) = check_method_override(req) {
return self.make_decision("method_override", &desc, "header");
}
}
for (label, input) in &inputs_to_check {
if input.contains("${") {
if let Some(desc) = log4shell::check_log4shell(input) {
return self.make_decision("log4shell", &desc, label);
}
}
}
if let Some(ref ua) = req.user_agent {
if ua.contains("${") {
if let Some(desc) = log4shell::check_log4shell(ua) {
return self.make_decision("log4shell", &desc, "user_agent");
}
}
}
for value in req.headers.values() {
if value.contains("${") {
if let Some(desc) = log4shell::check_log4shell(value) {
return self.make_decision("log4shell", &desc, "header");
}
}
}
if let Some(ref body) = req.body {
if body.contains("<!") {
if let Some(desc) = xxe::check_xxe(body) {
return self.make_decision("xxe", &desc, "body");
}
}
}
for (label, input) in &inputs_to_check {
if input.contains("{{")
|| input.contains("${")
|| input.contains("<%")
|| input.contains("__")
{
if let Some(desc) = ssti::check_ssti(input) {
return self.make_decision("ssti", &desc, label);
}
}
}
for (label, input) in &inputs_to_check {
if input.contains("__proto__") || input.contains("constructor") {
if let Some(desc) = prototype::check_prototype(input) {
return self.make_decision("prototype_pollution", &desc, label);
}
}
}
for (label, input) in &inputs_to_check {
if input.contains("\"$") {
if let Some(desc) = nosql::check_nosql(input) {
return self.make_decision("nosql_injection", &desc, label);
}
}
}
for (label, input) in &inputs_to_check {
if input.contains("rO0AB")
|| input.contains("aced")
|| input.contains("AAEAAAD")
|| (input.len() > 4
&& input.as_bytes()[1] == b':'
&& input.as_bytes()[0].is_ascii_uppercase())
{
if let Some(desc) = deserialization::check_deserialization(input) {
return self.make_decision("deserialization", &desc, label);
}
}
}
for (label, input) in &inputs_to_check {
if input.contains("<!--#") {
if let Some(desc) = ssi::check_ssi(input) {
return self.make_decision("ssi_injection", &desc, label);
}
}
}
if let Some(ref query) = req.query {
if let Some(desc) = check_open_redirect(query) {
return self.make_decision("open_redirect", &desc, "query");
}
}
if let Some(ref body) = req.body {
if body.contains("__schema") || body.contains("__type") {
return self.make_decision(
"graphql_introspection",
"GraphQL introspection query detected (__schema/__type)",
"body",
);
}
}
for (label, input) in &inputs_to_check {
if input.contains(")(") || input.contains("(|") || input.contains("(&") {
return self.make_decision(
"ldap_injection",
"LDAP filter injection pattern detected",
label,
);
}
}
for (name, value) in &req.headers {
if name.eq_ignore_ascii_case("authorization") && value.contains("eyJhbGciOiJub25lIi") {
return self.make_decision("jwt_attack", "JWT with alg:none detected", "header");
}
if name.eq_ignore_ascii_case("authorization") && value.starts_with("Bearer ") {
let token = &value[7..];
let parts: Vec<&str> = token.split('.').collect();
if parts.len() == 3 && parts[2].is_empty() {
return self.make_decision(
"jwt_attack",
"JWT with empty signature detected",
"header",
);
}
}
}
if self.config.scanner_detection {
if let Some(ref ua) = req.user_agent {
if let Some(desc) = scanner::check_scanner(ua) {
return self.make_decision("scanner_detection", &desc, "user_agent");
}
}
}
if let Some(decoded_path) = percent_decode(&req.path) {
if self.config.sql_injection {
if let Some(desc) = sqli::check_sqli(&decoded_path) {
return self.make_decision("sql_injection", &desc, "path(decoded)");
}
}
if self.config.xss {
if let Some(desc) = xss::check_xss(&decoded_path) {
return self.make_decision("xss", &desc, "path(decoded)");
}
}
if self.config.path_traversal {
if let Some(desc) = traversal::check_traversal(&decoded_path) {
return self.make_decision("path_traversal", &desc, "path(decoded)");
}
}
if let Some(double) = percent_decode(&decoded_path) {
if self.config.path_traversal {
if let Some(desc) = traversal::check_traversal(&double) {
return self.make_decision("path_traversal", &desc, "path(double-decoded)");
}
}
}
}
if let Some(ref q) = req.query {
if let Some(decoded_q) = percent_decode(q) {
if self.config.sql_injection {
if let Some(desc) = sqli::check_sqli(&decoded_q) {
return self.make_decision("sql_injection", &desc, "query(decoded)");
}
}
if self.config.xss {
if let Some(desc) = xss::check_xss(&decoded_q) {
return self.make_decision("xss", &desc, "query(decoded)");
}
}
if self.config.path_traversal {
if let Some(desc) = traversal::check_traversal(&decoded_q) {
return self.make_decision("path_traversal", &desc, "query(decoded)");
}
}
if self.config.shell_injection {
if let Some(desc) = shell::check_shell(&decoded_q) {
return self.make_decision("shell_injection", &desc, "query(decoded)");
}
}
if let Some(double_q) = percent_decode(&decoded_q) {
if self.config.xss {
if let Some(desc) = xss::check_xss(&double_q) {
return self.make_decision("xss", &desc, "query(double-decoded)");
}
}
if self.config.sql_injection {
if let Some(desc) = sqli::check_sqli(&double_q) {
return self.make_decision(
"sql_injection",
&desc,
"query(double-decoded)",
);
}
}
}
}
}
if let Some(ref body) = req.body {
if let Some(decoded_body) = percent_decode(body) {
if self.config.sql_injection {
if let Some(desc) = sqli::check_sqli(&decoded_body) {
return self.make_decision("sql_injection", &desc, "body(decoded)");
}
}
if self.config.xss {
if let Some(desc) = xss::check_xss(&decoded_body) {
return self.make_decision("xss", &desc, "body(decoded)");
}
}
if let Some(double_body) = percent_decode(&decoded_body) {
if self.config.sql_injection {
if let Some(desc) = sqli::check_sqli(&double_body) {
return self.make_decision("sql_injection", &desc, "body(double-decoded)");
}
}
if self.config.xss {
if let Some(desc) = xss::check_xss(&double_body) {
return self.make_decision("xss", &desc, "body(double-decoded)");
}
}
}
}
}
None
}
fn collect_inputs<'a>(&self, req: &'a WafRequest) -> Vec<(&'static str, &'a str)> {
let mut inputs = Vec::with_capacity(4);
inputs.push(("path", req.path.as_str()));
if let Some(ref q) = req.query {
inputs.push(("query", q.as_str()));
}
if let Some(ref body) = req.body {
inputs.push(("body", body.as_str()));
}
inputs
}
fn make_decision(&self, rule: &str, desc: &str, input_source: &str) -> Option<WafDecision> {
if self.config.log_only {
tracing::warn!(
rule = rule,
pattern = desc,
source = input_source,
"WAF rule match (log-only mode)"
);
None
} else {
Some(WafDecision::Block {
status: 403,
reason: format!("{rule}: {desc} (in {input_source})"),
rule: rule.into(),
})
}
}
}
fn check_open_redirect(query: &str) -> Option<String> {
use regex::RegexSet;
use std::sync::OnceLock;
static REDIRECT_PATTERNS: OnceLock<RegexSet> = OnceLock::new();
let patterns = REDIRECT_PATTERNS.get_or_init(|| {
RegexSet::new([
r"(?i)(redirect|redirect_uri|next|return_to|return_url|dest|destination|rurl|continue|login_to|logout|forward|goto|target_url|returnTo|RelayState)=//[a-zA-Z]",
r"(?i)(redirect|redirect_uri|next|return_to|return_url|dest|destination|rurl|continue|login_to|logout|forward|goto|target_url|returnTo|RelayState)=https?://[a-zA-Z]",
r"(?i)(redirect|redirect_uri|next|return_to|return_url|dest|destination|rurl|continue|login_to|logout|forward|goto|target_url|returnTo|RelayState)=/\\",
r"(?i)(redirect|redirect_uri|next|return_to|return_url|dest|destination|rurl|continue|login_to|logout|forward|goto|target_url|returnTo|RelayState)=https?://[^/]*@",
])
.unwrap()
});
if patterns.is_match(query) {
Some("Open redirect: external URL in redirect parameter".into())
} else {
None
}
}
fn check_method_override(req: &WafRequest) -> Option<String> {
static OVERRIDE_HEADERS: &[&str] = &[
"x-http-method-override",
"x-http-method",
"x-method-override",
];
static DANGEROUS_HEADERS: &[&str] = &[
"x-original-url",
"x-rewrite-url",
"x-forwarded-host",
"x-forwarded-scheme",
];
for name in req.headers.keys() {
let lower = name.to_ascii_lowercase();
if OVERRIDE_HEADERS.contains(&lower.as_str()) {
return Some(format!("HTTP method override header detected: {name}"));
}
if DANGEROUS_HEADERS.contains(&lower.as_str()) {
return Some(format!(
"URL override header detected (cache poisoning): {name}"
));
}
}
None
}
fn percent_decode(input: &str) -> Option<String> {
if !input.contains('%') {
return None;
}
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
let mut changed = false;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
out.push(hi << 4 | lo);
i += 3;
changed = true;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
if changed {
Some(String::from_utf8_lossy(&out).into_owned())
} else {
None
}
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn check_ssrf(input: &str) -> Option<String> {
use regex::RegexSet;
use std::sync::OnceLock;
static SSRF_PATTERNS: OnceLock<RegexSet> = OnceLock::new();
let patterns = SSRF_PATTERNS.get_or_init(|| {
RegexSet::new([
r"(?i)(https?://|//)(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])",
r"(?i)(https?://|//)(10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+)",
r"(?i)(https?://|//)169\.254\.\d+\.\d+",
r"(?i)(file|gopher|dict|ftp)://",
r"(?i)(https?://|//)\d{8,10}(/|$|\s|:)",
r"(?i)(https?://|//)0\d+\.\d+\.\d+\.\d+",
r"(?i)(https?://|//)\[::ffff:",
r"(?i)(https?://|//)(127\.1|0\.0\.0\.0)(:|/|$)",
r"(?i)(https?://)\w+@(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)",
r"(?i)(https?://|//)(metadata\.google\.internal|metadata\.azure\.com)",
]).unwrap()
});
if patterns.is_match(input) {
Some("SSRF: private/internal URL detected in parameters".into())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn make_req(
method: &str,
path: &str,
query: Option<&str>,
body: Option<&str>,
ua: Option<&str>,
) -> WafRequest {
WafRequest {
client_ip: "127.0.0.1".parse().unwrap(),
method: method.into(),
path: path.into(),
query: query.map(String::from),
headers: HashMap::new(),
body: body.map(String::from),
user_agent: ua.map(String::from),
}
}
#[test]
fn clean_request_passes() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req("GET", "/api/users", None, None, Some("Mozilla/5.0"));
assert!(engine.inspect(&req).is_none());
}
#[test]
fn detects_sqli_in_query() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req(
"GET",
"/search",
Some("q=1 UNION SELECT * FROM users"),
None,
None,
);
let decision = engine.inspect(&req);
assert!(
matches!(decision, Some(WafDecision::Block { rule, .. }) if rule == "sql_injection")
);
}
#[test]
fn detects_sqli_in_body() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req("POST", "/login", None, Some("user=admin' OR 1=1 --"), None);
let decision = engine.inspect(&req);
assert!(
matches!(decision, Some(WafDecision::Block { rule, .. }) if rule == "sql_injection")
);
}
#[test]
fn detects_xss_in_body() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req(
"POST",
"/comment",
None,
Some("<script>alert(1)</script>"),
None,
);
let decision = engine.inspect(&req);
assert!(matches!(decision, Some(WafDecision::Block { rule, .. }) if rule == "xss"));
}
#[test]
fn detects_traversal_in_path() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req("GET", "/static/../../etc/passwd", None, None, None);
let decision = engine.inspect(&req);
assert!(
matches!(decision, Some(WafDecision::Block { rule, .. }) if rule == "path_traversal")
);
}
#[test]
fn detects_scanner_ua() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req("GET", "/", None, None, Some("sqlmap/1.5"));
let decision = engine.inspect(&req);
assert!(
matches!(decision, Some(WafDecision::Block { rule, .. }) if rule == "scanner_detection")
);
}
#[test]
fn detects_shell_injection_in_body() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req("POST", "/exec", None, Some("; cat /etc/passwd"), None);
let decision = engine.inspect(&req);
assert!(
matches!(decision, Some(WafDecision::Block { rule, .. }) if rule == "shell_injection")
);
}
#[test]
fn detects_shell_injection_in_query() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req("GET", "/search", Some("cmd=$(whoami)"), None, None);
let decision = engine.inspect(&req);
assert!(
matches!(decision, Some(WafDecision::Block { rule, .. }) if rule == "shell_injection")
);
}
#[test]
fn detects_protocol_violation_null_byte() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req("GET", "/file.php%00.jpg", None, None, None);
let decision = engine.inspect(&req);
assert!(decision.is_some());
}
#[test]
fn detects_protocol_violation_body_no_content_length() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req("POST", "/api/data", None, Some(r#"{"key":"val"}"#), None);
let decision = engine.inspect(&req);
assert!(
matches!(decision, Some(WafDecision::Block { rule, .. }) if rule == "protocol_violation")
);
}
#[test]
fn protocol_violation_passes_with_content_length() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = WafRequest {
client_ip: "127.0.0.1".parse().unwrap(),
method: "POST".into(),
path: "/api/data".into(),
query: None,
headers: {
let mut h = HashMap::new();
h.insert("Content-Length".into(), "13".into());
h
},
body: Some(r#"{"key":"val"}"#.into()),
user_agent: None,
};
assert!(engine.inspect(&req).is_none());
}
#[test]
fn disabled_rules_skip() {
let config = RuleConfig {
sql_injection: false,
xss: false,
path_traversal: false,
shell_injection: false,
protocol_violation: false,
scanner_detection: false,
sensitive_path: false,
crlf_injection: false,
method_override: false,
log_only: false,
};
let engine = RuleEngine::new(config, vec![]);
let req = make_req(
"POST",
"/../../etc/passwd",
Some("q=UNION SELECT *"),
Some("<script>alert(1)</script>"),
Some("sqlmap/1.5"),
);
assert!(engine.inspect(&req).is_none());
}
#[test]
fn shell_injection_disabled_allows() {
let config = RuleConfig {
shell_injection: false,
..Default::default()
};
let engine = RuleEngine::new(config, vec![]);
let req = make_req("POST", "/api", None, Some("; cat /etc/passwd"), None);
let decision = engine.inspect(&req);
if let Some(WafDecision::Block { rule, .. }) = &decision {
assert_ne!(rule, "shell_injection");
}
}
#[test]
fn detects_ssrf_localhost() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req(
"GET",
"/proxy",
Some("url=http://localhost/admin"),
None,
None,
);
let decision = engine.inspect(&req);
assert!(matches!(decision, Some(WafDecision::Block { rule, .. }) if rule == "ssrf"));
}
#[test]
fn detects_ssrf_127001() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req(
"GET",
"/fetch",
Some("url=http://127.0.0.1:8080/secret"),
None,
None,
);
let decision = engine.inspect(&req);
assert!(matches!(decision, Some(WafDecision::Block { rule, .. }) if rule == "ssrf"));
}
#[test]
fn detects_ssrf_private_10_range() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req(
"GET",
"/proxy",
Some("target=http://10.0.0.1/internal"),
None,
None,
);
let decision = engine.inspect(&req);
assert!(matches!(decision, Some(WafDecision::Block { rule, .. }) if rule == "ssrf"));
}
#[test]
fn detects_ssrf_private_192168() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req(
"GET",
"/proxy",
Some("target=http://192.168.1.1/admin"),
None,
None,
);
let decision = engine.inspect(&req);
assert!(matches!(decision, Some(WafDecision::Block { rule, .. }) if rule == "ssrf"));
}
#[test]
fn detects_ssrf_private_172() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req(
"GET",
"/proxy",
Some("url=http://172.16.0.1/meta"),
None,
None,
);
let decision = engine.inspect(&req);
assert!(matches!(decision, Some(WafDecision::Block { rule, .. }) if rule == "ssrf"));
}
#[test]
fn detects_ssrf_link_local_169254() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req(
"GET",
"/proxy",
Some("url=http://169.254.169.254/latest/meta-data/"),
None,
None,
);
let decision = engine.inspect(&req);
assert!(matches!(decision, Some(WafDecision::Block { rule, .. }) if rule == "ssrf"));
}
#[test]
fn detects_ssrf_file_scheme() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req(
"GET",
"/read",
Some("path=file:///tmp/data.txt"),
None,
None,
);
let decision = engine.inspect(&req);
assert!(matches!(decision, Some(WafDecision::Block { rule, .. }) if rule == "ssrf"));
}
#[test]
fn detects_ssrf_gopher_scheme() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req(
"GET",
"/fetch",
Some("url=gopher://127.0.0.1:25/"),
None,
None,
);
let decision = engine.inspect(&req);
assert!(matches!(decision, Some(WafDecision::Block { rule, .. }) if rule == "ssrf"));
}
#[test]
fn ssrf_allows_public_urls() {
let engine = RuleEngine::new(RuleConfig::default(), vec![]);
let req = make_req(
"GET",
"/proxy",
Some("url=https://example.com/page"),
None,
None,
);
assert!(engine.inspect(&req).is_none());
}
#[test]
fn ssrf_check_function_directly() {
assert!(check_ssrf("url=http://localhost/admin").is_some());
assert!(check_ssrf("url=http://0.0.0.0:8080").is_some());
assert!(check_ssrf("url=http://[::1]/secret").is_some());
assert!(check_ssrf("url=dict://evil.com").is_some());
assert!(check_ssrf("url=ftp://internal").is_some());
assert!(check_ssrf("url=https://google.com").is_none());
assert!(check_ssrf("search=hello+world").is_none());
}
#[test]
fn protocol_violation_disabled_allows() {
let config = RuleConfig {
protocol_violation: false,
..Default::default()
};
let engine = RuleEngine::new(config, vec![]);
let req = make_req("POST", "/api/clean", None, Some("clean body"), None);
let decision = engine.inspect(&req);
if let Some(WafDecision::Block { rule, .. }) = &decision {
assert_ne!(rule, "protocol_violation");
}
}
#[test]
fn log_only_mode_allows() {
let config = RuleConfig {
log_only: true,
..Default::default()
};
let engine = RuleEngine::new(config, vec![]);
let req = make_req("GET", "/search", Some("q=1 UNION SELECT *"), None, None);
assert!(engine.inspect(&req).is_none());
}
#[test]
fn custom_rules_take_priority() {
let custom = vec![custom::CustomRule {
name: "block-all-posts".into(),
match_config: custom::MatchConfig {
method: Some("POST".into()),
..Default::default()
},
action: custom::CustomRuleAction::Block,
status: 405,
reason: Some("POST not allowed".into()),
}];
let engine = RuleEngine::new(RuleConfig::default(), custom);
let req = make_req("POST", "/api/data", None, Some(r#"{"key":"value"}"#), None);
let decision = engine.inspect(&req);
assert!(matches!(
decision,
Some(WafDecision::Block { status: 405, .. })
));
}
}