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
339struct ScanBudget {
346 max_values: usize,
347 scanned_values: usize,
348 max_chars: usize,
349 scanned_chars: usize,
350}
351
352impl ScanBudget {
353 const fn new(config: &DetectConfig) -> Self {
354 Self {
355 max_values: config.max_scan_values,
356 scanned_values: 0,
357 max_chars: config.max_scan_chars,
358 scanned_chars: 0,
359 }
360 }
361
362 fn admit(&mut self, content: &str) -> bool {
366 self.scanned_values += 1;
367 if self.scanned_values > self.max_values {
368 return false;
369 }
370 let value_chars = content.chars().count();
371 if self.scanned_chars >= self.max_chars {
372 return false;
373 }
374 self.scanned_chars += value_chars;
375 true
376 }
377}
378#[must_use]
393pub fn scan_request(
394 surfaces: &RequestSurfaces<'_>,
395 exclusions: &ResolvedExclusions,
396 config: &DetectConfig,
397) -> RequestScanVerdict {
398 struct Value<'a> {
399 content: &'a str,
400 context: &'a str,
401 skip_categories: &'static [&'static str],
402 }
403
404 let mut values: Vec<Value<'_>> = Vec::new();
405 if let Some(path) = surfaces.url_path.filter(|path| !path.is_empty()) {
406 values.push(Value {
407 content: path,
408 context: "url_path",
409 skip_categories: &[],
410 });
411 }
412 for (name, value) in surfaces.query_params {
413 if exclusions
414 .excluded_params
415 .contains(&name.to_ascii_lowercase())
416 {
417 continue;
418 }
419 values.push(Value {
420 content: value,
421 context: "query_param",
422 skip_categories: &[],
423 });
424 }
425 for (name, value) in surfaces.headers {
426 if value.is_empty() {
427 continue;
428 }
429 let skip_categories = if exclusions
430 .excluded_headers
431 .contains(&name.to_ascii_lowercase())
432 {
433 excluded_header_skip_categories(name, value)
434 } else {
435 &[]
436 };
437 values.push(Value {
438 content: value,
439 context: "header",
440 skip_categories,
441 });
442 }
443 let body_values: Vec<BodyScanValue> = if exclusions.scan_body {
446 crate::body_scan::extract_body_scan_values_with_exclusions(
447 surfaces.raw_body,
448 surfaces.content_type,
449 config,
450 crate::body_scan::ExcludedBodyFields::new(&exclusions.excluded_body_fields),
451 )
452 } else {
453 Vec::new()
454 };
455 for value in &body_values {
456 if let Some(forced) = value.forced_category {
457 return forced_verdict(core::slice::from_ref(&forced));
461 }
462 values.push(Value {
463 content: &value.content,
464 context: &value.context,
465 skip_categories: &[],
466 });
467 }
468
469 let mut budget = ScanBudget::new(config);
475 for value in &values {
476 if !budget.admit(value.content) {
477 continue;
478 }
479 let verdict = detect::detect(value.content, value.context, config);
480 if !verdict.is_threat {
481 continue;
482 }
483 let mut categories: Vec<String> = Vec::new();
484 for threat in &verdict.threats {
485 let category = match threat {
486 Threat::Regex(regex) => Some(regex.category.clone()),
487 Threat::Semantic(_) => None,
491 };
492 let Some(category) = category else {
493 continue;
494 };
495 if !category_enabled(&category, exclusions.enabled_categories.as_ref())
496 || value.skip_categories.contains(&category.as_str())
497 || categories.contains(&category)
498 {
499 continue;
500 }
501 categories.push(category);
502 }
503 if categories.is_empty() {
504 return RequestScanVerdict::default();
507 }
508 categories.sort();
509 return verdict_from(categories);
510 }
511
512 RequestScanVerdict::default()
513}
514
515fn forced_verdict(categories: &[&str]) -> RequestScanVerdict {
518 let categories: Vec<String> = categories.iter().map(|c| (*c).to_owned()).collect();
519 verdict_from(categories)
520}
521
522fn verdict_from(mut categories: Vec<String>) -> RequestScanVerdict {
523 categories.sort();
524 let reason = format!("Penetration patterns detected: {}", categories.join(", "));
525 RequestScanVerdict {
526 is_threat: true,
527 categories,
528 reason,
529 }
530}
531
532fn category_enabled(category: &str, enabled: Option<&HashSet<String>>) -> bool {
533 enabled.is_none_or(|set| set.contains(category))
534}
535
536#[must_use]
542pub fn detection_enabled(global_enabled: bool, route_enabled: Option<bool>) -> bool {
543 route_enabled.unwrap_or(global_enabled)
544}
545
546#[must_use]
552pub fn check_applies(
553 global_enabled: bool,
554 route_toggles: impl IntoIterator<Item = bool>,
555 dynamic_rules: bool,
556) -> bool {
557 global_enabled || route_toggles.into_iter().any(|flag| flag) || dynamic_rules
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563 use crate::detect::DetectConfig;
564
565 fn corpus_config() -> DetectConfig {
566 DetectConfig {
567 max_content_length: 10_000,
568 max_full_scan_bytes: 262_144,
569 preserve_attack_patterns: true,
570 semantic_threshold: 0.7,
571 threat_score_threshold: 1.0,
572 binary_min_run_length: 16,
573 max_scan_values: 512,
574 max_scan_chars: 65_536,
575 max_json_depth: 32,
576 }
577 }
578
579 fn config_of(params: &[&str]) -> DetectionExclusionConfig {
580 DetectionExclusionConfig {
581 excluded_detection_params: params.iter().map(|s| (*s).to_owned()).collect(),
582 ..DetectionExclusionConfig::default()
583 }
584 }
585
586 #[test]
587 fn resolve_defaults_scan_everything() {
588 let resolved = resolve(None, None);
589 assert!(resolved.excluded_params.is_empty());
590 assert!(resolved.excluded_body_fields.is_empty());
591 assert!(resolved.enabled_categories.is_none());
592 assert!(resolved.scan_body);
593 assert!(resolved.excluded_headers.contains("host"));
595 assert!(resolved.excluded_headers.contains("x-forwarded-for"));
596 assert!(!resolved.excluded_headers.contains("x-custom"));
597 }
598
599 #[test]
600 fn non_none_route_sets_replace_and_unset_inherit() {
601 let global = config_of(&["debug", "trace"]);
602 let route = RouteDetectionExclusions {
603 excluded_detection_params: Some(vec!["session".to_owned()]),
604 ..RouteDetectionExclusions::default()
605 };
606 let resolved = resolve(Some(&global), Some(&route));
607 assert!(resolved.excluded_params.contains("session"));
608 assert!(!resolved.excluded_params.contains("debug"));
609
610 let inheriting = resolve(Some(&global), Some(&RouteDetectionExclusions::default()));
612 assert!(inheriting.excluded_params.contains("debug"));
613 assert!(inheriting.excluded_params.contains("trace"));
614 }
615
616 #[test]
617 fn header_set_merges_defaults_config_and_route() {
618 let global = DetectionExclusionConfig {
619 excluded_detection_headers: vec!["X-Custom-Global".to_owned()],
620 ..DetectionExclusionConfig::default()
621 };
622 let route = RouteDetectionExclusions {
623 excluded_detection_headers: Some(vec!["x-custom-route".to_owned()]),
624 ..RouteDetectionExclusions::default()
625 };
626 let resolved = resolve(Some(&global), Some(&route));
627 assert!(resolved.excluded_headers.contains("x-custom-global"));
628 assert!(resolved.excluded_headers.contains("x-custom-route"));
629 assert!(
630 resolved.excluded_headers.contains("referer"),
631 "defaults survive"
632 );
633 }
634
635 #[test]
636 fn empty_route_category_set_disables_every_category() {
637 let route = RouteDetectionExclusions {
638 enabled_detection_categories: Some(Vec::new()),
639 ..RouteDetectionExclusions::default()
640 };
641 let resolved = resolve(None, Some(&route));
642 let enabled = resolved.enabled_categories.clone().expect("some");
643 assert!(enabled.is_empty(), "an empty Some replaces, not inherits");
644 assert!(!category_enabled(
645 "sqli",
646 resolved.enabled_categories.as_ref()
647 ));
648
649 let global = DetectionExclusionConfig {
651 enabled_detection_categories: Some(vec!["sqli".to_owned()]),
652 ..DetectionExclusionConfig::default()
653 };
654 let inheriting = resolve(Some(&global), None);
655 assert!(category_enabled(
656 "sqli",
657 inheriting.enabled_categories.as_ref()
658 ));
659 assert!(!category_enabled(
660 "xss",
661 inheriting.enabled_categories.as_ref()
662 ));
663 }
664
665 #[test]
666 fn scan_body_toggle_overrides_both_directions() {
667 let global = DetectionExclusionConfig {
669 detection_scan_body: Some(true),
670 ..DetectionExclusionConfig::default()
671 };
672 let route = RouteDetectionExclusions {
673 detection_scan_body: Some(false),
674 ..RouteDetectionExclusions::default()
675 };
676 assert!(!resolve(Some(&global), Some(&route)).scan_body);
677
678 let global = DetectionExclusionConfig {
680 detection_scan_body: Some(false),
681 ..DetectionExclusionConfig::default()
682 };
683 let route = RouteDetectionExclusions {
684 detection_scan_body: Some(true),
685 ..RouteDetectionExclusions::default()
686 };
687 assert!(resolve(Some(&global), Some(&route)).scan_body);
688
689 let global = DetectionExclusionConfig {
691 detection_scan_body: Some(false),
692 ..DetectionExclusionConfig::default()
693 };
694 assert!(!resolve(Some(&global), None).scan_body);
695 }
696
697 #[test]
698 fn the_scan_value_budget_caps_the_values_scanned() {
699 let mut config = corpus_config();
700 config.max_scan_values = 2;
701 let exclusions = resolve(None, None);
702 let surfaces = RequestSurfaces {
704 url_path: Some("/scan"),
705 query_params: &[
706 ("a".to_owned(), "benign-one".to_owned()),
707 ("b".to_owned(), "benign-two".to_owned()),
708 ("c".to_owned(), "1 UNION SELECT password".to_owned()),
709 ("d".to_owned(), "benign-four".to_owned()),
710 ("e".to_owned(), "<script>alert(1)</script>".to_owned()),
711 ],
712 headers: &[],
713 content_type: "",
714 raw_body: "",
715 };
716 let verdict = scan_request(&surfaces, &exclusions, &config);
717 assert!(
718 !verdict.is_threat,
719 "values past the detection_max_scan_values cap are not scanned"
720 );
721
722 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
724 assert!(verdict.is_threat);
725 }
726
727 #[test]
728 fn the_scan_char_budget_caps_the_characters_scanned() {
729 let mut config = corpus_config();
730 config.max_scan_chars = "benign-one".chars().count() + "benign-two".chars().count();
732 let exclusions = resolve(None, None);
733 let surfaces = RequestSurfaces {
734 url_path: Some("/scan"),
735 query_params: &[
736 ("a".to_owned(), "benign-one".to_owned()),
737 ("b".to_owned(), "benign-two".to_owned()),
738 ("c".to_owned(), "1 UNION SELECT password".to_owned()),
739 ],
740 headers: &[],
741 content_type: "",
742 raw_body: "",
743 };
744 let verdict = scan_request(&surfaces, &exclusions, &config);
745 assert!(
746 !verdict.is_threat,
747 "values past the detection_max_scan_chars cap are not scanned"
748 );
749 }
750
751 #[test]
752 fn the_budgets_count_every_candidate_even_skipped_ones() {
753 let mut config = corpus_config();
754 config.max_scan_values = 3;
757 config.max_scan_chars = 0;
758 let exclusions = resolve(None, None);
759 let surfaces = RequestSurfaces {
760 url_path: Some("/scan"),
761 query_params: &[
762 ("a".to_owned(), "1 UNION SELECT password".to_owned()),
763 ("b".to_owned(), "<script>alert(1)</script>".to_owned()),
764 ("c".to_owned(), "benign".to_owned()),
765 ],
766 headers: &[],
767 content_type: "",
768 raw_body: "",
769 };
770 let verdict = scan_request(&surfaces, &exclusions, &config);
771 assert!(
772 !verdict.is_threat,
773 "a zero char budget skips every value (the count still ran)"
774 );
775 }
776 #[test]
777 fn scan_body_false_still_scans_headers_params_and_path() {
778 let route = RouteDetectionExclusions {
779 detection_scan_body: Some(false),
780 ..RouteDetectionExclusions::default()
781 };
782 let exclusions = resolve(None, Some(&route));
783 let surfaces = RequestSurfaces {
784 url_path: Some("/files?x=../../etc/passwd"),
785 query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
786 headers: &[(
787 "x-payload".to_owned(),
788 "<script>alert(1)</script>".to_owned(),
789 )],
790 content_type: "application/x-www-form-urlencoded",
791 raw_body: "payload=<img src=x onerror=alert(1)>",
792 };
793 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
794 assert!(
795 verdict.is_threat,
796 "the body surface is skipped, the rest still detect"
797 );
798 }
799
800 #[test]
801 fn excluded_param_skips_only_itself() {
802 let global = config_of(&["debug"]);
803 let exclusions = resolve(Some(&global), None);
804 let surfaces = RequestSurfaces {
805 url_path: Some("/search"),
806 query_params: &[
807 ("debug".to_owned(), "1 UNION SELECT password".to_owned()),
808 ("q".to_owned(), "<script>alert(1)</script>".to_owned()),
809 ],
810 headers: &[],
811 content_type: "",
812 raw_body: "",
813 };
814 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
815 assert!(verdict.is_threat);
816 assert!(
817 !verdict.categories.contains(&"sqli".to_owned()),
818 "the excluded param did not contribute"
819 );
820 let surfaces = RequestSurfaces {
822 url_path: Some("/search"),
823 query_params: &[("DEBUG".to_owned(), "1 UNION SELECT password".to_owned())],
824 headers: &[],
825 content_type: "",
826 raw_body: "",
827 };
828 assert!(!scan_request(&surfaces, &exclusions, &corpus_config()).is_threat);
829 }
830
831 #[test]
832 fn excluded_body_field_skips_only_that_field() {
833 let global = DetectionExclusionConfig {
834 excluded_detection_body_fields: vec!["secret".to_owned()],
835 ..DetectionExclusionConfig::default()
836 };
837 let exclusions = resolve(Some(&global), None);
838 let surfaces = RequestSurfaces {
839 url_path: Some("/api"),
840 query_params: &[],
841 headers: &[],
842 content_type: "application/x-www-form-urlencoded",
843 raw_body: "secret=1 UNION SELECT password&safe=<script>alert(1)</script>",
844 };
845 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
846 assert!(verdict.is_threat, "the untouched field still scans");
847 assert_eq!(verdict.categories, vec!["xss"]);
848
849 let surfaces = RequestSurfaces {
851 url_path: Some("/api"),
852 query_params: &[],
853 headers: &[],
854 content_type: "application/x-www-form-urlencoded",
855 raw_body: "secret=1 UNION SELECT password",
856 };
857 assert!(!scan_request(&surfaces, &exclusions, &corpus_config()).is_threat);
858 }
859
860 #[test]
861 fn excluded_header_with_attack_payload_still_detects() {
862 let exclusions = resolve(None, None);
863 let surfaces = RequestSurfaces {
864 url_path: Some("/x"),
865 query_params: &[],
866 headers: &[(
867 "x-custom".to_owned(),
868 "<script>alert(1)</script>".to_owned(),
869 )],
870 content_type: "",
871 raw_body: "",
872 };
873 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
874 assert!(verdict.is_threat, "an excluded header is not a skip hole");
875 assert_eq!(verdict.categories, vec!["xss"]);
876 }
877
878 #[test]
879 fn address_chain_headers_skip_ssrf_only() {
880 let chain = "10.0.0.5, 172.16.0.1";
883 assert_eq!(
884 excluded_header_skip_categories("x-custom", chain),
885 &["ssrf"][..]
886 );
887 assert_eq!(
888 excluded_header_skip_categories("via", chain),
889 &["ssrf"][..],
890 "the address-carrying name skips for any value"
891 );
892 assert_eq!(
893 excluded_header_skip_categories("x-custom", "<script>alert(1)</script>"),
894 &[] as &[&str],
895 "a non-address value skips nothing"
896 );
897 assert_eq!(
898 excluded_header_skip_categories("x-custom", "10.0.0.5, not-an-ip"),
899 &[] as &[&str],
900 "one non-address token breaks the chain"
901 );
902 }
903
904 #[test]
905 fn port_stripping_feeds_the_address_parser() {
906 assert_eq!(strip_forwarded_entry_port("1.2.3.4:8080"), "1.2.3.4");
907 assert_eq!(strip_forwarded_entry_port("[::1]:8080"), "::1");
908 assert_eq!(strip_forwarded_entry_port("::1"), "::1");
909 assert_eq!(strip_forwarded_entry_port("not-an-ip"), "not-an-ip");
910 assert_eq!(
911 strip_forwarded_entry_port("[::1]:nope"),
912 "[::1]:nope",
913 "a non-numeric port keeps the value unparsed"
914 );
915 assert!(
916 value_looks_like_address_chain("1.2.3.4:8080, [::1]:8080"),
917 "socket-literal entries parse once the port is stripped"
918 );
919 }
920
921 #[test]
922 fn filtered_out_threat_ends_the_scan_clean() {
923 let route = RouteDetectionExclusions {
927 enabled_detection_categories: Some(vec!["xss".to_owned()]),
928 ..RouteDetectionExclusions::default()
929 };
930 let exclusions = resolve(None, Some(&route));
931 let surfaces = RequestSurfaces {
932 url_path: Some("/x"),
933 query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
934 headers: &[],
935 content_type: "",
936 raw_body: "",
937 };
938 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
939 assert!(!verdict.is_threat);
940 assert!(verdict.categories.is_empty());
941 }
942
943 #[test]
944 fn enabled_categories_route_the_verdict() {
945 let route = RouteDetectionExclusions {
946 enabled_detection_categories: Some(vec!["sqli".to_owned()]),
947 ..RouteDetectionExclusions::default()
948 };
949 let exclusions = resolve(None, Some(&route));
950 let surfaces = RequestSurfaces {
951 url_path: Some("/x"),
952 query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
953 headers: &[(
954 "x-payload".to_owned(),
955 "<script>alert(1)</script>".to_owned(),
956 )],
957 content_type: "",
958 raw_body: "",
959 };
960 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
961 assert!(verdict.is_threat);
962 assert_eq!(verdict.categories, vec!["sqli"]);
963 assert!(verdict.reason.contains("sqli"));
964 assert!(!verdict.reason.contains("xss"), "xss is not enabled here");
965 }
966
967 #[test]
968 fn mongo_operator_keys_hit_through_the_body_walk() {
969 let exclusions = resolve(None, None);
970 let surfaces = RequestSurfaces {
971 url_path: Some("/x"),
972 query_params: &[],
973 headers: &[],
974 content_type: "application/json",
975 raw_body: "{\"$where\": \"1 OR 1=1\"}",
976 };
977 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
978 assert!(verdict.is_threat);
979 assert_eq!(verdict.categories, vec!["nosql"]);
980 }
981
982 #[test]
983 fn route_toggles_and_liveness_mirror_the_reference() {
984 assert!(detection_enabled(false, Some(true)));
986 assert!(!detection_enabled(true, Some(false)));
987 assert!(detection_enabled(true, None));
988 assert!(!detection_enabled(false, None));
989
990 assert!(check_applies(false, [false, true], false));
992 assert!(!check_applies(false, [false, false], false));
993 assert!(check_applies(true, [], false));
994 assert!(
995 check_applies(false, [false], true),
996 "dynamic rules keep it alive"
997 );
998 }
999}
1000
1001#[cfg(test)]
1002mod gap_tests {
1003 use super::*;
1004
1005 fn config() -> DetectConfig {
1006 DetectConfig {
1007 max_content_length: 10_000,
1008 max_full_scan_bytes: 262_144,
1009 preserve_attack_patterns: true,
1010 semantic_threshold: 0.7,
1011 threat_score_threshold: 1.0,
1012 binary_min_run_length: 16,
1013 max_scan_values: 512,
1014 max_scan_chars: 65_536,
1015 max_json_depth: 32,
1016 }
1017 }
1018
1019 #[test]
1020 fn an_unterminated_bracket_entry_keeps_its_port_text() {
1021 assert_eq!(strip_forwarded_entry_port("[::1"), "[::1");
1022 }
1023
1024 #[test]
1025 fn a_chain_of_only_empty_tokens_is_not_an_address_chain() {
1026 assert!(!value_looks_like_address_chain(" , ,, "));
1027 }
1028
1029 #[test]
1030 fn empty_header_values_never_scan() {
1031 let exclusions = ResolvedExclusions {
1032 excluded_params: std::collections::HashSet::new(),
1033 excluded_headers: std::collections::HashSet::new(),
1034 excluded_body_fields: std::collections::HashSet::new(),
1035 enabled_categories: None,
1036 scan_body: false,
1037 };
1038 let headers = vec![
1039 ("x-empty".to_owned(), String::new()),
1040 ("x-attack".to_owned(), "10.0.0.5, 127.0.0.1".to_owned()),
1041 ];
1042 let verdict = scan_request(
1043 &RequestSurfaces {
1044 url_path: None,
1045 query_params: &[],
1046 headers: &headers,
1047 content_type: "",
1048 raw_body: "",
1049 },
1050 &exclusions,
1051 &config(),
1052 );
1053 assert!(verdict.is_threat);
1055 assert_eq!(verdict.categories, vec!["ssrf".to_owned()]);
1056 }
1057
1058 #[test]
1059 fn an_excluded_address_header_suppresses_its_ssrf_category() {
1060 let mut excluded_headers = std::collections::HashSet::new();
1061 excluded_headers.insert("x-forwarded-for".to_owned());
1062 let exclusions = ResolvedExclusions {
1063 excluded_params: std::collections::HashSet::new(),
1064 excluded_headers,
1065 excluded_body_fields: std::collections::HashSet::new(),
1066 enabled_categories: None,
1067 scan_body: false,
1068 };
1069 let headers = vec![(
1070 "x-forwarded-for".to_owned(),
1071 "10.0.0.5, 127.0.0.1".to_owned(),
1072 )];
1073 let verdict = scan_request(
1074 &RequestSurfaces {
1075 url_path: None,
1076 query_params: &[],
1077 headers: &headers,
1078 content_type: "",
1079 raw_body: "",
1080 },
1081 &exclusions,
1082 &config(),
1083 );
1084 assert!(!verdict.is_threat);
1087 assert!(verdict.categories.is_empty());
1088 }
1089
1090 #[test]
1091 fn a_semantic_threat_contributes_no_category_to_the_reason() {
1092 let content = format!(
1095 "select union insert update delete drop from where order group having concat \
1096 substring database table column (1 OR 1=1) {}",
1097 "A".repeat(120)
1098 );
1099 let verdict = scan_request(
1100 &RequestSurfaces {
1101 url_path: None,
1102 query_params: &[],
1103 headers: &[],
1104 content_type: "text/plain",
1105 raw_body: &content,
1106 },
1107 &ResolvedExclusions {
1108 excluded_params: std::collections::HashSet::new(),
1109 excluded_headers: std::collections::HashSet::new(),
1110 excluded_body_fields: std::collections::HashSet::new(),
1111 enabled_categories: None,
1112 scan_body: true,
1113 },
1114 &config(),
1115 );
1116 assert!(verdict.is_threat);
1117 assert_eq!(verdict.categories, vec!["sqli".to_owned()]);
1118 }
1119}