1pub mod decode;
24pub mod detect;
25
26use std::collections::BTreeMap;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::sync::{Mutex, OnceLock};
29
30use crate::application::Application;
31use crate::middleware::Middleware;
32use crate::request::Request;
33use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
34use crate::server::ConnectionInfo;
35
36#[derive(Debug, Clone, Copy, PartialEq, Default)]
38pub enum Mode {
39 #[default]
40 Block,
41 Detect,
42 Off,
43}
44
45impl Mode {
46 pub fn parse(s: &str) -> Option<Mode> {
47 match s.trim().to_ascii_lowercase().as_str() {
48 "block" | "" => Some(Mode::Block),
49 "detect" | "log" => Some(Mode::Detect),
50 "off" => Some(Mode::Off),
51 _ => None,
52 }
53 }
54}
55
56#[derive(Debug, Clone, PartialEq)]
58pub enum Field {
59 Path,
60 Query,
62 Arg(String),
64 Args,
66 Header(String),
67 Method,
68 Ip,
70 Body,
72}
73
74impl Field {
75 pub fn parse(s: &str) -> Option<Field> {
76 let s = s.trim();
77 let lower = s.to_ascii_lowercase();
78 Some(match lower.as_str() {
79 "path" => Field::Path,
80 "query" => Field::Query,
81 "args" => Field::Args,
82 "method" => Field::Method,
83 "ip" => Field::Ip,
84 "body" => Field::Body,
85 _ if lower.starts_with("arg:") => Field::Arg(s[4..].to_string()),
86 _ if lower.starts_with("header:") => Field::Header(s[7..].to_ascii_lowercase()),
87 _ => return None,
88 })
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq)]
94pub enum Action {
95 Deny,
97 Allow,
99 Log,
101 Score(u32),
103}
104
105#[derive(Debug, Clone, PartialEq)]
107pub struct CustomRule {
108 pub id: u32,
109 pub field: Field,
110 pub contains: Vec<String>,
113 pub prefix: Vec<String>,
114 pub equals: Vec<String>,
115 pub cidr: Vec<String>,
116 pub action: Action,
117}
118
119#[derive(Debug, Clone, PartialEq)]
121pub struct Exclusion {
122 pub rule: String,
123 pub path_prefix: String,
124}
125
126impl Exclusion {
127 pub fn parse(s: &str) -> Exclusion {
129 let (rule, path) = s.trim().split_once('@').unwrap_or((s.trim(), ""));
130 Exclusion { rule: rule.trim().to_string(), path_prefix: path.trim().to_string() }
131 }
132
133 fn covers(&self, id: u32, path: &str) -> bool {
134 let rule_ok = self.rule == "*" || self.rule == id.to_string() || self.rule.strip_suffix('*').is_some_and(|p| id.to_string().starts_with(p));
135 rule_ok && path.starts_with(&self.path_prefix)
136 }
137}
138
139#[derive(Debug, Clone, PartialEq)]
141pub struct WafConfig {
142 pub mode: Mode,
143 pub paranoia: u8,
146 pub threshold: u32,
148 pub inspect_body_bytes: usize,
150 pub max_args: usize,
152 pub exclude: Vec<Exclusion>,
153 pub rules: Vec<CustomRule>,
154}
155
156impl Default for WafConfig {
157 fn default() -> Self {
158 WafConfig { mode: Mode::Block, paranoia: 1, threshold: 5, inspect_body_bytes: 64 * 1024, max_args: 255, exclude: Vec::new(), rules: Vec::new() }
159 }
160}
161
162#[derive(Debug, Clone, PartialEq)]
164pub struct Match {
165 pub rule: u32,
166 pub score: u32,
167 pub location: String,
169}
170
171#[derive(Debug, Clone, PartialEq, Default)]
173pub struct Verdict {
174 pub score: u32,
175 pub matches: Vec<Match>,
176 pub denied: bool,
178 pub allowed: bool,
180}
181
182impl Verdict {
183 pub fn rule_ids(&self) -> Vec<u32> {
184 let mut ids: Vec<u32> = self.matches.iter().map(|m| m.rule).collect();
185 ids.sort_unstable();
186 ids.dedup();
187 ids
188 }
189}
190
191#[derive(Debug, Clone)]
193pub struct Waf {
194 config: WafConfig,
195}
196
197fn form_pairs(text: &str) -> Vec<(String, String)> {
199 text.split('&')
200 .filter(|p| !p.is_empty())
201 .take(4096)
202 .map(|pair| {
203 let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
204 let dec = |s: &str| crate::url::URL::percent_decode(&s.replace('+', " "));
205 (dec(k), dec(v))
206 })
207 .collect()
208}
209
210fn json_strings(text: &str) -> Vec<String> {
212 let b = text.as_bytes();
213 let mut out = Vec::new();
214 let mut i = 0;
215 while i < b.len() && out.len() < 4096 {
216 if b[i] == b'"' {
217 let mut s = Vec::new();
218 i += 1;
219 while i < b.len() && b[i] != b'"' {
220 if b[i] == b'\\' && i + 1 < b.len() {
221 match b[i + 1] {
222 b'n' => s.push(b'\n'),
223 b't' => s.push(b'\t'),
224 b'u' if i + 5 < b.len() => {
225 if let Some(ch) = std::str::from_utf8(&b[i + 2..i + 6]).ok().and_then(|h| u32::from_str_radix(h, 16).ok()).and_then(char::from_u32) {
226 let mut buf = [0u8; 4];
227 s.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
228 }
229 i += 4;
230 }
231 other => s.push(other),
232 }
233 i += 2;
234 } else {
235 s.push(b[i]);
236 i += 1;
237 }
238 }
239 out.push(String::from_utf8_lossy(&s).into_owned());
240 }
241 i += 1;
242 }
243 out
244}
245
246fn multipart_fields(body: &[u8], content_type: &str) -> Vec<(String, String)> {
248 let Some(boundary) = content_type.split(';').find_map(|p| {
250 let p = p.trim();
251 p.get(..9).filter(|k| k.eq_ignore_ascii_case("boundary=")).map(|_| p[9..].trim_matches('"').to_string())
252 }) else {
253 return Vec::new();
254 };
255 let text = String::from_utf8_lossy(body);
256 let delimiter = format!("--{boundary}");
257 let mut out = Vec::new();
258 for part in text.split(delimiter.as_str()).skip(1).take(256) {
259 let Some((head, value)) = part.split_once("\r\n\r\n") else { continue };
260 let head_lower = head.to_ascii_lowercase();
261 let name = head_lower.split("name=\"").nth(1).and_then(|n| n.split('"').next()).unwrap_or("").to_string();
262 if head_lower.contains("filename=") {
263 if let Some(file) = head.split("filename=\"").nth(1).and_then(|n| n.split('"').next()) {
265 out.push((format!("{name}.filename"), file.to_string()));
266 }
267 continue;
268 }
269 out.push((name, value.trim_end_matches("\r\n").trim_end_matches("--").to_string()));
270 }
271 out
272}
273
274impl Waf {
275 pub fn new(config: WafConfig) -> Waf {
276 Waf { config }
277 }
278
279 pub fn config(&self) -> &WafConfig {
280 &self.config
281 }
282
283 fn enabled(&self, id: u32, path: &str) -> Option<&'static detect::Rule> {
284 let rule = detect::rule(id)?;
285 (rule.paranoia <= self.config.paranoia && !self.config.exclude.iter().any(|e| e.covers(id, path))).then_some(rule)
286 }
287
288 pub fn inspect(&self, request: &Request, connection: &ConnectionInfo) -> Verdict {
290 let mut verdict = Verdict::default();
291 if self.config.mode == Mode::Off {
292 return verdict;
293 }
294 let target = &request.request_uri;
295 let (raw_path, query) = target.split_once('?').unwrap_or((target, ""));
296 let path = crate::url::URL::percent_decode(raw_path);
297 let header = |name: &str| request.header(name).map(|h| h.value.to_string()).unwrap_or_default();
298 let raw_content_type = header("Content-Type");
299 let content_type = raw_content_type.to_ascii_lowercase();
300
301 let mut args = form_pairs(query);
302 let body = if self.config.inspect_body_bytes > 0 { &request.body[..request.body.len().min(self.config.inspect_body_bytes)] } else { &[][..] };
303 let mut body_values: Vec<(String, String)> = Vec::new();
304 if !body.is_empty() {
305 if content_type.starts_with("application/x-www-form-urlencoded") {
306 args.extend(form_pairs(&String::from_utf8_lossy(body)));
307 } else if content_type.contains("json") {
308 body_values.extend(json_strings(&String::from_utf8_lossy(body)).into_iter().map(|s| ("json".to_string(), s)));
309 } else if content_type.starts_with("multipart/form-data") {
310 args.extend(multipart_fields(body, &raw_content_type));
311 } else if content_type.starts_with("text/") || content_type.contains("xml") {
312 body_values.push(("body".to_string(), String::from_utf8_lossy(body).into_owned()));
313 }
314 }
315
316 let ip = connection.client.ip.as_str();
318 for rule in &self.config.rules {
319 if self.custom_matches(rule, request, &path, query, &args, body, ip) {
320 match rule.action {
321 Action::Deny => verdict.denied = true,
322 Action::Allow => {
323 verdict.allowed = true;
324 return verdict;
325 }
326 Action::Log => verdict.matches.push(Match { rule: rule.id, score: 0, location: "custom".into() }),
327 Action::Score(n) => {
328 verdict.score += n;
329 verdict.matches.push(Match { rule: rule.id, score: n, location: "custom".into() });
330 }
331 }
332 }
333 }
334
335 let add = |verdict: &mut Verdict, hits: &[u32], location: &str| {
336 let mut seen: Vec<u32> = Vec::new();
337 for id in hits {
338 if seen.contains(id) {
339 continue;
340 }
341 seen.push(*id);
342 if let Some(rule) = self.enabled(*id, &path) {
343 verdict.score += rule.score;
344 verdict.matches.push(Match { rule: *id, score: rule.score, location: location.to_string() });
345 }
346 }
347 };
348 let mut hits = Vec::new();
349 detect::value(&decode::normalize(&path), &mut hits);
351 add(&mut verdict, &hits, "path");
352 if args.len() > self.config.max_args {
353 add(&mut verdict, &[920100], "args");
354 }
355 for (name, value) in &args {
356 hits.clear();
357 detect::value(&decode::normalize(value), &mut hits);
358 if name.len() > 1 {
359 detect::value(&decode::normalize(name), &mut hits);
360 }
361 if value.len() > 8192 {
362 hits.push(920200);
363 }
364 add(&mut verdict, &hits, &format!("arg:{}", truncate(name, 64)));
365 }
366 for (name, value) in &body_values {
367 hits.clear();
368 detect::value(&decode::normalize(value), &mut hits);
369 add(&mut verdict, &hits, name);
370 }
371 for cookie in request.headers.iter().filter(|h| h.name.eq_ignore_ascii_case("cookie")) {
373 for pair in cookie.value.split(';').take(64) {
374 let (name, value) = pair.split_once('=').unwrap_or(("", pair));
375 hits.clear();
376 detect::value(&decode::normalize(value), &mut hits);
377 add(&mut verdict, &hits, &format!("cookie:{}", truncate(name.trim(), 64)));
378 }
379 }
380 let user_agent = decode::normalize(&header("User-Agent"));
383 if detect::scanner(&user_agent) {
384 add(&mut verdict, &[913100], "header:user-agent");
385 }
386 for h in request.headers.iter().take(128) {
387 if h.value.contains("${") && detect::log4shell(&decode::normalize(&h.value)) {
388 add(&mut verdict, &[944100], &format!("header:{}", h.name.to_ascii_lowercase()));
389 }
390 }
391 if self.config.paranoia >= 2 {
392 for name in ["Referer", "User-Agent"] {
393 hits.clear();
394 detect::value(&decode::normalize(&header(name)), &mut hits);
395 add(&mut verdict, &hits, &format!("header:{}", name.to_ascii_lowercase()));
396 }
397 }
398 verdict
399 }
400
401 #[allow(clippy::too_many_arguments)]
402 fn custom_matches(&self, rule: &CustomRule, request: &Request, path: &str, query: &str, args: &[(String, String)], body: &[u8], ip: &str) -> bool {
403 if rule.field == Field::Ip {
404 return rule.cidr.iter().any(|c| crate::ip_filter::matches(c, ip));
405 }
406 let values: Vec<String> = match &rule.field {
407 Field::Path => vec![path.to_string()],
408 Field::Query => vec![query.to_string()],
409 Field::Method => vec![request.method.clone()],
410 Field::Arg(name) => args.iter().filter(|(k, _)| k == name).map(|(_, v)| v.clone()).collect(),
411 Field::Args => args.iter().map(|(_, v)| v.clone()).collect(),
412 Field::Header(name) => request.headers.iter().filter(|h| h.name.eq_ignore_ascii_case(name)).map(|h| h.value.to_string()).collect(),
413 Field::Body => vec![String::from_utf8_lossy(body).into_owned()],
414 Field::Ip => Vec::new(),
415 };
416 values.iter().any(|v| {
417 let v = v.to_ascii_lowercase();
418 rule.contains.iter().any(|c| v.contains(&c.to_ascii_lowercase()))
419 || rule.prefix.iter().any(|p| v.starts_with(&p.to_ascii_lowercase()))
420 || rule.equals.iter().any(|e| v == e.to_ascii_lowercase())
421 })
422 }
423}
424
425fn truncate(s: &str, n: usize) -> String {
426 s.chars().take(n).collect()
427}
428
429struct Counters {
432 blocked: AtomicU64,
433 detected: AtomicU64,
434 rules: Mutex<BTreeMap<u32, u64>>,
435}
436
437fn counters() -> &'static Counters {
438 static C: OnceLock<Counters> = OnceLock::new();
439 C.get_or_init(|| Counters { blocked: AtomicU64::new(0), detected: AtomicU64::new(0), rules: Mutex::new(BTreeMap::new()) })
440}
441
442pub fn prometheus_text() -> String {
445 let c = counters();
446 let rules = c.rules.lock().unwrap_or_else(|e| e.into_inner());
447 if rules.is_empty() && c.blocked.load(Ordering::Relaxed) == 0 && c.detected.load(Ordering::Relaxed) == 0 {
448 return String::new();
449 }
450 let mut out = String::from("# HELP rws_waf_requests_total Requests the web application firewall matched, by what it did.\n# TYPE rws_waf_requests_total counter\n");
451 out.push_str(&format!("rws_waf_requests_total{{action=\"blocked\"}} {}\nrws_waf_requests_total{{action=\"detected\"}} {}\n", c.blocked.load(Ordering::Relaxed), c.detected.load(Ordering::Relaxed)));
452 out.push_str("# HELP rws_waf_rule_matches_total Matches per WAF rule.\n# TYPE rws_waf_rule_matches_total counter\n");
453 for (rule, n) in rules.iter() {
454 out.push_str(&format!("rws_waf_rule_matches_total{{rule=\"{rule}\"}} {n}\n"));
455 }
456 out
457}
458
459pub struct WafLayer {
463 waf: Waf,
464}
465
466impl WafLayer {
467 pub fn new(config: WafConfig) -> WafLayer {
468 WafLayer { waf: Waf::new(config) }
469 }
470}
471
472fn refused(verdict: &Verdict) -> Response {
473 let ids: Vec<String> = verdict.rule_ids().iter().map(u32::to_string).collect();
474 let detail = if verdict.denied && ids.is_empty() { "refused by a firewall rule".to_string() } else { format!("refused by the web application firewall (rules {})", ids.join(", ")) };
475 let mut response = crate::error::problem(STATUS_CODE_REASON_PHRASE.n403_forbidden, &detail);
476 response.headers.push(crate::header::Header::new("Cache-Control", "no-store"));
477 response
478}
479
480impl Middleware for WafLayer {
481 fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
482 let verdict = self.waf.inspect(request, connection);
483 if verdict.allowed || (verdict.matches.is_empty() && !verdict.denied) {
484 return next.execute(request, connection);
485 }
486 let c = counters();
487 {
488 let mut rules = c.rules.lock().unwrap_or_else(|e| e.into_inner());
489 for id in verdict.rule_ids() {
490 *rules.entry(id).or_insert(0) += 1;
491 }
492 }
493 let block = verdict.denied || (self.waf.config.mode == Mode::Block && verdict.score >= self.waf.config.threshold);
494 let locations: Vec<String> = verdict.matches.iter().map(|m| format!("{}@{}", m.rule, m.location)).collect();
495 eprintln!(
496 "[waf] {} {} {} {} score={} {}",
497 if block { "blocked" } else { "detected" },
498 connection.client.ip,
499 request.method,
500 truncate(&request.request_uri, 200),
501 verdict.score,
502 locations.join(" ")
503 );
504 if block {
505 c.blocked.fetch_add(1, Ordering::Relaxed);
506 return Ok(refused(&verdict));
507 }
508 c.detected.fetch_add(1, Ordering::Relaxed);
509 next.execute(request, connection)
510 }
511
512 fn runs_inline(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
514 true
515 }
516
517 fn accepts_streamed_body(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
520 self.waf.config.inspect_body_bytes == 0
521 }
522}
523
524#[doc(hidden)]
525pub mod fuzz {
526 pub fn inspect(data: &[u8]) {
528 let text = String::from_utf8_lossy(data);
529 let v = super::decode::normalize(&text);
530 let mut hits = Vec::new();
531 super::detect::value(&v, &mut hits);
532 let _ = super::detect::scanner(&v);
533 let _ = super::json_strings(&text);
534 let _ = super::multipart_fields(data, "multipart/form-data; boundary=x");
535 }
536}
537
538#[cfg(test)]
539mod tests;