1use crate::request::Request;
45use regex::{Regex, RegexBuilder};
46use serde::{Deserialize, Serialize};
47use std::sync::atomic::{AtomicUsize, Ordering};
48use thiserror::Error;
49
50#[derive(
52 Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
53)]
54#[serde(rename_all = "snake_case")]
55pub enum WafMode {
56 #[default]
58 Block,
59 ReportOnly,
61}
62
63#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum WafTarget {
67 Uri,
69 Method,
71 Header(String),
73 AnyHeader,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
79pub struct WafRule {
80 pub id: String,
82 pub target: WafTarget,
84 pub pattern: String,
86}
87
88impl WafRule {
89 pub fn new(id: &str, target: WafTarget, pattern: &str) -> Self {
91 Self {
92 id: id.to_string(),
93 target,
94 pattern: pattern.to_string(),
95 }
96 }
97}
98
99#[derive(Clone, Debug, PartialEq, Eq, Error)]
101pub enum WafError {
102 #[error("WAF rule id must not be empty")]
104 EmptyId,
105 #[error("duplicate WAF rule id `{0}`")]
107 DuplicateId(String),
108 #[error("WAF rule `{0}`: header target needs a header name")]
110 EmptyHeaderName(String),
111 #[error("WAF rule `{id}`: invalid pattern: {reason}")]
113 InvalidPattern {
114 id: String,
116 reason: String,
118 },
119}
120
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub enum WafAction {
124 Blocked,
126 Reported,
128}
129
130#[derive(Clone, Debug, PartialEq, Eq)]
132pub struct WafEvent {
133 pub rule_id: String,
135 pub target: String,
137 pub action: WafAction,
139 pub method: String,
141 pub path: String,
143}
144
145impl WafEvent {
146 pub fn to_log_line(&self) -> String {
148 let action = match self.action {
149 WafAction::Blocked => "blocked",
150 WafAction::Reported => "reported",
151 };
152 format!(
153 "waf rule={} target={} action={action} method={} path={:?}",
154 self.rule_id, self.target, self.method, self.path
155 )
156 }
157}
158
159#[derive(Clone, Debug)]
160struct CompiledRule {
161 rule: WafRule,
162 regex: Regex,
163}
164
165#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
167pub struct WafConfig {
168 #[serde(default)]
170 pub mode: WafMode,
171 pub rules: Vec<WafRule>,
173}
174
175#[derive(Clone, Debug, Serialize, Deserialize)]
180#[serde(try_from = "WafConfig", into = "WafConfig")]
181pub struct WafPolicy {
182 mode: WafMode,
183 rules: Vec<CompiledRule>,
184}
185
186impl PartialEq for WafPolicy {
187 fn eq(&self, other: &Self) -> bool {
188 self.mode == other.mode && self.rules().eq(other.rules())
189 }
190}
191
192impl Eq for WafPolicy {}
193
194impl WafPolicy {
195 pub fn new(
202 mode: WafMode,
203 rules: Vec<WafRule>,
204 ) -> Result<Self, WafError> {
205 let mut compiled: Vec<CompiledRule> =
206 Vec::with_capacity(rules.len());
207 for rule in rules {
208 validate(&rule, &compiled)?;
209 let regex = RegexBuilder::new(&rule.pattern)
210 .case_insensitive(true)
211 .build()
212 .map_err(|e| WafError::InvalidPattern {
213 id: rule.id.clone(),
214 reason: e.to_string(),
215 })?;
216 compiled.push(CompiledRule { rule, regex });
217 }
218 Ok(Self {
219 mode,
220 rules: compiled,
221 })
222 }
223
224 pub fn baseline(mode: WafMode) -> Self {
226 Self::new(mode, Self::baseline_rules())
227 .expect("baseline rules are valid")
228 }
229
230 pub fn baseline_rules() -> Vec<WafRule> {
232 use WafTarget::{AnyHeader, Uri};
233 [
234 ("path-traversal", Uri, r"(^|[/\\])\.\.([/\\]|$)"),
235 ("null-byte", Uri, r"\x00"),
236 ("sensitive-file", Uri, r"/\.(git|svn|hg|env|htaccess|htpasswd)(/|$)"),
237 ("script-injection", Uri, r"<\s*script|javascript:|on(error|load)\s*="),
238 ("sql-injection", Uri, r"\bunion\b.+\bselect\b|'\s*or\s+'?\d+'?\s*=\s*'?\d+|;\s*drop\s+table\b"),
239 ("command-injection", Uri, r"[;|`]\s*(cat|ls|id|wget|curl|sh|bash)\b|\$\("),
240 ("header-injection", AnyHeader, r"[\r\n]|\$\{jndi:"),
241 ]
242 .into_iter()
243 .map(|(id, target, pattern)| WafRule::new(id, target, pattern))
244 .collect()
245 }
246
247 pub fn mode(&self) -> WafMode {
249 self.mode
250 }
251
252 pub fn rules(&self) -> impl Iterator<Item = &WafRule> {
254 self.rules.iter().map(|c| &c.rule)
255 }
256
257 pub fn inspect(&self, request: &Request) -> Option<WafEvent> {
260 let decoded = percent_decode(request.path());
261 let compiled = self
262 .rules
263 .iter()
264 .find(|c| matches(c, request, &decoded))?;
265 Some(WafEvent {
266 rule_id: compiled.rule.id.clone(),
267 target: target_label(&compiled.rule.target),
268 action: match self.mode {
269 WafMode::Block => WafAction::Blocked,
270 WafMode::ReportOnly => WafAction::Reported,
271 },
272 method: request.method().to_string(),
273 path: request.path().to_string(),
274 })
275 }
276}
277
278impl TryFrom<WafConfig> for WafPolicy {
279 type Error = WafError;
280
281 fn try_from(config: WafConfig) -> Result<Self, Self::Error> {
282 Self::new(config.mode, config.rules)
283 }
284}
285
286impl From<WafPolicy> for WafConfig {
287 fn from(policy: WafPolicy) -> Self {
288 Self {
289 mode: policy.mode,
290 rules: policy.rules.into_iter().map(|c| c.rule).collect(),
291 }
292 }
293}
294
295static BLOCKED: AtomicUsize = AtomicUsize::new(0);
296static REPORTED: AtomicUsize = AtomicUsize::new(0);
297
298#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
300pub struct WafStats {
301 pub blocked: usize,
303 pub reported: usize,
305}
306
307pub fn stats() -> WafStats {
309 WafStats {
310 blocked: BLOCKED.load(Ordering::Relaxed),
311 reported: REPORTED.load(Ordering::Relaxed),
312 }
313}
314
315pub(crate) fn metrics_lines() -> String {
317 let s = stats();
318 format!(
319 "http_handle_waf_total{{action=\"blocked\"}} {}\nhttp_handle_waf_total{{action=\"reported\"}} {}\n",
320 s.blocked, s.reported
321 )
322}
323
324pub(crate) fn record(event: &WafEvent) {
326 log::warn!("{}", event.to_log_line());
327 let counter = match event.action {
328 WafAction::Blocked => &BLOCKED,
329 WafAction::Reported => &REPORTED,
330 };
331 let _ = counter.fetch_add(1, Ordering::Relaxed);
332}
333
334fn validate(
335 rule: &WafRule,
336 earlier: &[CompiledRule],
337) -> Result<(), WafError> {
338 if rule.id.is_empty() {
339 return Err(WafError::EmptyId);
340 }
341 if earlier.iter().any(|c| c.rule.id == rule.id) {
342 return Err(WafError::DuplicateId(rule.id.clone()));
343 }
344 match &rule.target {
345 WafTarget::Header(name) if name.is_empty() => {
346 Err(WafError::EmptyHeaderName(rule.id.clone()))
347 }
348 _ => Ok(()),
349 }
350}
351
352fn matches(
353 compiled: &CompiledRule,
354 request: &Request,
355 decoded: &str,
356) -> bool {
357 let regex = &compiled.regex;
358 match &compiled.rule.target {
359 WafTarget::Uri => {
360 regex.is_match(request.path()) || regex.is_match(decoded)
361 }
362 WafTarget::Method => regex.is_match(request.method()),
363 WafTarget::Header(name) => {
364 request.header(name).is_some_and(|v| regex.is_match(v))
365 }
366 WafTarget::AnyHeader => {
367 request.headers().iter().any(|(_, v)| regex.is_match(v))
368 }
369 }
370}
371
372fn target_label(target: &WafTarget) -> String {
373 match target {
374 WafTarget::Uri => "uri".into(),
375 WafTarget::Method => "method".into(),
376 WafTarget::Header(name) => {
377 format!("header:{}", name.to_ascii_lowercase())
378 }
379 WafTarget::AnyHeader => "any_header".into(),
380 }
381}
382
383fn percent_decode(input: &str) -> String {
385 let bytes = input.as_bytes();
386 let mut out = Vec::with_capacity(bytes.len());
387 let mut i = 0;
388 while i < bytes.len() {
389 let hex = bytes.get(i + 1..i + 3).and_then(|h| {
390 u8::from_str_radix(std::str::from_utf8(h).ok()?, 16).ok()
391 });
392 match (bytes[i], hex) {
393 (b'%', Some(byte)) => {
394 out.push(byte);
395 i += 3;
396 }
397 (byte, _) => {
398 out.push(byte);
399 i += 1;
400 }
401 }
402 }
403 String::from_utf8_lossy(&out).into_owned()
404}