1use std::collections::HashSet;
58
59use crate::body_scan::BodyScanValue;
60use crate::detect::{self, DetectConfig, Threat};
61
62const SSRF_SKIP: &[&str] = &["ssrf"];
65
66pub const DEFAULT_EXCLUDED_HEADERS: &[&str] = &[
73 "host",
74 "user-agent",
75 "accept",
76 "accept-encoding",
77 "connection",
78 "origin",
79 "referer",
80 "sec-fetch-site",
81 "sec-fetch-mode",
82 "sec-fetch-dest",
83 "sec-ch-ua",
84 "sec-ch-ua-mobile",
85 "sec-ch-ua-platform",
86 "forwarded",
87 "x-forwarded-for",
88 "x-forwarded-host",
89 "x-forwarded-proto",
90 "x-real-ip",
91 "x-client-ip",
92 "x-cluster-client-ip",
93 "cf-connecting-ip",
94 "true-client-ip",
95 "fly-client-ip",
96 "x-envoy-external-address",
97];
98
99const ADDRESS_HEADER_NAMES: &[&str] = &[
102 "host",
103 "origin",
104 "x-forwarded-for",
105 "x-forwarded-host",
106 "x-real-ip",
107 "x-client-ip",
108 "x-cluster-client-ip",
109 "cf-connecting-ip",
110 "true-client-ip",
111 "fly-client-ip",
112 "x-envoy-external-address",
113 "via",
114];
115
116#[derive(Debug, Clone, Default, PartialEq, Eq)]
122pub struct DetectionExclusionConfig {
123 pub excluded_detection_headers: Vec<String>,
126 pub excluded_detection_params: Vec<String>,
128 pub excluded_detection_body_fields: Vec<String>,
130 pub enabled_detection_categories: Option<Vec<String>>,
132 pub detection_scan_body: Option<bool>,
134}
135
136#[derive(Debug, Clone, Default, PartialEq, Eq)]
142pub struct RouteDetectionExclusions {
143 pub excluded_detection_headers: Option<Vec<String>>,
146 pub excluded_detection_params: Option<Vec<String>>,
148 pub excluded_detection_body_fields: Option<Vec<String>>,
150 pub enabled_detection_categories: Option<Vec<String>>,
154 pub detection_scan_body: Option<bool>,
157}
158
159#[derive(Debug, Clone, Default, PartialEq, Eq)]
162pub struct ResolvedExclusions {
163 pub excluded_params: HashSet<String>,
165 pub excluded_body_fields: HashSet<String>,
167 pub excluded_headers: HashSet<String>,
170 pub enabled_categories: Option<HashSet<String>>,
173 pub scan_body: bool,
175}
176
177fn extend_lowered(set: &mut HashSet<String>, values: impl IntoIterator<Item = String>) {
179 set.extend(values.into_iter().map(|value| value.to_ascii_lowercase()));
180}
181
182#[must_use]
186pub fn resolve(
187 config: Option<&DetectionExclusionConfig>,
188 route: Option<&RouteDetectionExclusions>,
189) -> ResolvedExclusions {
190 let mut resolved = ResolvedExclusions::default();
191
192 resolved
194 .excluded_headers
195 .extend(DEFAULT_EXCLUDED_HEADERS.iter().map(|h| (*h).to_owned()));
196 if let Some(config) = config {
197 extend_lowered(
198 &mut resolved.excluded_headers,
199 config.excluded_detection_headers.clone(),
200 );
201 }
202 if let Some(headers) = route.and_then(|route| route.excluded_detection_headers.as_ref()) {
203 extend_lowered(&mut resolved.excluded_headers, headers.clone());
204 }
205
206 let params: Option<Vec<String>> = route
209 .and_then(|route| route.excluded_detection_params.clone())
210 .or_else(|| config.map(|config| config.excluded_detection_params.clone()));
211 if let Some(params) = params {
212 extend_lowered(&mut resolved.excluded_params, params);
213 }
214 let body_fields: Option<Vec<String>> = route
215 .and_then(|route| route.excluded_detection_body_fields.clone())
216 .or_else(|| config.map(|config| config.excluded_detection_body_fields.clone()));
217 if let Some(body_fields) = body_fields {
218 extend_lowered(&mut resolved.excluded_body_fields, body_fields);
219 }
220 let categories: Option<Vec<String>> = route
221 .and_then(|route| route.enabled_detection_categories.clone())
222 .or_else(|| config.and_then(|config| config.enabled_detection_categories.clone()));
223 if let Some(categories) = categories {
224 let mut set = HashSet::new();
225 extend_lowered(&mut set, categories);
226 resolved.enabled_categories = Some(set);
227 }
228
229 resolved.scan_body = route
231 .and_then(|route| route.detection_scan_body)
232 .or_else(|| config.and_then(|config| config.detection_scan_body))
233 .unwrap_or(true);
234
235 resolved
236}
237
238#[must_use]
246pub fn excluded_header_skip_categories(name: &str, value: &str) -> &'static [&'static str] {
247 let normalized = name.trim().to_ascii_lowercase();
248 if ADDRESS_HEADER_NAMES.contains(&normalized.as_str()) {
249 return SSRF_SKIP;
250 }
251 if value_looks_like_address_chain(value) {
252 return SSRF_SKIP;
253 }
254 &[]
255}
256
257#[must_use]
263pub fn strip_forwarded_entry_port(value: &str) -> &str {
264 if let Some(rest) = value.strip_prefix('[') {
265 let Some(closing) = rest.find(']') else {
266 return value;
267 };
268 let remainder = &rest[closing + 1..];
269 let port_valid =
270 remainder.starts_with(':') && remainder[1..].bytes().all(|b| b.is_ascii_digit());
271 if !(remainder.is_empty() || port_valid) {
272 return value;
273 }
274 return &rest[..closing];
275 }
276 if value.matches(':').count() == 1
277 && let Some((host, port)) = value.split_once(':')
278 && !port.is_empty()
279 && port.bytes().all(|b| b.is_ascii_digit())
280 {
281 return host;
282 }
283 value
284}
285
286#[must_use]
292pub fn value_looks_like_address_chain(value: &str) -> bool {
293 let tokens: Vec<&str> = value
294 .split(',')
295 .map(str::trim)
296 .filter(|token| !token.is_empty())
297 .collect();
298 if tokens.is_empty() {
299 return false;
300 }
301 tokens.iter().all(|token| {
302 strip_forwarded_entry_port(token)
303 .parse::<std::net::IpAddr>()
304 .is_ok()
305 })
306}
307
308#[derive(Debug, Clone, Default)]
312pub struct RequestSurfaces<'a> {
313 pub url_path: Option<&'a str>,
315 pub query_params: &'a [(String, String)],
317 pub headers: &'a [(String, String)],
319 pub content_type: &'a str,
321 pub raw_body: &'a str,
324}
325
326#[derive(Debug, Clone, Default, PartialEq, Eq)]
328pub struct RequestScanVerdict {
329 pub is_threat: bool,
332 pub categories: Vec<String>,
335 pub reason: String,
337}
338
339#[must_use]
354pub fn scan_request(
355 surfaces: &RequestSurfaces<'_>,
356 exclusions: &ResolvedExclusions,
357 config: &DetectConfig,
358) -> RequestScanVerdict {
359 struct Value<'a> {
360 content: &'a str,
361 context: &'a str,
362 skip_categories: &'static [&'static str],
363 }
364
365 let mut values: Vec<Value<'_>> = Vec::new();
366 if let Some(path) = surfaces.url_path.filter(|path| !path.is_empty()) {
367 values.push(Value {
368 content: path,
369 context: "url_path",
370 skip_categories: &[],
371 });
372 }
373 for (name, value) in surfaces.query_params {
374 if exclusions
375 .excluded_params
376 .contains(&name.to_ascii_lowercase())
377 {
378 continue;
379 }
380 values.push(Value {
381 content: value,
382 context: "query_param",
383 skip_categories: &[],
384 });
385 }
386 for (name, value) in surfaces.headers {
387 if value.is_empty() {
388 continue;
389 }
390 let skip_categories = if exclusions
391 .excluded_headers
392 .contains(&name.to_ascii_lowercase())
393 {
394 excluded_header_skip_categories(name, value)
395 } else {
396 &[]
397 };
398 values.push(Value {
399 content: value,
400 context: "header",
401 skip_categories,
402 });
403 }
404 let body_values: Vec<BodyScanValue> = if exclusions.scan_body {
407 crate::body_scan::extract_body_scan_values_with_exclusions(
408 surfaces.raw_body,
409 surfaces.content_type,
410 config,
411 crate::body_scan::ExcludedBodyFields::new(&exclusions.excluded_body_fields),
412 )
413 } else {
414 Vec::new()
415 };
416 for value in &body_values {
417 if let Some(forced) = value.forced_category {
418 return forced_verdict(core::slice::from_ref(&forced));
422 }
423 values.push(Value {
424 content: &value.content,
425 context: &value.context,
426 skip_categories: &[],
427 });
428 }
429
430 for value in &values {
431 let verdict = detect::detect(value.content, value.context, config);
432 if !verdict.is_threat {
433 continue;
434 }
435 let mut categories: Vec<String> = Vec::new();
436 for threat in &verdict.threats {
437 let category = match threat {
438 Threat::Regex(regex) => Some(regex.category.clone()),
439 Threat::Semantic(_) => None,
443 };
444 let Some(category) = category else {
445 continue;
446 };
447 if !category_enabled(&category, exclusions.enabled_categories.as_ref())
448 || value.skip_categories.contains(&category.as_str())
449 || categories.contains(&category)
450 {
451 continue;
452 }
453 categories.push(category);
454 }
455 if categories.is_empty() {
456 return RequestScanVerdict::default();
459 }
460 categories.sort();
461 return verdict_from(categories);
462 }
463
464 RequestScanVerdict::default()
465}
466
467fn forced_verdict(categories: &[&str]) -> RequestScanVerdict {
470 let categories: Vec<String> = categories.iter().map(|c| (*c).to_owned()).collect();
471 verdict_from(categories)
472}
473
474fn verdict_from(mut categories: Vec<String>) -> RequestScanVerdict {
475 categories.sort();
476 let reason = format!("Penetration patterns detected: {}", categories.join(", "));
477 RequestScanVerdict {
478 is_threat: true,
479 categories,
480 reason,
481 }
482}
483
484fn category_enabled(category: &str, enabled: Option<&HashSet<String>>) -> bool {
485 enabled.is_none_or(|set| set.contains(category))
486}
487
488#[must_use]
494pub fn detection_enabled(global_enabled: bool, route_enabled: Option<bool>) -> bool {
495 route_enabled.unwrap_or(global_enabled)
496}
497
498#[must_use]
504pub fn check_applies(
505 global_enabled: bool,
506 route_toggles: impl IntoIterator<Item = bool>,
507 dynamic_rules: bool,
508) -> bool {
509 global_enabled || route_toggles.into_iter().any(|flag| flag) || dynamic_rules
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use crate::detect::DetectConfig;
516
517 fn corpus_config() -> DetectConfig {
518 DetectConfig {
519 max_content_length: 10_000,
520 max_full_scan_bytes: 262_144,
521 preserve_attack_patterns: true,
522 semantic_threshold: 0.7,
523 threat_score_threshold: 1.0,
524 binary_min_run_length: 16,
525 }
526 }
527
528 fn config_of(params: &[&str]) -> DetectionExclusionConfig {
529 DetectionExclusionConfig {
530 excluded_detection_params: params.iter().map(|s| (*s).to_owned()).collect(),
531 ..DetectionExclusionConfig::default()
532 }
533 }
534
535 #[test]
536 fn resolve_defaults_scan_everything() {
537 let resolved = resolve(None, None);
538 assert!(resolved.excluded_params.is_empty());
539 assert!(resolved.excluded_body_fields.is_empty());
540 assert!(resolved.enabled_categories.is_none());
541 assert!(resolved.scan_body);
542 assert!(resolved.excluded_headers.contains("host"));
544 assert!(resolved.excluded_headers.contains("x-forwarded-for"));
545 assert!(!resolved.excluded_headers.contains("x-custom"));
546 }
547
548 #[test]
549 fn non_none_route_sets_replace_and_unset_inherit() {
550 let global = config_of(&["debug", "trace"]);
551 let route = RouteDetectionExclusions {
552 excluded_detection_params: Some(vec!["session".to_owned()]),
553 ..RouteDetectionExclusions::default()
554 };
555 let resolved = resolve(Some(&global), Some(&route));
556 assert!(resolved.excluded_params.contains("session"));
557 assert!(!resolved.excluded_params.contains("debug"));
558
559 let inheriting = resolve(Some(&global), Some(&RouteDetectionExclusions::default()));
561 assert!(inheriting.excluded_params.contains("debug"));
562 assert!(inheriting.excluded_params.contains("trace"));
563 }
564
565 #[test]
566 fn header_set_merges_defaults_config_and_route() {
567 let global = DetectionExclusionConfig {
568 excluded_detection_headers: vec!["X-Custom-Global".to_owned()],
569 ..DetectionExclusionConfig::default()
570 };
571 let route = RouteDetectionExclusions {
572 excluded_detection_headers: Some(vec!["x-custom-route".to_owned()]),
573 ..RouteDetectionExclusions::default()
574 };
575 let resolved = resolve(Some(&global), Some(&route));
576 assert!(resolved.excluded_headers.contains("x-custom-global"));
577 assert!(resolved.excluded_headers.contains("x-custom-route"));
578 assert!(
579 resolved.excluded_headers.contains("referer"),
580 "defaults survive"
581 );
582 }
583
584 #[test]
585 fn empty_route_category_set_disables_every_category() {
586 let route = RouteDetectionExclusions {
587 enabled_detection_categories: Some(Vec::new()),
588 ..RouteDetectionExclusions::default()
589 };
590 let resolved = resolve(None, Some(&route));
591 let enabled = resolved.enabled_categories.clone().expect("some");
592 assert!(enabled.is_empty(), "an empty Some replaces, not inherits");
593 assert!(!category_enabled(
594 "sqli",
595 resolved.enabled_categories.as_ref()
596 ));
597
598 let global = DetectionExclusionConfig {
600 enabled_detection_categories: Some(vec!["sqli".to_owned()]),
601 ..DetectionExclusionConfig::default()
602 };
603 let inheriting = resolve(Some(&global), None);
604 assert!(category_enabled(
605 "sqli",
606 inheriting.enabled_categories.as_ref()
607 ));
608 assert!(!category_enabled(
609 "xss",
610 inheriting.enabled_categories.as_ref()
611 ));
612 }
613
614 #[test]
615 fn scan_body_toggle_overrides_both_directions() {
616 let global = DetectionExclusionConfig {
618 detection_scan_body: Some(true),
619 ..DetectionExclusionConfig::default()
620 };
621 let route = RouteDetectionExclusions {
622 detection_scan_body: Some(false),
623 ..RouteDetectionExclusions::default()
624 };
625 assert!(!resolve(Some(&global), Some(&route)).scan_body);
626
627 let global = DetectionExclusionConfig {
629 detection_scan_body: Some(false),
630 ..DetectionExclusionConfig::default()
631 };
632 let route = RouteDetectionExclusions {
633 detection_scan_body: Some(true),
634 ..RouteDetectionExclusions::default()
635 };
636 assert!(resolve(Some(&global), Some(&route)).scan_body);
637
638 let global = DetectionExclusionConfig {
640 detection_scan_body: Some(false),
641 ..DetectionExclusionConfig::default()
642 };
643 assert!(!resolve(Some(&global), None).scan_body);
644 }
645
646 #[test]
647 fn scan_body_false_still_scans_headers_params_and_path() {
648 let route = RouteDetectionExclusions {
649 detection_scan_body: Some(false),
650 ..RouteDetectionExclusions::default()
651 };
652 let exclusions = resolve(None, Some(&route));
653 let surfaces = RequestSurfaces {
654 url_path: Some("/files?x=../../etc/passwd"),
655 query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
656 headers: &[(
657 "x-payload".to_owned(),
658 "<script>alert(1)</script>".to_owned(),
659 )],
660 content_type: "application/x-www-form-urlencoded",
661 raw_body: "payload=<img src=x onerror=alert(1)>",
662 };
663 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
664 assert!(
665 verdict.is_threat,
666 "the body surface is skipped, the rest still detect"
667 );
668 }
669
670 #[test]
671 fn excluded_param_skips_only_itself() {
672 let global = config_of(&["debug"]);
673 let exclusions = resolve(Some(&global), None);
674 let surfaces = RequestSurfaces {
675 url_path: Some("/search"),
676 query_params: &[
677 ("debug".to_owned(), "1 UNION SELECT password".to_owned()),
678 ("q".to_owned(), "<script>alert(1)</script>".to_owned()),
679 ],
680 headers: &[],
681 content_type: "",
682 raw_body: "",
683 };
684 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
685 assert!(verdict.is_threat);
686 assert!(
687 !verdict.categories.contains(&"sqli".to_owned()),
688 "the excluded param did not contribute"
689 );
690 let surfaces = RequestSurfaces {
692 url_path: Some("/search"),
693 query_params: &[("DEBUG".to_owned(), "1 UNION SELECT password".to_owned())],
694 headers: &[],
695 content_type: "",
696 raw_body: "",
697 };
698 assert!(!scan_request(&surfaces, &exclusions, &corpus_config()).is_threat);
699 }
700
701 #[test]
702 fn excluded_body_field_skips_only_that_field() {
703 let global = DetectionExclusionConfig {
704 excluded_detection_body_fields: vec!["secret".to_owned()],
705 ..DetectionExclusionConfig::default()
706 };
707 let exclusions = resolve(Some(&global), None);
708 let surfaces = RequestSurfaces {
709 url_path: Some("/api"),
710 query_params: &[],
711 headers: &[],
712 content_type: "application/x-www-form-urlencoded",
713 raw_body: "secret=1 UNION SELECT password&safe=<script>alert(1)</script>",
714 };
715 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
716 assert!(verdict.is_threat, "the untouched field still scans");
717 assert_eq!(verdict.categories, vec!["xss"]);
718
719 let surfaces = RequestSurfaces {
721 url_path: Some("/api"),
722 query_params: &[],
723 headers: &[],
724 content_type: "application/x-www-form-urlencoded",
725 raw_body: "secret=1 UNION SELECT password",
726 };
727 assert!(!scan_request(&surfaces, &exclusions, &corpus_config()).is_threat);
728 }
729
730 #[test]
731 fn excluded_header_with_attack_payload_still_detects() {
732 let exclusions = resolve(None, None);
733 let surfaces = RequestSurfaces {
734 url_path: Some("/x"),
735 query_params: &[],
736 headers: &[(
737 "x-custom".to_owned(),
738 "<script>alert(1)</script>".to_owned(),
739 )],
740 content_type: "",
741 raw_body: "",
742 };
743 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
744 assert!(verdict.is_threat, "an excluded header is not a skip hole");
745 assert_eq!(verdict.categories, vec!["xss"]);
746 }
747
748 #[test]
749 fn address_chain_headers_skip_ssrf_only() {
750 let chain = "10.0.0.5, 172.16.0.1";
753 assert_eq!(
754 excluded_header_skip_categories("x-custom", chain),
755 &["ssrf"][..]
756 );
757 assert_eq!(
758 excluded_header_skip_categories("via", chain),
759 &["ssrf"][..],
760 "the address-carrying name skips for any value"
761 );
762 assert_eq!(
763 excluded_header_skip_categories("x-custom", "<script>alert(1)</script>"),
764 &[] as &[&str],
765 "a non-address value skips nothing"
766 );
767 assert_eq!(
768 excluded_header_skip_categories("x-custom", "10.0.0.5, not-an-ip"),
769 &[] as &[&str],
770 "one non-address token breaks the chain"
771 );
772 }
773
774 #[test]
775 fn port_stripping_feeds_the_address_parser() {
776 assert_eq!(strip_forwarded_entry_port("1.2.3.4:8080"), "1.2.3.4");
777 assert_eq!(strip_forwarded_entry_port("[::1]:8080"), "::1");
778 assert_eq!(strip_forwarded_entry_port("::1"), "::1");
779 assert_eq!(strip_forwarded_entry_port("not-an-ip"), "not-an-ip");
780 assert_eq!(
781 strip_forwarded_entry_port("[::1]:nope"),
782 "[::1]:nope",
783 "a non-numeric port keeps the value unparsed"
784 );
785 assert!(
786 value_looks_like_address_chain("1.2.3.4:8080, [::1]:8080"),
787 "socket-literal entries parse once the port is stripped"
788 );
789 }
790
791 #[test]
792 fn filtered_out_threat_ends_the_scan_clean() {
793 let route = RouteDetectionExclusions {
797 enabled_detection_categories: Some(vec!["xss".to_owned()]),
798 ..RouteDetectionExclusions::default()
799 };
800 let exclusions = resolve(None, Some(&route));
801 let surfaces = RequestSurfaces {
802 url_path: Some("/x"),
803 query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
804 headers: &[],
805 content_type: "",
806 raw_body: "",
807 };
808 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
809 assert!(!verdict.is_threat);
810 assert!(verdict.categories.is_empty());
811 }
812
813 #[test]
814 fn enabled_categories_route_the_verdict() {
815 let route = RouteDetectionExclusions {
816 enabled_detection_categories: Some(vec!["sqli".to_owned()]),
817 ..RouteDetectionExclusions::default()
818 };
819 let exclusions = resolve(None, Some(&route));
820 let surfaces = RequestSurfaces {
821 url_path: Some("/x"),
822 query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
823 headers: &[(
824 "x-payload".to_owned(),
825 "<script>alert(1)</script>".to_owned(),
826 )],
827 content_type: "",
828 raw_body: "",
829 };
830 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
831 assert!(verdict.is_threat);
832 assert_eq!(verdict.categories, vec!["sqli"]);
833 assert!(verdict.reason.contains("sqli"));
834 assert!(!verdict.reason.contains("xss"), "xss is not enabled here");
835 }
836
837 #[test]
838 fn mongo_operator_keys_hit_through_the_body_walk() {
839 let exclusions = resolve(None, None);
840 let surfaces = RequestSurfaces {
841 url_path: Some("/x"),
842 query_params: &[],
843 headers: &[],
844 content_type: "application/json",
845 raw_body: "{\"$where\": \"1 OR 1=1\"}",
846 };
847 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
848 assert!(verdict.is_threat);
849 assert_eq!(verdict.categories, vec!["nosql"]);
850 }
851
852 #[test]
853 fn route_toggles_and_liveness_mirror_the_reference() {
854 assert!(detection_enabled(false, Some(true)));
856 assert!(!detection_enabled(true, Some(false)));
857 assert!(detection_enabled(true, None));
858 assert!(!detection_enabled(false, None));
859
860 assert!(check_applies(false, [false, true], false));
862 assert!(!check_applies(false, [false, false], false));
863 assert!(check_applies(true, [], false));
864 assert!(
865 check_applies(false, [false], true),
866 "dynamic rules keep it alive"
867 );
868 }
869}