1use std::collections::HashSet;
58
59use crate::body_scan::BodyScanValue;
60use crate::detect::{self, DetectConfig, Threat};
61use crate::scan_budget::{DEFAULT_MAX_SCAN_SECONDS, ScanTimeBudget, budget_exhausted_message};
62
63const SSRF_SKIP: &[&str] = &["ssrf"];
66
67pub const DEFAULT_EXCLUDED_HEADERS: &[&str] = &[
74 "host",
75 "user-agent",
76 "accept",
77 "accept-encoding",
78 "connection",
79 "origin",
80 "referer",
81 "sec-fetch-site",
82 "sec-fetch-mode",
83 "sec-fetch-dest",
84 "sec-ch-ua",
85 "sec-ch-ua-mobile",
86 "sec-ch-ua-platform",
87 "forwarded",
88 "x-forwarded-for",
89 "x-forwarded-host",
90 "x-forwarded-proto",
91 "x-real-ip",
92 "x-client-ip",
93 "x-cluster-client-ip",
94 "cf-connecting-ip",
95 "true-client-ip",
96 "fly-client-ip",
97 "x-envoy-external-address",
98];
99
100const ADDRESS_HEADER_NAMES: &[&str] = &[
103 "host",
104 "origin",
105 "x-forwarded-for",
106 "x-forwarded-host",
107 "x-real-ip",
108 "x-client-ip",
109 "x-cluster-client-ip",
110 "cf-connecting-ip",
111 "true-client-ip",
112 "fly-client-ip",
113 "x-envoy-external-address",
114 "via",
115];
116
117#[derive(Debug, Clone, Default, PartialEq)]
123pub struct DetectionExclusionConfig {
124 pub excluded_detection_headers: Vec<String>,
127 pub excluded_semantic_detection_headers: Vec<String>,
131 pub excluded_detection_params: Vec<String>,
133 pub excluded_detection_body_fields: Vec<String>,
135 pub enabled_detection_categories: Option<Vec<String>>,
137 pub detection_scan_body: Option<bool>,
139 pub max_scan_seconds: Option<f64>,
143}
144
145#[derive(Debug, Clone, Default, PartialEq, Eq)]
151pub struct RouteDetectionExclusions {
152 pub excluded_detection_headers: Option<Vec<String>>,
155 pub excluded_semantic_detection_headers: Option<Vec<String>>,
158 pub excluded_detection_params: Option<Vec<String>>,
160 pub excluded_detection_body_fields: Option<Vec<String>>,
162 pub enabled_detection_categories: Option<Vec<String>>,
166 pub detection_scan_body: Option<bool>,
169}
170
171#[derive(Debug, Clone, Default, PartialEq)]
174pub struct ResolvedExclusions {
175 pub excluded_params: HashSet<String>,
177 pub excluded_body_fields: HashSet<String>,
179 pub excluded_headers: HashSet<String>,
182 pub semantic_headers: HashSet<String>,
185 pub enabled_categories: Option<HashSet<String>>,
188 pub scan_body: bool,
190 pub max_scan_seconds: Option<f64>,
193}
194
195fn extend_trimmed_lowered(set: &mut HashSet<String>, values: impl IntoIterator<Item = String>) {
198 set.extend(
199 values
200 .into_iter()
201 .map(|value| value.trim().to_ascii_lowercase()),
202 );
203}
204
205fn extend_lowered(set: &mut HashSet<String>, values: impl IntoIterator<Item = String>) {
207 set.extend(values.into_iter().map(|value| value.to_ascii_lowercase()));
208}
209
210#[must_use]
214pub fn resolve(
215 config: Option<&DetectionExclusionConfig>,
216 route: Option<&RouteDetectionExclusions>,
217) -> ResolvedExclusions {
218 let mut resolved = ResolvedExclusions::default();
219
220 resolved
222 .excluded_headers
223 .extend(DEFAULT_EXCLUDED_HEADERS.iter().map(|h| (*h).to_owned()));
224 if let Some(config) = config {
225 extend_lowered(
226 &mut resolved.excluded_headers,
227 config.excluded_detection_headers.clone(),
228 );
229 }
230 if let Some(headers) = route.and_then(|route| route.excluded_detection_headers.as_ref()) {
231 extend_lowered(&mut resolved.excluded_headers, headers.clone());
232 }
233
234 if let Some(config) = config {
236 extend_trimmed_lowered(
237 &mut resolved.semantic_headers,
238 config.excluded_semantic_detection_headers.clone(),
239 );
240 }
241 if let Some(headers) =
242 route.and_then(|route| route.excluded_semantic_detection_headers.as_ref())
243 {
244 extend_trimmed_lowered(&mut resolved.semantic_headers, headers.clone());
245 }
246
247 let params: Option<Vec<String>> = route
250 .and_then(|route| route.excluded_detection_params.clone())
251 .or_else(|| config.map(|config| config.excluded_detection_params.clone()));
252 if let Some(params) = params {
253 extend_lowered(&mut resolved.excluded_params, params);
254 }
255 let body_fields: Option<Vec<String>> = route
256 .and_then(|route| route.excluded_detection_body_fields.clone())
257 .or_else(|| config.map(|config| config.excluded_detection_body_fields.clone()));
258 if let Some(body_fields) = body_fields {
259 extend_lowered(&mut resolved.excluded_body_fields, body_fields);
260 }
261 let categories: Option<Vec<String>> = route
262 .and_then(|route| route.enabled_detection_categories.clone())
263 .or_else(|| config.and_then(|config| config.enabled_detection_categories.clone()));
264 if let Some(categories) = categories {
265 let mut set = HashSet::new();
266 extend_lowered(&mut set, categories);
267 resolved.enabled_categories = Some(set);
268 }
269
270 resolved.scan_body = route
272 .and_then(|route| route.detection_scan_body)
273 .or_else(|| config.and_then(|config| config.detection_scan_body))
274 .unwrap_or(true);
275
276 resolved.max_scan_seconds = config.and_then(|config| config.max_scan_seconds);
279
280 resolved
281}
282
283#[must_use]
291pub fn excluded_header_skip_categories(name: &str, value: &str) -> &'static [&'static str] {
292 let normalized = name.trim().to_ascii_lowercase();
293 if ADDRESS_HEADER_NAMES.contains(&normalized.as_str()) {
294 return SSRF_SKIP;
295 }
296 if value_looks_like_address_chain(value) {
297 return SSRF_SKIP;
298 }
299 &[]
300}
301
302#[must_use]
308pub fn strip_forwarded_entry_port(value: &str) -> &str {
309 if let Some(rest) = value.strip_prefix('[') {
310 let Some(closing) = rest.find(']') else {
311 return value;
312 };
313 let remainder = &rest[closing + 1..];
314 let port_valid =
315 remainder.starts_with(':') && remainder[1..].bytes().all(|b| b.is_ascii_digit());
316 if !(remainder.is_empty() || port_valid) {
317 return value;
318 }
319 return &rest[..closing];
320 }
321 if value.matches(':').count() == 1
322 && let Some((host, port)) = value.split_once(':')
323 && !port.is_empty()
324 && port.bytes().all(|b| b.is_ascii_digit())
325 {
326 return host;
327 }
328 value
329}
330
331#[must_use]
337pub fn value_looks_like_address_chain(value: &str) -> bool {
338 let tokens: Vec<&str> = value
339 .split(',')
340 .map(str::trim)
341 .filter(|token| !token.is_empty())
342 .collect();
343 if tokens.is_empty() {
344 return false;
345 }
346 tokens.iter().all(|token| {
347 strip_forwarded_entry_port(token)
348 .parse::<std::net::IpAddr>()
349 .is_ok()
350 })
351}
352
353fn header_items_to_scan<'a>(name: &str, value: &'a str) -> Vec<&'a str> {
361 if name.trim().eq_ignore_ascii_case("cookie") {
362 value
363 .split(';')
364 .map(|pair| pair.trim_matches(' '))
365 .filter(|pair| !pair.is_empty())
366 .collect()
367 } else {
368 vec![value]
369 }
370}
371
372#[derive(Debug, Clone, Default)]
376pub struct RequestSurfaces<'a> {
377 pub url_path: Option<&'a str>,
379 pub query_params: &'a [(String, String)],
381 pub headers: &'a [(String, String)],
383 pub content_type: &'a str,
385 pub raw_body: &'a str,
388}
389
390#[derive(Debug, Clone, Default, PartialEq, Eq)]
392pub struct RequestScanVerdict {
393 pub is_threat: bool,
396 pub categories: Vec<String>,
399 pub reason: String,
401}
402
403struct ScanBudget {
410 max_values: usize,
411 scanned_values: usize,
412 max_chars: usize,
413 scanned_chars: usize,
414}
415
416impl ScanBudget {
417 const fn new(config: &DetectConfig) -> Self {
418 Self {
419 max_values: config.max_scan_values,
420 scanned_values: 0,
421 max_chars: config.max_scan_chars,
422 scanned_chars: 0,
423 }
424 }
425
426 fn admit(&mut self, content: &str) -> bool {
430 self.scanned_values += 1;
431 if self.scanned_values > self.max_values {
432 return false;
433 }
434 let value_chars = content.chars().count();
435 if self.scanned_chars >= self.max_chars {
436 return false;
437 }
438 self.scanned_chars += value_chars;
439 true
440 }
441}
442struct ScanValue<'a> {
444 content: &'a str,
445 context: &'a str,
446 skip_categories: &'static [&'static str],
447 skip_semantic: bool,
448}
449
450fn push_header_values<'a>(
453 values: &mut Vec<ScanValue<'a>>,
454 headers: &'a [(String, String)],
455 exclusions: &ResolvedExclusions,
456) {
457 for (name, value) in headers {
458 if value.is_empty() {
459 continue;
460 }
461 let skip_semantic = exclusions
462 .semantic_headers
463 .contains(&name.trim().to_ascii_lowercase());
464 let excluded = exclusions
465 .excluded_headers
466 .contains(&name.to_ascii_lowercase());
467 for pair in header_items_to_scan(name, value) {
468 values.push(ScanValue {
469 content: pair,
470 context: "header",
471 skip_categories: if excluded {
472 excluded_header_skip_categories(name, pair)
473 } else {
474 &[]
475 },
476 skip_semantic,
477 });
478 }
479 }
480}
481
482#[must_use]
500pub fn scan_request(
501 surfaces: &RequestSurfaces<'_>,
502 exclusions: &ResolvedExclusions,
503 config: &DetectConfig,
504) -> RequestScanVerdict {
505 let mut values: Vec<ScanValue<'_>> = Vec::new();
506 if let Some(path) = surfaces.url_path.filter(|path| !path.is_empty()) {
507 values.push(ScanValue {
508 content: path,
509 context: "url_path",
510 skip_categories: &[],
511 skip_semantic: false,
512 });
513 }
514 for (name, value) in surfaces.query_params {
515 if exclusions
516 .excluded_params
517 .contains(&name.to_ascii_lowercase())
518 {
519 continue;
520 }
521 values.push(ScanValue {
522 content: value,
523 context: "query_param",
524 skip_categories: &[],
525 skip_semantic: false,
526 });
527 }
528 push_header_values(&mut values, surfaces.headers, exclusions);
529 let body_values: Vec<BodyScanValue> = if exclusions.scan_body {
532 crate::body_scan::extract_body_scan_values_with_exclusions(
533 surfaces.raw_body,
534 surfaces.content_type,
535 config,
536 crate::body_scan::ExcludedBodyFields::new(&exclusions.excluded_body_fields),
537 )
538 } else {
539 Vec::new()
540 };
541 for value in &body_values {
542 if let Some(forced) = value.forced_category {
543 return forced_verdict(core::slice::from_ref(&forced));
547 }
548 values.push(ScanValue {
549 content: &value.content,
550 context: &value.context,
551 skip_categories: &[],
552 skip_semantic: false,
553 });
554 }
555
556 let mut budget = ScanBudget::new(config);
562 let mut time_budget = ScanTimeBudget::new(
565 exclusions
566 .max_scan_seconds
567 .unwrap_or(DEFAULT_MAX_SCAN_SECONDS),
568 );
569 for value in &values {
570 if !budget.admit(value.content) {
571 continue;
572 }
573 let verdict = detect::detect_with_budget(
574 value.content,
575 value.context,
576 config,
577 value.skip_semantic,
578 &mut time_budget,
579 );
580 if !verdict.is_threat {
581 continue;
582 }
583 if verdict
589 .threats
590 .iter()
591 .any(|threat| matches!(threat, Threat::PatternTimeout(_)))
592 {
593 return budget_exhausted_verdict(&verdict, exclusions, value.skip_categories);
594 }
595 let mut categories: Vec<String> = Vec::new();
596 for threat in &verdict.threats {
597 let category = match threat {
598 Threat::Regex(regex) | Threat::PatternTimeout(regex) => {
599 if !category_enabled(®ex.category, exclusions.enabled_categories.as_ref())
600 || value.skip_categories.contains(®ex.category.as_str())
601 {
602 continue;
603 }
604 regex.category.clone()
605 }
606 Threat::Semantic(semantic) => semantic.attack_type.clone(),
607 };
608 if !categories.contains(&category) {
609 categories.push(category);
610 }
611 }
612 if verdict.threats.is_empty() {
613 categories.push("uncategorized".to_owned());
614 }
615 if categories.is_empty() {
616 continue;
619 }
620 return verdict_from(categories);
621 }
622
623 RequestScanVerdict::default()
624}
625
626fn budget_exhausted_verdict(
631 verdict: &detect::DetectVerdict,
632 exclusions: &ResolvedExclusions,
633 skip_categories: &[&str],
634) -> RequestScanVerdict {
635 let mut matched: Vec<String> = Vec::new();
636 for threat in &verdict.threats {
637 if let Threat::Regex(regex) = threat
638 && category_enabled(®ex.category, exclusions.enabled_categories.as_ref())
639 && !skip_categories.contains(®ex.category.as_str())
640 && !matched.contains(®ex.category)
641 {
642 matched.push(regex.category.clone());
643 }
644 }
645 let budget_message = budget_exhausted_message();
646 let reason = if matched.is_empty() {
647 budget_message
648 } else {
649 matched.sort();
650 format!(
651 "Penetration patterns detected: {}; {budget_message}",
652 matched.join(", ")
653 )
654 };
655 let mut categories = matched;
656 categories.push("custom".to_owned());
657 categories.sort();
658 categories.dedup();
659 RequestScanVerdict {
660 is_threat: true,
661 categories,
662 reason,
663 }
664}
665
666fn forced_verdict(categories: &[&str]) -> RequestScanVerdict {
669 let categories: Vec<String> = categories.iter().map(|c| (*c).to_owned()).collect();
670 verdict_from(categories)
671}
672
673fn verdict_from(mut categories: Vec<String>) -> RequestScanVerdict {
674 categories.sort();
675 let reason = format!("Penetration patterns detected: {}", categories.join(", "));
676 RequestScanVerdict {
677 is_threat: true,
678 categories,
679 reason,
680 }
681}
682
683fn category_enabled(category: &str, enabled: Option<&HashSet<String>>) -> bool {
684 enabled.is_none_or(|set| set.contains(category))
685}
686
687#[must_use]
693pub fn detection_enabled(global_enabled: bool, route_enabled: Option<bool>) -> bool {
694 route_enabled.unwrap_or(global_enabled)
695}
696
697#[must_use]
703pub fn check_applies(
704 global_enabled: bool,
705 route_toggles: impl IntoIterator<Item = bool>,
706 dynamic_rules: bool,
707) -> bool {
708 global_enabled || route_toggles.into_iter().any(|flag| flag) || dynamic_rules
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714 use crate::detect::DetectConfig;
715
716 fn corpus_config() -> DetectConfig {
717 DetectConfig {
718 max_content_length: 10_000,
719 max_full_scan_bytes: 262_144,
720 preserve_attack_patterns: true,
721 semantic_threshold: 0.7,
722 threat_score_threshold: 1.0,
723 binary_min_run_length: 16,
724 max_scan_values: 512,
725 max_scan_chars: 65_536,
726 max_json_depth: 32,
727 }
728 }
729
730 fn config_of(params: &[&str]) -> DetectionExclusionConfig {
731 DetectionExclusionConfig {
732 excluded_detection_params: params.iter().map(|s| (*s).to_owned()).collect(),
733 ..DetectionExclusionConfig::default()
734 }
735 }
736
737 #[test]
738 fn resolve_defaults_scan_everything() {
739 let resolved = resolve(None, None);
740 assert!(resolved.excluded_params.is_empty());
741 assert!(resolved.excluded_body_fields.is_empty());
742 assert!(resolved.enabled_categories.is_none());
743 assert!(resolved.scan_body);
744 assert!(resolved.excluded_headers.contains("host"));
746 assert!(resolved.excluded_headers.contains("x-forwarded-for"));
747 assert!(!resolved.excluded_headers.contains("x-custom"));
748 }
749
750 #[test]
751 fn non_none_route_sets_replace_and_unset_inherit() {
752 let global = config_of(&["debug", "trace"]);
753 let route = RouteDetectionExclusions {
754 excluded_detection_params: Some(vec!["session".to_owned()]),
755 ..RouteDetectionExclusions::default()
756 };
757 let resolved = resolve(Some(&global), Some(&route));
758 assert!(resolved.excluded_params.contains("session"));
759 assert!(!resolved.excluded_params.contains("debug"));
760
761 let inheriting = resolve(Some(&global), Some(&RouteDetectionExclusions::default()));
763 assert!(inheriting.excluded_params.contains("debug"));
764 assert!(inheriting.excluded_params.contains("trace"));
765 }
766
767 #[test]
768 fn header_set_merges_defaults_config_and_route() {
769 let global = DetectionExclusionConfig {
770 excluded_detection_headers: vec!["X-Custom-Global".to_owned()],
771 ..DetectionExclusionConfig::default()
772 };
773 let route = RouteDetectionExclusions {
774 excluded_detection_headers: Some(vec!["x-custom-route".to_owned()]),
775 ..RouteDetectionExclusions::default()
776 };
777 let resolved = resolve(Some(&global), Some(&route));
778 assert!(resolved.excluded_headers.contains("x-custom-global"));
779 assert!(resolved.excluded_headers.contains("x-custom-route"));
780 assert!(
781 resolved.excluded_headers.contains("referer"),
782 "defaults survive"
783 );
784 }
785
786 #[test]
787 fn empty_route_category_set_disables_every_category() {
788 let route = RouteDetectionExclusions {
789 enabled_detection_categories: Some(Vec::new()),
790 ..RouteDetectionExclusions::default()
791 };
792 let resolved = resolve(None, Some(&route));
793 let enabled = resolved.enabled_categories.clone().expect("some");
794 assert!(enabled.is_empty(), "an empty Some replaces, not inherits");
795 assert!(!category_enabled(
796 "sqli",
797 resolved.enabled_categories.as_ref()
798 ));
799
800 let global = DetectionExclusionConfig {
802 enabled_detection_categories: Some(vec!["sqli".to_owned()]),
803 ..DetectionExclusionConfig::default()
804 };
805 let inheriting = resolve(Some(&global), None);
806 assert!(category_enabled(
807 "sqli",
808 inheriting.enabled_categories.as_ref()
809 ));
810 assert!(!category_enabled(
811 "xss",
812 inheriting.enabled_categories.as_ref()
813 ));
814 }
815
816 #[test]
817 fn scan_body_toggle_overrides_both_directions() {
818 let global = DetectionExclusionConfig {
820 detection_scan_body: Some(true),
821 ..DetectionExclusionConfig::default()
822 };
823 let route = RouteDetectionExclusions {
824 detection_scan_body: Some(false),
825 ..RouteDetectionExclusions::default()
826 };
827 assert!(!resolve(Some(&global), Some(&route)).scan_body);
828
829 let global = DetectionExclusionConfig {
831 detection_scan_body: Some(false),
832 ..DetectionExclusionConfig::default()
833 };
834 let route = RouteDetectionExclusions {
835 detection_scan_body: Some(true),
836 ..RouteDetectionExclusions::default()
837 };
838 assert!(resolve(Some(&global), Some(&route)).scan_body);
839
840 let global = DetectionExclusionConfig {
842 detection_scan_body: Some(false),
843 ..DetectionExclusionConfig::default()
844 };
845 assert!(!resolve(Some(&global), None).scan_body);
846 }
847
848 const SESSION_COOKIE: &str = "session=eyJhbGciOiAiRVMyNTYiLCAidHlwIjogIkpXVCJ9.eyJzdWIiOiAiM2YyYzFiOWUtOGE0Ny00ZDJlLTljMWYtNWI2YTdkOGU5ZjAxIiwgIm5hbWUiOiAiSmFuZSBEb2UgKEFjbWUpIiwgImVtYWlsIjogImphbmUuZG9lQGV4YW1wbGUuY29tIiwgInJvbGVzIjogWyJhZG1pbiJdLCAiaWF0IjogMTc5MjAwMDAwMCwgImV4cCI6IDE3OTIwMDA5MDB9.AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-Pw; theme=system; _ga=GA1.1.123456789.1792000000";
852
853 fn semantic_threat_count(content: &str, skip_semantic: bool) -> usize {
854 crate::detect::detect_with_semantic_skip(content, "header", &corpus_config(), skip_semantic)
855 .threats
856 .iter()
857 .filter(|threat| matches!(threat, Threat::Semantic(_)))
858 .count()
859 }
860
861 fn scan_header_with_xss_body(
865 name: &str,
866 value: &str,
867 global: Option<&DetectionExclusionConfig>,
868 route: Option<&RouteDetectionExclusions>,
869 ) -> RequestScanVerdict {
870 let headers = vec![(name.to_owned(), value.to_owned())];
871 scan_request(
872 &RequestSurfaces {
873 url_path: None,
874 query_params: &[],
875 headers: &headers,
876 content_type: "application/x-www-form-urlencoded",
877 raw_body: "q=<script>alert(1)</script>",
878 },
879 &resolve(global, route),
880 &corpus_config(),
881 )
882 }
883
884 #[test]
885 fn the_semantic_skip_drops_only_the_semantic_threats() {
886 assert!(
887 semantic_threat_count(SESSION_COOKIE, false) > 0,
888 "control: the unsplit session value trips the semantic analyzer"
889 );
890 assert_eq!(semantic_threat_count(SESSION_COOKIE, true), 0);
891 let verdict = crate::detect::detect_with_semantic_skip(
892 "<script>alert(1)</script>",
893 "header",
894 &corpus_config(),
895 true,
896 );
897 assert!(
898 verdict
899 .threats
900 .iter()
901 .any(|threat| matches!(threat, Threat::Regex(regex) if regex.category == "xss")),
902 "regex scanning is unchanged"
903 );
904 }
905
906 #[test]
907 fn semantic_headers_resolve_lowercased_trimmed_and_merged() {
908 let global = DetectionExclusionConfig {
909 excluded_semantic_detection_headers: vec![" X-Global ".to_owned()],
910 ..DetectionExclusionConfig::default()
911 };
912 let route = RouteDetectionExclusions {
913 excluded_semantic_detection_headers: Some(vec!["X-Route".to_owned()]),
914 ..RouteDetectionExclusions::default()
915 };
916 let merged = resolve(Some(&global), Some(&route));
917 assert_eq!(
918 merged.semantic_headers,
919 HashSet::from(["x-global".to_owned(), "x-route".to_owned()])
920 );
921 let inherited = resolve(Some(&global), Some(&RouteDetectionExclusions::default()));
922 assert_eq!(
923 inherited.semantic_headers,
924 HashSet::from(["x-global".to_owned()])
925 );
926 assert!(resolve(None, None).semantic_headers.is_empty(), "opt-in");
927 }
928
929 #[test]
930 fn a_global_semantic_header_skips_only_the_semantic_analyzer() {
931 let global = DetectionExclusionConfig {
932 excluded_semantic_detection_headers: vec!["X-Session".to_owned()],
933 ..DetectionExclusionConfig::default()
934 };
935 let verdict = scan_header_with_xss_body("x-session", SESSION_COOKIE, Some(&global), None);
936 assert_eq!(verdict.categories, vec!["xss".to_owned()]);
937
938 let attack = scan_header_with_xss_body(
939 "x-session",
940 "<img src=x onerror=alert(1)>",
941 Some(&global),
942 None,
943 );
944 assert_eq!(
945 attack.categories,
946 vec!["xss".to_owned()],
947 "regex scanning of the header is unchanged"
948 );
949 }
950
951 #[test]
952 fn a_route_semantic_header_skips_the_semantic_analyzer() {
953 let route = RouteDetectionExclusions {
954 excluded_semantic_detection_headers: Some(vec!["x-session".to_owned()]),
955 ..RouteDetectionExclusions::default()
956 };
957 let verdict = scan_header_with_xss_body("X-Session", SESSION_COOKIE, None, Some(&route));
958 assert_eq!(verdict.categories, vec!["xss".to_owned()]);
959 }
960
961 const SEMANTIC_ONLY_PAIR: &str = "${x} <t> (y) [z] {w} a://b c://d <b>call(f(x))</b> {{render(jinja(template(mustache(handlebars(ejs(pug(twig)))))))}}";
964
965 fn scan_headers(headers: &[(&str, &str)], config: &DetectConfig) -> RequestScanVerdict {
966 let headers: Vec<(String, String)> = headers
967 .iter()
968 .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
969 .collect();
970 scan_request(
971 &RequestSurfaces {
972 url_path: None,
973 query_params: &[],
974 headers: &headers,
975 content_type: "",
976 raw_body: "",
977 },
978 &resolve(None, None),
979 config,
980 )
981 }
982
983 #[test]
984 fn a_logged_in_cookie_header_is_not_flagged() {
985 let cookie = scan_header_with_xss_body("Cookie", SESSION_COOKIE, None, None);
986 assert_eq!(
987 cookie.categories,
988 vec!["xss".to_owned()],
989 "each pair scans on its own, so the session cookie is not a threat"
990 );
991 let other = scan_header_with_xss_body("x-session", SESSION_COOKIE, None, None);
992 assert!(
993 other.is_threat
994 && !other.categories.is_empty()
995 && other.categories != cookie.categories,
996 "control: the same value in another header scans as one value and is reported on its own: {:?}",
997 other.categories
998 );
999 }
1000
1001 #[test]
1002 fn an_attack_in_one_cookie_is_still_detected() {
1003 for (cookie, category) in [
1004 ("theme=dark; q=<script>alert(1)</script>", "xss"),
1005 ("theme=dark; id=1' OR 1=1--", "sqli"),
1006 ("theme=dark;\r\nSet-Cookie: admin=1", "http_split"),
1007 ] {
1008 let verdict = scan_headers(&[("Cookie", cookie)], &corpus_config());
1009 assert!(
1010 verdict.categories.iter().any(|found| found == category),
1011 "{cookie:?} must detect {category}, got {:?}",
1012 verdict.categories
1013 );
1014 }
1015 }
1016
1017 #[test]
1018 fn the_semantic_exclusion_applies_to_each_cookie_pair() {
1019 let cookie = format!("theme=dark; {SEMANTIC_ONLY_PAIR}");
1020 let blocked = scan_header_with_xss_body("cookie", &cookie, None, None);
1021 assert!(
1022 blocked.is_threat && !blocked.categories.is_empty() && blocked.categories != ["xss"],
1023 "control: the pair trips the semantic analyzer and is reported: {:?}",
1024 blocked.categories
1025 );
1026 let global = DetectionExclusionConfig {
1027 excluded_semantic_detection_headers: vec!["Cookie".to_owned()],
1028 ..DetectionExclusionConfig::default()
1029 };
1030 let verdict = scan_header_with_xss_body("cookie", &cookie, Some(&global), None);
1031 assert_eq!(verdict.categories, vec!["xss".to_owned()]);
1032 }
1033
1034 #[test]
1035 fn each_cookie_pair_counts_against_the_value_budget() {
1036 let mut config = corpus_config();
1037 config.max_scan_values = 2;
1038 let verdict = scan_headers(
1039 &[("cookie", "a=1; b=2; c=<script>alert(1)</script>")],
1040 &config,
1041 );
1042 assert!(
1043 verdict.categories.is_empty(),
1044 "the pair holding the attack is the third value, past the cap"
1045 );
1046 }
1047
1048 #[test]
1049 fn empty_cookie_pairs_are_skipped_and_only_spaces_are_trimmed() {
1050 let mut config = corpus_config();
1051 config.max_scan_values = 1;
1052 let verdict = scan_headers(&[("cookie", " ; ;; <script>alert(1)</script> ;")], &config);
1053 assert_eq!(
1054 verdict.categories,
1055 vec!["xss".to_owned()],
1056 "empty pairs take no budget"
1057 );
1058 }
1059
1060 fn benign_surfaces() -> RequestSurfaces<'static> {
1061 RequestSurfaces {
1062 url_path: Some("/items"),
1063 query_params: &[],
1064 headers: &[],
1065 content_type: "",
1066 raw_body: "",
1067 }
1068 }
1069
1070 fn zero_budget_exclusions(categories: Option<Vec<String>>) -> ResolvedExclusions {
1071 resolve(
1072 Some(&DetectionExclusionConfig {
1073 enabled_detection_categories: categories,
1074 max_scan_seconds: Some(0.0),
1075 ..DetectionExclusionConfig::default()
1076 }),
1077 None,
1078 )
1079 }
1080
1081 #[test]
1082 fn the_scan_time_cap_resolves_from_the_global_config_only() {
1083 assert_eq!(resolve(None, None).max_scan_seconds, None);
1084 let global = DetectionExclusionConfig {
1085 max_scan_seconds: Some(2.5),
1086 ..DetectionExclusionConfig::default()
1087 };
1088 assert_eq!(resolve(Some(&global), None).max_scan_seconds, Some(2.5));
1089 let route = RouteDetectionExclusions::default();
1090 assert_eq!(
1091 resolve(Some(&global), Some(&route)).max_scan_seconds,
1092 Some(2.5)
1093 );
1094 }
1095
1096 #[test]
1097 fn an_exhausted_scan_time_budget_blocks_and_names_the_budget() {
1098 let verdict = scan_request(
1099 &benign_surfaces(),
1100 &zero_budget_exclusions(None),
1101 &corpus_config(),
1102 );
1103 assert!(verdict.is_threat);
1104 assert_eq!(verdict.categories, vec!["custom".to_owned()]);
1105 assert_eq!(
1106 verdict.reason,
1107 "Pattern exceeded scan time budget: 'scan_time_budget_exhausted'"
1108 );
1109 }
1110
1111 #[test]
1112 fn the_budget_threat_follows_the_normal_threshold_rule() {
1113 let mut config = corpus_config();
1116 config.threat_score_threshold = 5.0;
1117 let verdict = scan_request(&benign_surfaces(), &zero_budget_exclusions(None), &config);
1118 assert!(!verdict.is_threat);
1119 assert!(verdict.reason.is_empty());
1120 }
1121
1122 #[test]
1123 fn the_budget_threat_is_not_dropped_by_the_category_filter() {
1124 let exclusions = zero_budget_exclusions(Some(vec!["xss".to_owned()]));
1127 let verdict = scan_request(&benign_surfaces(), &exclusions, &corpus_config());
1128 assert!(verdict.is_threat);
1129 assert!(verdict.reason.contains("scan time budget"));
1130 }
1131
1132 #[test]
1133 fn the_budget_reason_keeps_the_categories_the_value_already_matched() {
1134 let verdict = crate::detect::DetectVerdict {
1138 is_threat: true,
1139 threat_score: 1.0,
1140 threats: vec![
1141 Threat::Regex(crate::patterns::RegexThreat {
1142 pattern: "p".to_owned(),
1143 match_text: "m".to_owned(),
1144 position: 0,
1145 category: "xss".to_owned(),
1146 weight: 1.0,
1147 }),
1148 Threat::PatternTimeout(crate::patterns::scan_time_budget_exhausted_threat()),
1149 ],
1150 original_length: 1,
1151 processed_length: 1,
1152 };
1153 let out = budget_exhausted_verdict(&verdict, &resolve(None, None), &[]);
1154 assert_eq!(out.categories, vec!["custom".to_owned(), "xss".to_owned()]);
1155 assert_eq!(
1156 out.reason,
1157 "Penetration patterns detected: xss; Pattern exceeded scan time budget: \
1158 'scan_time_budget_exhausted'"
1159 );
1160 let filtered = budget_exhausted_verdict(&verdict, &resolve(None, None), &["xss"]);
1162 assert_eq!(filtered.categories, vec!["custom".to_owned()]);
1163 }
1164
1165 #[test]
1166 fn the_scan_time_budget_is_shared_by_every_value_of_the_request() {
1167 let surfaces = RequestSurfaces {
1168 url_path: Some("/items"),
1169 query_params: &[("q".to_owned(), "benign".to_owned())],
1170 headers: &[],
1171 content_type: "",
1172 raw_body: "",
1173 };
1174 let verdict = scan_request(&surfaces, &zero_budget_exclusions(None), &corpus_config());
1175 assert!(
1176 verdict.is_threat,
1177 "the first value already trips the budget"
1178 );
1179 }
1180
1181 #[test]
1182 fn the_default_scan_time_budget_leaves_a_benign_request_clean() {
1183 let verdict = scan_request(&benign_surfaces(), &resolve(None, None), &corpus_config());
1184 assert!(!verdict.is_threat);
1185 assert!(verdict.reason.is_empty());
1186 }
1187
1188 #[test]
1189 fn the_scan_value_budget_caps_the_values_scanned() {
1190 let mut config = corpus_config();
1191 config.max_scan_values = 2;
1192 let exclusions = resolve(None, None);
1193 let surfaces = RequestSurfaces {
1195 url_path: Some("/scan"),
1196 query_params: &[
1197 ("a".to_owned(), "benign-one".to_owned()),
1198 ("b".to_owned(), "benign-two".to_owned()),
1199 ("c".to_owned(), "1 UNION SELECT password".to_owned()),
1200 ("d".to_owned(), "benign-four".to_owned()),
1201 ("e".to_owned(), "<script>alert(1)</script>".to_owned()),
1202 ],
1203 headers: &[],
1204 content_type: "",
1205 raw_body: "",
1206 };
1207 let verdict = scan_request(&surfaces, &exclusions, &config);
1208 assert!(
1209 !verdict.is_threat,
1210 "values past the detection_max_scan_values cap are not scanned"
1211 );
1212
1213 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
1215 assert!(verdict.is_threat);
1216 }
1217
1218 #[test]
1219 fn the_scan_char_budget_caps_the_characters_scanned() {
1220 let mut config = corpus_config();
1221 config.max_scan_chars = "benign-one".chars().count() + "benign-two".chars().count();
1223 let exclusions = resolve(None, None);
1224 let surfaces = RequestSurfaces {
1225 url_path: Some("/scan"),
1226 query_params: &[
1227 ("a".to_owned(), "benign-one".to_owned()),
1228 ("b".to_owned(), "benign-two".to_owned()),
1229 ("c".to_owned(), "1 UNION SELECT password".to_owned()),
1230 ],
1231 headers: &[],
1232 content_type: "",
1233 raw_body: "",
1234 };
1235 let verdict = scan_request(&surfaces, &exclusions, &config);
1236 assert!(
1237 !verdict.is_threat,
1238 "values past the detection_max_scan_chars cap are not scanned"
1239 );
1240 }
1241
1242 #[test]
1243 fn the_budgets_count_every_candidate_even_skipped_ones() {
1244 let mut config = corpus_config();
1245 config.max_scan_values = 3;
1248 config.max_scan_chars = 0;
1249 let exclusions = resolve(None, None);
1250 let surfaces = RequestSurfaces {
1251 url_path: Some("/scan"),
1252 query_params: &[
1253 ("a".to_owned(), "1 UNION SELECT password".to_owned()),
1254 ("b".to_owned(), "<script>alert(1)</script>".to_owned()),
1255 ("c".to_owned(), "benign".to_owned()),
1256 ],
1257 headers: &[],
1258 content_type: "",
1259 raw_body: "",
1260 };
1261 let verdict = scan_request(&surfaces, &exclusions, &config);
1262 assert!(
1263 !verdict.is_threat,
1264 "a zero char budget skips every value (the count still ran)"
1265 );
1266 }
1267 #[test]
1268 fn scan_body_false_still_scans_headers_params_and_path() {
1269 let route = RouteDetectionExclusions {
1270 detection_scan_body: Some(false),
1271 ..RouteDetectionExclusions::default()
1272 };
1273 let exclusions = resolve(None, Some(&route));
1274 let surfaces = RequestSurfaces {
1275 url_path: Some("/files?x=../../etc/passwd"),
1276 query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
1277 headers: &[(
1278 "x-payload".to_owned(),
1279 "<script>alert(1)</script>".to_owned(),
1280 )],
1281 content_type: "application/x-www-form-urlencoded",
1282 raw_body: "payload=<img src=x onerror=alert(1)>",
1283 };
1284 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
1285 assert!(
1286 verdict.is_threat,
1287 "the body surface is skipped, the rest still detect"
1288 );
1289 }
1290
1291 #[test]
1292 fn excluded_param_skips_only_itself() {
1293 let global = config_of(&["debug"]);
1294 let exclusions = resolve(Some(&global), None);
1295 let surfaces = RequestSurfaces {
1296 url_path: Some("/search"),
1297 query_params: &[
1298 ("debug".to_owned(), "1 UNION SELECT password".to_owned()),
1299 ("q".to_owned(), "<script>alert(1)</script>".to_owned()),
1300 ],
1301 headers: &[],
1302 content_type: "",
1303 raw_body: "",
1304 };
1305 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
1306 assert!(verdict.is_threat);
1307 assert!(
1308 !verdict.categories.contains(&"sqli".to_owned()),
1309 "the excluded param did not contribute"
1310 );
1311 let surfaces = RequestSurfaces {
1313 url_path: Some("/search"),
1314 query_params: &[("DEBUG".to_owned(), "1 UNION SELECT password".to_owned())],
1315 headers: &[],
1316 content_type: "",
1317 raw_body: "",
1318 };
1319 assert!(!scan_request(&surfaces, &exclusions, &corpus_config()).is_threat);
1320 }
1321
1322 #[test]
1323 fn excluded_body_field_skips_only_that_field() {
1324 let global = DetectionExclusionConfig {
1325 excluded_detection_body_fields: vec!["secret".to_owned()],
1326 ..DetectionExclusionConfig::default()
1327 };
1328 let exclusions = resolve(Some(&global), None);
1329 let surfaces = RequestSurfaces {
1330 url_path: Some("/api"),
1331 query_params: &[],
1332 headers: &[],
1333 content_type: "application/x-www-form-urlencoded",
1334 raw_body: "secret=1 UNION SELECT password&safe=<script>alert(1)</script>",
1335 };
1336 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
1337 assert!(verdict.is_threat, "the untouched field still scans");
1338 assert_eq!(verdict.categories, vec!["xss"]);
1339
1340 let surfaces = RequestSurfaces {
1342 url_path: Some("/api"),
1343 query_params: &[],
1344 headers: &[],
1345 content_type: "application/x-www-form-urlencoded",
1346 raw_body: "secret=1 UNION SELECT password",
1347 };
1348 assert!(!scan_request(&surfaces, &exclusions, &corpus_config()).is_threat);
1349 }
1350
1351 #[test]
1352 fn excluded_header_with_attack_payload_still_detects() {
1353 let exclusions = resolve(None, None);
1354 let surfaces = RequestSurfaces {
1355 url_path: Some("/x"),
1356 query_params: &[],
1357 headers: &[(
1358 "x-custom".to_owned(),
1359 "<script>alert(1)</script>".to_owned(),
1360 )],
1361 content_type: "",
1362 raw_body: "",
1363 };
1364 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
1365 assert!(verdict.is_threat, "an excluded header is not a skip hole");
1366 assert_eq!(verdict.categories, vec!["xss"]);
1367 }
1368
1369 #[test]
1370 fn address_chain_headers_skip_ssrf_only() {
1371 let chain = "10.0.0.5, 172.16.0.1";
1374 assert_eq!(
1375 excluded_header_skip_categories("x-custom", chain),
1376 &["ssrf"][..]
1377 );
1378 assert_eq!(
1379 excluded_header_skip_categories("via", chain),
1380 &["ssrf"][..],
1381 "the address-carrying name skips for any value"
1382 );
1383 assert_eq!(
1384 excluded_header_skip_categories("x-custom", "<script>alert(1)</script>"),
1385 &[] as &[&str],
1386 "a non-address value skips nothing"
1387 );
1388 assert_eq!(
1389 excluded_header_skip_categories("x-custom", "10.0.0.5, not-an-ip"),
1390 &[] as &[&str],
1391 "one non-address token breaks the chain"
1392 );
1393 }
1394
1395 #[test]
1396 fn port_stripping_feeds_the_address_parser() {
1397 assert_eq!(strip_forwarded_entry_port("1.2.3.4:8080"), "1.2.3.4");
1398 assert_eq!(strip_forwarded_entry_port("[::1]:8080"), "::1");
1399 assert_eq!(strip_forwarded_entry_port("::1"), "::1");
1400 assert_eq!(strip_forwarded_entry_port("not-an-ip"), "not-an-ip");
1401 assert_eq!(
1402 strip_forwarded_entry_port("[::1]:nope"),
1403 "[::1]:nope",
1404 "a non-numeric port keeps the value unparsed"
1405 );
1406 assert!(
1407 value_looks_like_address_chain("1.2.3.4:8080, [::1]:8080"),
1408 "socket-literal entries parse once the port is stripped"
1409 );
1410 }
1411
1412 #[test]
1413 fn filtered_out_threat_alone_is_clean() {
1414 let route = RouteDetectionExclusions {
1417 enabled_detection_categories: Some(vec!["xss".to_owned()]),
1418 ..RouteDetectionExclusions::default()
1419 };
1420 let exclusions = resolve(None, Some(&route));
1421 let surfaces = RequestSurfaces {
1422 url_path: Some("/x"),
1423 query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
1424 headers: &[],
1425 content_type: "",
1426 raw_body: "",
1427 };
1428 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
1429 assert!(!verdict.is_threat);
1430 assert!(verdict.categories.is_empty());
1431 }
1432
1433 const XSS: &str = "<script>alert(1)</script>";
1434
1435 fn semantic_only_value() -> String {
1436 SESSION_COOKIE.to_owned()
1437 }
1438
1439 fn scan_surfaces(
1440 exclusions: &ResolvedExclusions,
1441 query: &[(String, String)],
1442 headers: &[(String, String)],
1443 ) -> RequestScanVerdict {
1444 let surfaces = RequestSurfaces {
1445 url_path: Some("/x"),
1446 query_params: query,
1447 headers,
1448 content_type: "",
1449 raw_body: "",
1450 };
1451 scan_request(&surfaces, exclusions, &corpus_config())
1452 }
1453
1454 #[test]
1455 fn semantic_only_value_is_reported() {
1456 let query = [("q".to_owned(), semantic_only_value())];
1457 let verdict = scan_surfaces(&resolve(None, None), &query, &[]);
1458 assert!(verdict.is_threat, "semantic-only threat must be reported");
1459 assert!(!verdict.categories.is_empty());
1460 }
1461
1462 #[test]
1463 fn filtered_out_value_does_not_end_the_scan() {
1464 let headers = [
1465 (
1466 "x-forwarded-host".to_owned(),
1467 "http://169.254.169.254/latest/meta-data/".to_owned(),
1468 ),
1469 ("x-evil".to_owned(), XSS.to_owned()),
1470 ];
1471 let verdict = scan_surfaces(&resolve(None, None), &[], &headers);
1472 assert_eq!(verdict.categories, vec!["xss".to_owned()]);
1473 }
1474
1475 #[test]
1476 fn disabled_category_does_not_end_the_scan() {
1477 let route = RouteDetectionExclusions {
1478 enabled_detection_categories: Some(vec!["xss".to_owned()]),
1479 ..RouteDetectionExclusions::default()
1480 };
1481 let query = [("q".to_owned(), "1 UNION SELECT password".to_owned())];
1482 let headers = [("x-evil".to_owned(), XSS.to_owned())];
1483 let verdict = scan_surfaces(&resolve(None, Some(&route)), &query, &headers);
1484 assert_eq!(verdict.categories, vec!["xss".to_owned()]);
1485 }
1486
1487 #[test]
1488 fn a_threat_without_a_threats_list_reports_as_uncategorized() {
1489 let config = DetectConfig {
1491 threat_score_threshold: 0.0,
1492 ..corpus_config()
1493 };
1494 let query = [("q".to_owned(), "hello".to_owned())];
1495 let surfaces = RequestSurfaces {
1496 url_path: None,
1497 query_params: &query,
1498 headers: &[],
1499 content_type: "",
1500 raw_body: "",
1501 };
1502 let verdict = scan_request(&surfaces, &resolve(None, None), &config);
1503 assert!(verdict.is_threat);
1504 assert_eq!(verdict.categories, vec!["uncategorized".to_owned()]);
1505 }
1506
1507 #[test]
1508 fn semantic_then_header_xss_is_still_reported() {
1509 let query = [("q".to_owned(), semantic_only_value())];
1510 let headers = [("x-evil".to_owned(), XSS.to_owned())];
1511 let verdict = scan_surfaces(&resolve(None, None), &query, &headers);
1512 assert!(verdict.is_threat);
1513 }
1514
1515 #[test]
1516 fn enabled_categories_route_the_verdict() {
1517 let route = RouteDetectionExclusions {
1518 enabled_detection_categories: Some(vec!["sqli".to_owned()]),
1519 ..RouteDetectionExclusions::default()
1520 };
1521 let exclusions = resolve(None, Some(&route));
1522 let surfaces = RequestSurfaces {
1523 url_path: Some("/x"),
1524 query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
1525 headers: &[(
1526 "x-payload".to_owned(),
1527 "<script>alert(1)</script>".to_owned(),
1528 )],
1529 content_type: "",
1530 raw_body: "",
1531 };
1532 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
1533 assert!(verdict.is_threat);
1534 assert_eq!(verdict.categories, vec!["sqli"]);
1535 assert!(verdict.reason.contains("sqli"));
1536 assert!(!verdict.reason.contains("xss"), "xss is not enabled here");
1537 }
1538
1539 #[test]
1540 fn mongo_operator_keys_hit_through_the_body_walk() {
1541 let exclusions = resolve(None, None);
1542 let surfaces = RequestSurfaces {
1543 url_path: Some("/x"),
1544 query_params: &[],
1545 headers: &[],
1546 content_type: "application/json",
1547 raw_body: "{\"$where\": \"1 OR 1=1\"}",
1548 };
1549 let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
1550 assert!(verdict.is_threat);
1551 assert_eq!(verdict.categories, vec!["nosql"]);
1552 }
1553
1554 #[test]
1555 fn route_toggles_and_liveness_mirror_the_reference() {
1556 assert!(detection_enabled(false, Some(true)));
1558 assert!(!detection_enabled(true, Some(false)));
1559 assert!(detection_enabled(true, None));
1560 assert!(!detection_enabled(false, None));
1561
1562 assert!(check_applies(false, [false, true], false));
1564 assert!(!check_applies(false, [false, false], false));
1565 assert!(check_applies(true, [], false));
1566 assert!(
1567 check_applies(false, [false], true),
1568 "dynamic rules keep it alive"
1569 );
1570 }
1571}
1572
1573#[cfg(test)]
1574mod gap_tests {
1575 use super::*;
1576
1577 fn config() -> DetectConfig {
1578 DetectConfig {
1579 max_content_length: 10_000,
1580 max_full_scan_bytes: 262_144,
1581 preserve_attack_patterns: true,
1582 semantic_threshold: 0.7,
1583 threat_score_threshold: 1.0,
1584 binary_min_run_length: 16,
1585 max_scan_values: 512,
1586 max_scan_chars: 65_536,
1587 max_json_depth: 32,
1588 }
1589 }
1590
1591 #[test]
1592 fn an_unterminated_bracket_entry_keeps_its_port_text() {
1593 assert_eq!(strip_forwarded_entry_port("[::1"), "[::1");
1594 }
1595
1596 #[test]
1597 fn a_chain_of_only_empty_tokens_is_not_an_address_chain() {
1598 assert!(!value_looks_like_address_chain(" , ,, "));
1599 }
1600
1601 #[test]
1602 fn empty_header_values_never_scan() {
1603 let exclusions = ResolvedExclusions {
1604 excluded_params: std::collections::HashSet::new(),
1605 excluded_headers: std::collections::HashSet::new(),
1606 excluded_body_fields: std::collections::HashSet::new(),
1607 semantic_headers: std::collections::HashSet::new(),
1608 enabled_categories: None,
1609 scan_body: false,
1610 max_scan_seconds: None,
1611 };
1612 let headers = vec![
1613 ("x-empty".to_owned(), String::new()),
1614 ("x-attack".to_owned(), "10.0.0.5, 127.0.0.1".to_owned()),
1615 ];
1616 let verdict = scan_request(
1617 &RequestSurfaces {
1618 url_path: None,
1619 query_params: &[],
1620 headers: &headers,
1621 content_type: "",
1622 raw_body: "",
1623 },
1624 &exclusions,
1625 &config(),
1626 );
1627 assert!(verdict.is_threat);
1629 assert_eq!(verdict.categories, vec!["ssrf".to_owned()]);
1630 }
1631
1632 #[test]
1633 fn an_excluded_address_header_suppresses_its_ssrf_category() {
1634 let mut excluded_headers = std::collections::HashSet::new();
1635 excluded_headers.insert("x-forwarded-for".to_owned());
1636 let exclusions = ResolvedExclusions {
1637 excluded_params: std::collections::HashSet::new(),
1638 excluded_headers,
1639 excluded_body_fields: std::collections::HashSet::new(),
1640 semantic_headers: std::collections::HashSet::new(),
1641 enabled_categories: None,
1642 scan_body: false,
1643 max_scan_seconds: None,
1644 };
1645 let headers = vec![(
1646 "x-forwarded-for".to_owned(),
1647 "10.0.0.5, 127.0.0.1".to_owned(),
1648 )];
1649 let verdict = scan_request(
1650 &RequestSurfaces {
1651 url_path: None,
1652 query_params: &[],
1653 headers: &headers,
1654 content_type: "",
1655 raw_body: "",
1656 },
1657 &exclusions,
1658 &config(),
1659 );
1660 assert!(!verdict.is_threat);
1663 assert!(verdict.categories.is_empty());
1664 }
1665
1666 #[test]
1667 fn a_semantic_threat_reports_its_attack_type_beside_the_regex_categories() {
1668 let content = format!(
1672 "select union insert update delete drop from where order group having concat \
1673 substring database table column (1 OR 1=1) {}",
1674 "A".repeat(120)
1675 );
1676 let verdict = scan_request(
1677 &RequestSurfaces {
1678 url_path: None,
1679 query_params: &[],
1680 headers: &[],
1681 content_type: "text/plain",
1682 raw_body: &content,
1683 },
1684 &ResolvedExclusions {
1685 excluded_params: std::collections::HashSet::new(),
1686 excluded_headers: std::collections::HashSet::new(),
1687 excluded_body_fields: std::collections::HashSet::new(),
1688 semantic_headers: std::collections::HashSet::new(),
1689 enabled_categories: None,
1690 scan_body: true,
1691 max_scan_seconds: None,
1692 },
1693 &config(),
1694 );
1695 assert!(verdict.is_threat);
1696 assert_eq!(
1697 verdict.categories,
1698 vec!["sql".to_owned(), "sqli".to_owned()]
1699 );
1700 }
1701}