1use std::pin::Pin;
35
36use bytes::Bytes;
37use http_body_util::BodyExt as _;
38use http_body_util::Full;
39use hyper::body::Body;
40use regex::Regex;
41
42use crate::config::RouteWaf;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum WafFilter {
47 Sqli,
48 Xss,
49 PathTraversal,
50}
51
52impl WafFilter {
53 pub fn as_str(&self) -> &'static str {
54 match self {
55 WafFilter::Sqli => "sqli",
56 WafFilter::Xss => "xss",
57 WafFilter::PathTraversal => "path_traversal",
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum WafTarget {
65 Path,
66 Query,
67 Header,
68 Body,
69}
70
71impl WafTarget {
72 pub fn as_str(&self) -> &'static str {
73 match self {
74 WafTarget::Path => "path",
75 WafTarget::Query => "query",
76 WafTarget::Header => "header",
77 WafTarget::Body => "body",
78 }
79 }
80}
81
82#[derive(Debug, Clone)]
86pub struct WafMatch {
87 pub filter: WafFilter,
88 pub pattern: String,
89 pub target: WafTarget,
90 pub value_preview: String,
91}
92
93fn truncate_preview(s: &str) -> String {
95 const MAX_PREVIEW: usize = 64;
96 if s.len() <= MAX_PREVIEW {
97 s.to_string()
98 } else {
99 let mut end = MAX_PREVIEW;
101 while end > 0 && !s.is_char_boundary(end) {
102 end -= 1;
103 }
104 format!("{}...", &s[..end])
105 }
106}
107
108const SQLI_PATTERNS: &[&str] = &[
110 r"(?i)union\s+select",
111 r"(?i)\bor\s+1\s*=\s*1\b",
112 r"(?i)'\s*;\s*drop\s+table",
113 r"(?i)'\s*;\s*delete\s+from",
114 r"(?i)'\s*;\s*insert\s+into",
115 r"(?i)'\s*;\s*update\s+set",
116 r"(?i)--\s*$",
117 r"(?i)--\s",
118 r"(?i)xp_cmdshell",
119 r"(?i)0x[0-9a-f]{8,}\b",
120 r"(?i)'\s*or\s*'",
121 r"(?i)'\s*and\s*'",
122 r"(?i);\s*select\s+",
123 r"(?i)\bexec\s*\(",
124 r"(?i)\bwaitfor\s+delay\b",
125 r"(?i)\bsleep\s*\(",
126 r"(?i)\bbenchmark\s*\(",
127 r"(?i)\bload_file\s*\(",
128 r"(?i)\binto\s+outfile\b",
129 r"(?i)\bgroup\s+by\s+\d+",
130 r"(?i)'\s*=\s*'",
131];
132
133const XSS_PATTERNS: &[&str] = &[
135 r"(?i)<script",
136 r"(?i)javascript:",
137 r"(?i)onerror\s*=",
138 r"(?i)onload\s*=",
139 r"(?i)onclick\s*=",
140 r"(?i)onmouseover\s*=",
141 r"(?i)<iframe",
142 r"(?i)<object",
143 r"(?i)<embed",
144 r"(?i)document\.cookie",
145 r"(?i)eval\s*\(",
146 r"(?i)alert\s*\(",
147 r"(?i)<img[^>]+src\s*=",
148 r"(?i)<svg[^>]+onload",
149 r"(?i)String\.fromCharCode",
150 r"(?i)\\x3cscript",
151 r"(?i)<script",
152 r"(?i)<body[^>]+onload",
153 r"(?i)expression\s*\(",
154];
155
156const PATH_TRAVERSAL_PATTERNS: &[&str] = &[
158 r"\.\./",
159 r"\.\.\\",
160 r"(?i)%2e%2e%2f",
161 r"(?i)%2e%2e/",
162 r"(?i)%2e%2e%5c",
163 r"\.\.%2f",
164 r"(?i)\.\.//",
165 r"(?i)\.\.\\",
166 r"(?i)\.\.;/",
167 r"%00",
168 r"(?i)%252e%252e%252f",
169 r"(?i)/etc/passwd",
170 r"(?i)/etc/shadow",
171 r"(?i)c:\\windows\\",
172 r"(?i)\\windows\\win\.ini",
173 r"(?i)/proc/self/",
174];
175
176fn builtin_patterns(filter: WafFilter) -> &'static [&'static str] {
178 match filter {
179 WafFilter::Sqli => SQLI_PATTERNS,
180 WafFilter::Xss => XSS_PATTERNS,
181 WafFilter::PathTraversal => PATH_TRAVERSAL_PATTERNS,
182 }
183}
184
185pub struct WafGeneration {
189 filters: Vec<WafFilterEntry>,
190 max_body_inspect_bytes: u64,
191 dry_run: bool,
192}
193
194struct WafFilterEntry {
195 filter: WafFilter,
196 patterns: Vec<Regex>,
197}
198
199impl WafGeneration {
200 pub fn from_config(waf: &RouteWaf) -> Option<Self> {
203 if !waf.enabled {
204 return None;
205 }
206 let filter_names: Vec<WafFilter> = if waf.filters.is_empty() {
208 vec![WafFilter::Sqli, WafFilter::Xss, WafFilter::PathTraversal]
209 } else {
210 waf.filters
211 .iter()
212 .filter_map(|f| match f.as_str() {
213 "sqli" => Some(WafFilter::Sqli),
214 "xss" => Some(WafFilter::Xss),
215 "path_traversal" => Some(WafFilter::PathTraversal),
216 _ => None,
217 })
218 .collect()
219 };
220 let mut entries = Vec::with_capacity(filter_names.len());
221 for filter in filter_names {
222 let mut patterns: Vec<Regex> = builtin_patterns(filter)
223 .iter()
224 .map(|p| Regex::new(p).expect("built-in WAF pattern compiles"))
225 .collect();
226 for custom in &waf.custom_patterns {
230 if let Ok(re) = Regex::new(custom) {
231 patterns.push(re);
232 }
233 }
234 entries.push(WafFilterEntry { filter, patterns });
235 }
236 Some(WafGeneration {
237 filters: entries,
238 max_body_inspect_bytes: waf.max_body_inspect_bytes,
239 dry_run: waf.dry_run,
240 })
241 }
242
243 pub fn dry_run(&self) -> bool {
245 self.dry_run
246 }
247
248 pub fn max_body_inspect_bytes(&self) -> u64 {
250 self.max_body_inspect_bytes
251 }
252
253 fn inspect_value(&self, target: WafTarget, value: &str) -> Option<WafMatch> {
257 for entry in &self.filters {
258 for pat in &entry.patterns {
259 if pat.is_match(value) {
260 return Some(WafMatch {
261 filter: entry.filter,
262 pattern: pat.as_str().to_string(),
263 target,
264 value_preview: truncate_preview(value),
265 });
266 }
267 }
268 }
269 None
270 }
271
272 pub fn inspect_head(
276 &self,
277 path: &str,
278 query: Option<&str>,
279 headers: &hyper::HeaderMap,
280 ) -> Option<WafMatch> {
281 if let Some(m) = self.inspect_value(WafTarget::Path, path) {
283 return Some(m);
284 }
285 if let Some(q) = query {
287 if let Some(m) = self.inspect_value(WafTarget::Query, q) {
288 return Some(m);
289 }
290 }
291 for name in &["user-agent", "referer", "cookie", "x-forwarded-for"] {
293 if let Some(val) = headers.get(*name).and_then(|v| v.to_str().ok()) {
294 if let Some(m) = self.inspect_value(WafTarget::Header, val) {
295 return Some(m);
296 }
297 }
298 }
299 None
300 }
301
302 pub fn inspect_body_slice(&self, body: &[u8]) -> Option<WafMatch> {
304 let text = String::from_utf8_lossy(body);
308 self.inspect_value(WafTarget::Body, &text)
309 }
310}
311
312pub fn should_inspect_body(headers: &hyper::HeaderMap) -> bool {
315 if let Some(ct) = headers
316 .get(hyper::header::CONTENT_TYPE)
317 .and_then(|v| v.to_str().ok())
318 {
319 let ct = ct.to_ascii_lowercase();
320 ct.starts_with("application/json")
321 || ct.starts_with("application/x-www-form-urlencoded")
322 || ct.starts_with("text/plain")
323 } else {
324 false
325 }
326}
327
328pub struct BodyInspectionResult {
333 pub body: WafBody,
339 pub match_found: Option<WafMatch>,
341}
342
343pub enum WafBody {
347 Full(Full<Bytes>),
349 Partial {
352 prefix: Bytes,
353 rest: Pin<
354 Box<dyn Body<Data = Bytes, Error = Box<dyn std::error::Error + Send + Sync>> + Send>,
355 >,
356 },
357}
358
359impl WafBody {
360 pub fn full(bytes: Bytes) -> Self {
362 WafBody::Full(Full::new(bytes))
363 }
364}
365
366impl Body for WafBody {
367 type Data = Bytes;
368 type Error = Box<dyn std::error::Error + Send + Sync>;
369
370 fn poll_frame(
371 self: Pin<&mut Self>,
372 cx: &mut std::task::Context<'_>,
373 ) -> std::task::Poll<Option<Result<hyper::body::Frame<Bytes>, Self::Error>>> {
374 let this = self.get_mut();
375 match this {
376 WafBody::Full(b) => Pin::new(b).poll_frame(cx).map_err(|e| match e {}),
377 WafBody::Partial { prefix, rest } => {
378 if !prefix.is_empty() {
379 let chunk = prefix.clone();
380 *prefix = Bytes::new();
381 return std::task::Poll::Ready(Some(Ok(hyper::body::Frame::data(chunk))));
382 }
383 rest.as_mut().poll_frame(cx)
384 }
385 }
386 }
387
388 fn is_end_stream(&self) -> bool {
389 match self {
390 WafBody::Full(b) => b.is_end_stream(),
391 WafBody::Partial { prefix, rest } => prefix.is_empty() && rest.is_end_stream(),
392 }
393 }
394
395 fn size_hint(&self) -> hyper::body::SizeHint {
396 match self {
397 WafBody::Full(b) => b.size_hint(),
398 WafBody::Partial { prefix, rest } => {
399 let mut hint = rest.size_hint();
400 if let Some(exact) = hint.exact() {
401 hint.set_exact(exact + prefix.len() as u64);
402 }
403 hint
404 }
405 }
406 }
407}
408
409pub async fn inspect_body<B>(body: B, gen: &WafGeneration) -> BodyInspectionResult
416where
417 B: Body<Data = Bytes> + Send + 'static,
418 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
419{
420 let cap = gen.max_body_inspect_bytes();
421 let mut body = Box::pin(body);
422 let mut buf: Vec<u8> = Vec::new();
423 let mut exceeded_cap = false;
424
425 loop {
426 if cap > 0 && buf.len() as u64 >= cap {
427 exceeded_cap = true;
428 break;
429 }
430 match body.frame().await {
431 Some(Ok(frame)) => {
432 let Ok(data) = frame.into_data() else {
433 continue; };
435 if cap > 0 && buf.len() as u64 + data.len() as u64 > cap {
436 let remaining = cap as usize - buf.len();
440 buf.extend_from_slice(&data[..remaining]);
441 let unconsumed = data.slice(remaining..);
445 let rest_body = PrefixedBody {
446 prefix: Some(unconsumed),
447 rest: body,
448 };
449 let match_found = gen.inspect_body_slice(&buf);
450 let body_out = WafBody::Partial {
451 prefix: Bytes::from(buf),
452 rest: Box::pin(rest_body),
453 };
454 return BodyInspectionResult {
455 body: body_out,
456 match_found,
457 };
458 }
459 buf.extend_from_slice(&data);
460 }
461 Some(Err(_)) => {
462 break;
464 }
465 None => break,
466 }
467 }
468
469 let match_found = gen.inspect_body_slice(&buf);
470 let buffered = Bytes::from(buf);
471
472 let body_out = if exceeded_cap {
473 WafBody::Partial {
477 prefix: buffered,
478 rest: Box::pin(body.map_err(|e| e.into())),
479 }
480 } else {
481 WafBody::Full(Full::new(buffered))
483 };
484
485 BodyInspectionResult {
486 body: body_out,
487 match_found,
488 }
489}
490
491struct PrefixedBody<B: Body> {
496 prefix: Option<Bytes>,
497 rest: Pin<Box<B>>,
498}
499
500impl<B> Body for PrefixedBody<B>
501where
502 B: Body<Data = Bytes>,
503 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
504{
505 type Data = Bytes;
506 type Error = Box<dyn std::error::Error + Send + Sync>;
507
508 fn poll_frame(
509 self: Pin<&mut Self>,
510 cx: &mut std::task::Context<'_>,
511 ) -> std::task::Poll<Option<Result<hyper::body::Frame<Bytes>, Self::Error>>> {
512 let this = self.get_mut();
513 if let Some(prefix) = this.prefix.take() {
514 if !prefix.is_empty() {
515 return std::task::Poll::Ready(Some(Ok(hyper::body::Frame::data(prefix))));
516 }
517 }
518 this.rest.as_mut().poll_frame(cx).map_err(|e| e.into())
519 }
520
521 fn is_end_stream(&self) -> bool {
522 self.prefix.as_ref().is_none_or(|p| p.is_empty()) && self.rest.is_end_stream()
523 }
524
525 fn size_hint(&self) -> hyper::body::SizeHint {
526 let mut hint = self.rest.size_hint();
527 if let Some(prefix) = &self.prefix {
528 if let Some(exact) = hint.exact() {
529 hint.set_exact(exact + prefix.len() as u64);
530 }
531 }
532 hint
533 }
534}
535
536use crate::config::GlobalWaf;
539
540pub struct CompiledCrsRule {
543 pub id: u32,
544 pub severity: u8,
545 pub phase: u8,
546 pub tags: Vec<String>,
547 pattern: Regex,
548 targets: Vec<CrsTarget>,
549 transformations: Vec<Transformation>,
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554enum CrsTarget {
555 Path,
556 Query,
557 Headers,
558 Body,
559}
560
561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
563enum Transformation {
564 Lowercase,
565 UrlDecode,
566 HtmlEntityDecode,
567 CompressWhitespace,
568 RemoveWhitespace,
569 UrlDecodeUni,
570}
571
572impl Transformation {
573 fn from_name(name: &str) -> Option<Self> {
574 match name {
575 "lowercase" => Some(Transformation::Lowercase),
576 "url_decode" => Some(Transformation::UrlDecode),
577 "html_entity_decode" => Some(Transformation::HtmlEntityDecode),
578 "compress_whitespace" => Some(Transformation::CompressWhitespace),
579 "remove_whitespace" => Some(Transformation::RemoveWhitespace),
580 "url_decode_uni" => Some(Transformation::UrlDecodeUni),
581 _ => None,
582 }
583 }
584
585 fn apply(&self, input: &str) -> String {
586 match self {
587 Transformation::Lowercase => input.to_lowercase(),
588 Transformation::UrlDecode => percent_decode(input).unwrap_or_else(|| input.to_string()),
589 Transformation::HtmlEntityDecode => html_entity_decode(input),
590 Transformation::CompressWhitespace => compress_whitespace(input),
591 Transformation::RemoveWhitespace => {
592 input.chars().filter(|c| !c.is_whitespace()).collect()
593 }
594 Transformation::UrlDecodeUni => {
595 percent_decode(input).unwrap_or_else(|| input.to_string())
596 }
597 }
598 }
599}
600
601fn percent_decode(input: &str) -> Option<String> {
603 let bytes = input.as_bytes();
604 let mut out = Vec::with_capacity(bytes.len());
605 let mut i = 0;
606 while i < bytes.len() {
607 if bytes[i] == b'%' && i + 2 < bytes.len() {
608 if let Ok(b) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).ok()?, 16) {
609 out.push(b);
610 i += 3;
611 continue;
612 }
613 }
614 out.push(bytes[i]);
615 i += 1;
616 }
617 String::from_utf8(out).ok()
618}
619
620fn html_entity_decode(input: &str) -> String {
622 let mut result = String::with_capacity(input.len());
623 let mut chars = input.chars().peekable();
624 while let Some(c) = chars.next() {
625 if c == '&' {
626 let mut entity = String::new();
627 let mut found = false;
628 for ec in chars.by_ref() {
629 entity.push(ec);
630 if ec == ';' {
631 found = true;
632 break;
633 }
634 if entity.len() > 10 {
635 break;
636 }
637 }
638 if found {
639 match entity.as_str() {
640 "lt;" => result.push('<'),
641 "gt;" => result.push('>'),
642 "amp;" => result.push('&'),
643 "quot;" => result.push('"'),
644 "apos;" => result.push('\''),
645 _ => {
646 if let Some(num) =
648 entity.strip_prefix("#").and_then(|s| s.strip_suffix(";"))
649 {
650 if let Some(hex) = num.strip_prefix("x") {
651 if let Ok(code) = u32::from_str_radix(hex, 16) {
652 if let Some(ch) = char::from_u32(code) {
653 result.push(ch);
654 continue;
655 }
656 }
657 } else if let Ok(code) = num.parse::<u32>() {
658 if let Some(ch) = char::from_u32(code) {
659 result.push(ch);
660 continue;
661 }
662 }
663 }
664 result.push('&');
666 result.push_str(&entity);
667 }
668 }
669 } else {
670 result.push('&');
671 result.push_str(&entity);
672 }
673 } else {
674 result.push(c);
675 }
676 }
677 result
678}
679
680fn compress_whitespace(input: &str) -> String {
682 let mut result = String::with_capacity(input.len());
683 let mut prev_ws = false;
684 for c in input.chars() {
685 if c.is_whitespace() {
686 if !prev_ws {
687 result.push(' ');
688 prev_ws = true;
689 }
690 } else {
691 result.push(c);
692 prev_ws = false;
693 }
694 }
695 result
696}
697
698pub struct CrsGeneration {
701 rules: Vec<CompiledCrsRule>,
702 anomaly_threshold: u32,
703 max_body_inspect_bytes: u64,
704 dry_run: bool,
705}
706
707#[derive(Debug, Clone)]
710pub struct CrsResult {
711 pub score: u32,
712 pub matched_rules: Vec<u32>,
713 pub blocked: bool,
714}
715
716impl CrsGeneration {
717 pub fn from_config(waf: &GlobalWaf) -> Option<Self> {
720 if !waf.enabled || waf.rules.is_empty() {
721 return None;
722 }
723 let exclude_ids: std::collections::HashSet<u32> =
724 waf.exclude_rule_ids.iter().copied().collect();
725 let exclude_tags: std::collections::HashSet<&str> =
726 waf.exclude_tags.iter().map(|s| s.as_str()).collect();
727 let mut compiled = Vec::with_capacity(waf.rules.len());
728 for rule in &waf.rules {
729 if exclude_ids.contains(&rule.id) {
731 continue;
732 }
733 if rule.tags.iter().any(|t| exclude_tags.contains(t.as_str())) {
735 continue;
736 }
737 let pattern = match Regex::new(&rule.pattern) {
739 Ok(re) => re,
740 Err(_) => continue,
741 };
742 let targets = rule
744 .targets
745 .iter()
746 .filter_map(|t| match t.as_str() {
747 "path" => Some(CrsTarget::Path),
748 "query" => Some(CrsTarget::Query),
749 "header" | "headers" => Some(CrsTarget::Headers),
750 "body" => Some(CrsTarget::Body),
751 _ => None,
752 })
753 .collect();
754 let transformations = rule
756 .transformations
757 .iter()
758 .filter_map(|t| Transformation::from_name(t.as_str()))
759 .collect();
760 compiled.push(CompiledCrsRule {
761 id: rule.id,
762 severity: rule.severity,
763 phase: rule.phase,
764 tags: rule.tags.clone(),
765 pattern,
766 targets,
767 transformations,
768 });
769 }
770 if compiled.is_empty() {
771 return None;
772 }
773 Some(CrsGeneration {
774 rules: compiled,
775 anomaly_threshold: waf.anomaly_threshold,
776 max_body_inspect_bytes: waf.max_body_inspect_bytes,
777 dry_run: waf.dry_run,
778 })
779 }
780
781 pub fn dry_run(&self) -> bool {
783 self.dry_run
784 }
785
786 pub fn max_body_inspect_bytes(&self) -> u64 {
788 self.max_body_inspect_bytes
789 }
790
791 fn transform(&self, rule: &CompiledCrsRule, value: &str) -> String {
793 let mut result = value.to_string();
794 for t in &rule.transformations {
795 result = t.apply(&result);
796 }
797 result
798 }
799
800 fn rule_matches(&self, rule: &CompiledCrsRule, value: &str) -> bool {
803 let transformed = self.transform(rule, value);
804 rule.pattern.is_match(&transformed)
805 }
806
807 pub fn evaluate_phase1(
810 &self,
811 path: &str,
812 query: Option<&str>,
813 headers: &hyper::HeaderMap,
814 ) -> CrsResult {
815 let mut score: u32 = 0;
816 let mut matched = Vec::new();
817 for rule in &self.rules {
818 if rule.phase != 1 {
819 continue;
820 }
821 let mut hit = false;
822 for target in &rule.targets {
823 match target {
824 CrsTarget::Path => {
825 if self.rule_matches(rule, path) {
826 hit = true;
827 break;
828 }
829 }
830 CrsTarget::Query => {
831 if let Some(q) = query {
832 if self.rule_matches(rule, q) {
833 hit = true;
834 break;
835 }
836 }
837 }
838 CrsTarget::Headers => {
839 for name in &["user-agent", "referer", "cookie", "x-forwarded-for"] {
840 if let Some(val) = headers.get(*name).and_then(|v| v.to_str().ok()) {
841 if self.rule_matches(rule, val) {
842 hit = true;
843 break;
844 }
845 }
846 }
847 if hit {
848 break;
849 }
850 }
851 CrsTarget::Body => {}
852 }
853 }
854 if hit {
855 score += rule.severity as u32;
856 matched.push(rule.id);
857 }
858 }
859 let blocked = score >= self.anomaly_threshold;
860 CrsResult {
861 score,
862 matched_rules: matched,
863 blocked,
864 }
865 }
866
867 pub fn evaluate_phase2(&self, body: &str) -> CrsResult {
870 let mut score: u32 = 0;
871 let mut matched = Vec::new();
872 for rule in &self.rules {
873 if rule.phase != 2 {
874 continue;
875 }
876 if !rule.targets.contains(&CrsTarget::Body) {
877 continue;
878 }
879 if self.rule_matches(rule, body) {
880 score += rule.severity as u32;
881 matched.push(rule.id);
882 }
883 }
884 let blocked = score >= self.anomaly_threshold;
885 CrsResult {
886 score,
887 matched_rules: matched,
888 blocked,
889 }
890 }
891}
892
893pub fn crs_from_gateway(gateway: &crate::config::Gateway) -> Option<CrsGeneration> {
896 gateway.waf.as_ref().and_then(CrsGeneration::from_config)
897}