1use serde::{Deserialize, Serialize};
5
6use crate::{WafDecision, WafRequest};
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "snake_case")]
11pub enum CustomRuleAction {
12 #[default]
14 Block,
15 Log,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, Default)]
21pub struct MatchConfig {
22 pub path: Option<String>,
25 pub method: Option<String>,
27 pub header_present: Option<String>,
29 pub header_missing: Option<String>,
31 pub header_value: Option<(String, String)>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct CustomRule {
38 pub name: String,
39 pub match_config: MatchConfig,
40 #[serde(default)]
41 pub action: CustomRuleAction,
42 #[serde(default = "default_status")]
44 pub status: u16,
45 #[serde(default)]
47 pub reason: Option<String>,
48}
49
50fn default_status() -> u16 {
51 403
52}
53
54pub struct CustomRuleSet {
56 rules: Vec<CustomRule>,
57}
58
59impl CustomRuleSet {
60 pub fn new(rules: Vec<CustomRule>) -> Self {
61 Self { rules }
62 }
63
64 pub fn rules(&self) -> &[CustomRule] {
65 &self.rules
66 }
67
68 pub fn check(&self, req: &WafRequest) -> Option<WafDecision> {
71 for rule in &self.rules {
72 if matches_rule(rule, req) {
73 match rule.action {
74 CustomRuleAction::Block => {
75 let reason = rule
76 .reason
77 .clone()
78 .unwrap_or_else(|| format!("blocked by custom rule: {}", rule.name));
79 return Some(WafDecision::Block {
80 status: rule.status,
81 reason,
82 rule: format!("custom:{}", rule.name),
83 });
84 }
85 CustomRuleAction::Log => {
86 tracing::info!(
87 rule = %rule.name,
88 path = %req.path,
89 method = %req.method,
90 "custom rule matched (log-only)"
91 );
92 }
94 }
95 }
96 }
97 None
98 }
99}
100
101fn matches_rule(rule: &CustomRule, req: &WafRequest) -> bool {
102 if let Some(ref m) = rule.match_config.method {
104 if !m.eq_ignore_ascii_case(&req.method) {
105 return false;
106 }
107 }
108
109 if let Some(ref pattern) = rule.match_config.path {
111 if !glob_match(pattern, &req.path) {
112 return false;
113 }
114 }
115
116 if let Some(ref hdr) = rule.match_config.header_present {
118 let key = hdr.to_lowercase();
119 if !req.headers.keys().any(|k| k.to_lowercase() == key) {
120 return false;
121 }
122 }
123
124 if let Some(ref hdr) = rule.match_config.header_missing {
126 let key = hdr.to_lowercase();
127 if req.headers.keys().any(|k| k.to_lowercase() == key) {
128 return false;
129 }
130 }
131
132 if let Some((ref hdr_name, ref hdr_val)) = rule.match_config.header_value {
134 let key = hdr_name.to_lowercase();
135 let found = req
136 .headers
137 .iter()
138 .any(|(k, v)| k.to_lowercase() == key && v == hdr_val);
139 if !found {
140 return false;
141 }
142 }
143
144 true
145}
146
147fn glob_match(pattern: &str, path: &str) -> bool {
149 let pat_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
151 let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
152 glob_match_parts(&pat_parts, &path_parts)
153}
154
155fn glob_match_parts(pattern: &[&str], path: &[&str]) -> bool {
156 if pattern.is_empty() {
157 return path.is_empty();
158 }
159
160 if pattern[0] == "**" {
161 let rest_pattern = &pattern[1..];
163 for i in 0..=path.len() {
164 if glob_match_parts(rest_pattern, &path[i..]) {
165 return true;
166 }
167 }
168 return false;
169 }
170
171 if path.is_empty() {
172 return false;
173 }
174
175 if segment_match(pattern[0], path[0]) {
176 glob_match_parts(&pattern[1..], &path[1..])
177 } else {
178 false
179 }
180}
181
182fn segment_match(pattern: &str, segment: &str) -> bool {
183 if pattern == "*" {
184 return true;
185 }
186 if pattern.contains('*') {
188 let parts: Vec<&str> = pattern.split('*').collect();
189 let mut pos = 0;
190 for (i, part) in parts.iter().enumerate() {
191 if part.is_empty() {
192 continue;
193 }
194 match segment[pos..].find(part) {
195 Some(found) => {
196 if i == 0 && found != 0 {
197 return false;
199 }
200 pos += found + part.len();
201 }
202 None => return false,
203 }
204 }
205 if let Some(last) = parts.last() {
207 if !last.is_empty() && !segment.ends_with(last) {
208 return false;
209 }
210 }
211 true
212 } else {
213 pattern == segment
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 fn make_req(method: &str, path: &str, headers: Vec<(&str, &str)>) -> WafRequest {
222 WafRequest {
223 client_ip: "127.0.0.1".parse().unwrap(),
224 method: method.into(),
225 path: path.into(),
226 query: None,
227 headers: headers
228 .into_iter()
229 .map(|(k, v)| (k.into(), v.into()))
230 .collect(),
231 body: None,
232 user_agent: None,
233 }
234 }
235
236 #[test]
237 fn block_by_path() {
238 let rules = CustomRuleSet::new(vec![CustomRule {
239 name: "block-admin".into(),
240 match_config: MatchConfig {
241 path: Some("/admin/**".into()),
242 ..Default::default()
243 },
244 action: CustomRuleAction::Block,
245 status: 403,
246 reason: None,
247 }]);
248
249 assert!(rules
250 .check(&make_req("GET", "/admin/settings", vec![]))
251 .is_some());
252 assert!(rules
253 .check(&make_req("GET", "/api/users", vec![]))
254 .is_none());
255 }
256
257 #[test]
258 fn block_by_method() {
259 let rules = CustomRuleSet::new(vec![CustomRule {
260 name: "block-delete".into(),
261 match_config: MatchConfig {
262 method: Some("DELETE".into()),
263 ..Default::default()
264 },
265 action: CustomRuleAction::Block,
266 status: 405,
267 reason: Some("DELETE not allowed".into()),
268 }]);
269
270 assert!(rules
271 .check(&make_req("DELETE", "/api/resource", vec![]))
272 .is_some());
273 assert!(rules
274 .check(&make_req("GET", "/api/resource", vec![]))
275 .is_none());
276 }
277
278 #[test]
279 fn block_by_header_present() {
280 let rules = CustomRuleSet::new(vec![CustomRule {
281 name: "require-no-debug".into(),
282 match_config: MatchConfig {
283 header_present: Some("X-Debug".into()),
284 ..Default::default()
285 },
286 action: CustomRuleAction::Block,
287 status: 403,
288 reason: None,
289 }]);
290
291 assert!(rules
292 .check(&make_req("GET", "/", vec![("X-Debug", "true")]))
293 .is_some());
294 assert!(rules.check(&make_req("GET", "/", vec![])).is_none());
295 }
296
297 #[test]
298 fn block_by_header_missing() {
299 let rules = CustomRuleSet::new(vec![CustomRule {
300 name: "require-auth".into(),
301 match_config: MatchConfig {
302 header_missing: Some("Authorization".into()),
303 ..Default::default()
304 },
305 action: CustomRuleAction::Block,
306 status: 401,
307 reason: Some("Authorization required".into()),
308 }]);
309
310 assert!(rules.check(&make_req("GET", "/api/data", vec![])).is_some());
311 assert!(rules
312 .check(&make_req(
313 "GET",
314 "/api/data",
315 vec![("Authorization", "Bearer token")]
316 ))
317 .is_none());
318 }
319
320 #[test]
321 fn block_by_header_value() {
322 let rules = CustomRuleSet::new(vec![CustomRule {
323 name: "block-bad-origin".into(),
324 match_config: MatchConfig {
325 header_value: Some(("Origin".into(), "http://evil.com".into())),
326 ..Default::default()
327 },
328 action: CustomRuleAction::Block,
329 status: 403,
330 reason: None,
331 }]);
332
333 assert!(rules
334 .check(&make_req("GET", "/", vec![("Origin", "http://evil.com")]))
335 .is_some());
336 assert!(rules
337 .check(&make_req("GET", "/", vec![("Origin", "http://good.com")]))
338 .is_none());
339 }
340
341 #[test]
342 fn log_action_does_not_block() {
343 let rules = CustomRuleSet::new(vec![CustomRule {
344 name: "log-api".into(),
345 match_config: MatchConfig {
346 path: Some("/api/**".into()),
347 ..Default::default()
348 },
349 action: CustomRuleAction::Log,
350 status: 200,
351 reason: None,
352 }]);
353
354 assert!(rules
355 .check(&make_req("GET", "/api/users", vec![]))
356 .is_none());
357 }
358
359 #[test]
360 fn combined_conditions() {
361 let rules = CustomRuleSet::new(vec![CustomRule {
362 name: "block-post-admin".into(),
363 match_config: MatchConfig {
364 path: Some("/admin/**".into()),
365 method: Some("POST".into()),
366 ..Default::default()
367 },
368 action: CustomRuleAction::Block,
369 status: 403,
370 reason: None,
371 }]);
372
373 assert!(rules
375 .check(&make_req("POST", "/admin/settings", vec![]))
376 .is_some());
377 assert!(rules
379 .check(&make_req("GET", "/admin/settings", vec![]))
380 .is_none());
381 assert!(rules
383 .check(&make_req("POST", "/api/users", vec![]))
384 .is_none());
385 }
386
387 #[test]
388 fn evaluation_order_first_match_wins() {
389 let rules = CustomRuleSet::new(vec![
390 CustomRule {
391 name: "allow-health".into(),
392 match_config: MatchConfig {
393 path: Some("/health".into()),
394 ..Default::default()
395 },
396 action: CustomRuleAction::Log, status: 200,
398 reason: None,
399 },
400 CustomRule {
401 name: "block-all".into(),
402 match_config: MatchConfig::default(), action: CustomRuleAction::Block,
404 status: 403,
405 reason: None,
406 },
407 ]);
408
409 assert!(rules.check(&make_req("GET", "/health", vec![])).is_some());
411 }
412
413 #[test]
414 fn glob_wildcard_single_segment() {
415 let rules = CustomRuleSet::new(vec![CustomRule {
416 name: "block-user-export".into(),
417 match_config: MatchConfig {
418 path: Some("/api/*/export".into()),
419 ..Default::default()
420 },
421 action: CustomRuleAction::Block,
422 status: 403,
423 reason: None,
424 }]);
425
426 assert!(rules
427 .check(&make_req("GET", "/api/users/export", vec![]))
428 .is_some());
429 assert!(rules
430 .check(&make_req("GET", "/api/orders/export", vec![]))
431 .is_some());
432 assert!(rules
433 .check(&make_req("GET", "/api/users/list", vec![]))
434 .is_none());
435 }
436
437 #[test]
438 fn glob_double_star_matches_deep() {
439 assert!(glob_match("/admin/**", "/admin/a/b/c"));
440 assert!(glob_match("/admin/**", "/admin"));
441 assert!(!glob_match("/admin/**", "/api/admin"));
442 }
443
444 #[test]
445 fn glob_exact_match() {
446 assert!(glob_match("/health", "/health"));
447 assert!(!glob_match("/health", "/healthz"));
448 }
449}