1use crate::error::{BenchError, Result};
40use crate::security_payloads::{
41 PayloadLocation as SecurityPayloadLocation, SecurityCategory, SecurityPayload,
42};
43use glob::glob;
44use serde::{Deserialize, Serialize};
45use std::collections::HashMap;
46use std::path::Path;
47
48#[derive(Debug, Clone, Deserialize, Serialize)]
50pub struct WafBenchMeta {
51 pub author: Option<String>,
53 pub description: Option<String>,
55 #[serde(default = "default_enabled")]
57 pub enabled: bool,
58 pub name: Option<String>,
60}
61
62fn default_enabled() -> bool {
63 true
64}
65
66#[derive(Debug, Clone, Deserialize, Serialize)]
68pub struct WafBenchTest {
69 pub desc: Option<String>,
71 pub test_title: String,
73 #[serde(default)]
75 pub stages: Vec<WafBenchStage>,
76}
77
78#[derive(Debug, Clone, Deserialize, Serialize)]
81pub struct WafBenchStage {
82 pub input: Option<WafBenchInput>,
84 pub output: Option<WafBenchOutput>,
86 pub stage: Option<WafBenchStageInner>,
88}
89
90#[derive(Debug, Clone, Deserialize, Serialize)]
92pub struct WafBenchStageInner {
93 pub input: WafBenchInput,
95 pub output: Option<WafBenchOutput>,
97}
98
99impl WafBenchStage {
100 pub fn get_input(&self) -> Option<&WafBenchInput> {
102 if let Some(stage) = &self.stage {
104 Some(&stage.input)
105 } else {
106 self.input.as_ref()
107 }
108 }
109
110 pub fn get_output(&self) -> Option<&WafBenchOutput> {
112 if let Some(stage) = &self.stage {
114 stage.output.as_ref()
115 } else {
116 self.output.as_ref()
117 }
118 }
119}
120
121#[derive(Debug, Clone, Deserialize, Serialize)]
123pub struct WafBenchInput {
124 pub dest_addr: Option<String>,
126 #[serde(default)]
128 pub headers: HashMap<String, String>,
129 #[serde(default = "default_method")]
131 pub method: String,
132 #[serde(default = "default_port")]
134 pub port: u16,
135 pub uri: Option<String>,
137 pub data: Option<String>,
139 pub version: Option<String>,
141}
142
143fn default_method() -> String {
144 "GET".to_string()
145}
146
147fn default_port() -> u16 {
148 80
149}
150
151#[derive(Debug, Clone, Deserialize, Serialize)]
153pub struct WafBenchOutput {
154 #[serde(default)]
156 pub status: Vec<u16>,
157 #[serde(default)]
159 pub response_headers: HashMap<String, String>,
160 #[serde(default, deserialize_with = "deserialize_string_or_vec")]
162 pub log_contains: Vec<String>,
163 #[serde(default, deserialize_with = "deserialize_string_or_vec")]
165 pub no_log_contains: Vec<String>,
166}
167
168fn deserialize_string_or_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
170where
171 D: serde::Deserializer<'de>,
172{
173 use serde::de::{self, Visitor};
174
175 struct StringOrVec;
176
177 impl<'de> Visitor<'de> for StringOrVec {
178 type Value = Vec<String>;
179
180 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
181 formatter.write_str("string or array of strings")
182 }
183
184 fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
185 where
186 E: de::Error,
187 {
188 Ok(vec![value.to_string()])
189 }
190
191 fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E>
192 where
193 E: de::Error,
194 {
195 Ok(vec![value])
196 }
197
198 fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
199 where
200 A: de::SeqAccess<'de>,
201 {
202 let mut vec = Vec::new();
203 while let Some(value) = seq.next_element::<String>()? {
204 vec.push(value);
205 }
206 Ok(vec)
207 }
208
209 fn visit_none<E>(self) -> std::result::Result<Self::Value, E>
210 where
211 E: de::Error,
212 {
213 Ok(Vec::new())
214 }
215
216 fn visit_unit<E>(self) -> std::result::Result<Self::Value, E>
217 where
218 E: de::Error,
219 {
220 Ok(Vec::new())
221 }
222 }
223
224 deserializer.deserialize_any(StringOrVec)
225}
226
227#[derive(Debug, Clone, Deserialize, Serialize)]
248pub struct SimpleTrafficCase {
249 pub title: Option<String>,
251 pub request: SimpleTrafficRequest,
253 pub expected: Option<serde_yaml::Value>,
255 #[serde(default)]
261 pub omit_rule: Option<bool>,
262}
263
264#[derive(Debug, Clone, Deserialize, Serialize)]
266pub struct SimpleTrafficRequest {
267 #[serde(default = "default_method")]
269 pub method: String,
270 pub uri: Option<String>,
272 #[serde(default)]
274 pub headers: HashMap<String, String>,
275 pub body: Option<String>,
278}
279
280fn extract_expected_statuses(value: Option<&serde_yaml::Value>) -> Vec<u16> {
287 fn as_status(v: &serde_yaml::Value) -> Option<u16> {
288 v.as_u64().and_then(|n| u16::try_from(n).ok())
289 }
290
291 let Some(value) = value else {
292 return Vec::new();
293 };
294
295 if let Some(one) = as_status(value) {
296 return vec![one];
297 }
298 if let Some(seq) = value.as_sequence() {
299 return seq.iter().filter_map(as_status).collect();
300 }
301 if let Some(map) = value.as_mapping() {
302 if let Some(status) = map.get(serde_yaml::Value::String("status".to_string())) {
303 if let Some(one) = as_status(status) {
304 return vec![one];
305 }
306 if let Some(seq) = status.as_sequence() {
307 return seq.iter().filter_map(as_status).collect();
308 }
309 }
310 }
311 Vec::new()
312}
313
314impl SimpleTrafficCase {
315 fn into_wafbench_test(self, index: usize) -> WafBenchTest {
319 let statuses = extract_expected_statuses(self.expected.as_ref());
320 let title = self.title.unwrap_or_else(|| format!("case-{}", index + 1));
321
322 WafBenchTest {
323 desc: Some(title.clone()),
324 test_title: title,
325 stages: vec![WafBenchStage {
326 input: Some(WafBenchInput {
327 dest_addr: None,
328 headers: self.request.headers,
329 method: self.request.method,
330 port: default_port(),
331 uri: self.request.uri,
332 data: self.request.body,
333 version: None,
334 }),
335 output: Some(WafBenchOutput {
336 status: statuses,
337 response_headers: HashMap::new(),
338 log_contains: Vec::new(),
339 no_log_contains: Vec::new(),
340 }),
341 stage: None,
342 }],
343 }
344 }
345}
346
347pub fn traffic_cases_to_templates(
373 cases: &[WafBenchTestCase],
374) -> Vec<crate::request_gen::RequestTemplate> {
375 use crate::request_gen::RequestTemplate;
376 use crate::spec_parser::ApiOperation;
377
378 let mut templates = Vec::new();
379
380 for case in cases {
381 let Some(uri) = case
385 .payloads
386 .iter()
387 .find(|p| p.location == PayloadLocation::Uri)
388 .map(|p| p.value.clone())
389 .filter(|u| !u.is_empty())
390 else {
391 continue;
392 };
393
394 let headers: HashMap<String, String> = case
395 .payloads
396 .iter()
397 .filter(|p| p.location == PayloadLocation::Header)
398 .filter_map(|p| p.header_name.clone().map(|n| (n, p.value.clone())))
399 .collect();
400
401 let body = case.payloads.iter().find(|p| p.location == PayloadLocation::Body).map(|p| {
402 serde_json::from_str::<serde_json::Value>(&p.value)
407 .unwrap_or_else(|_| serde_json::Value::String(p.value.clone()))
408 });
409
410 templates.push(RequestTemplate {
411 operation: ApiOperation {
412 method: case.method.clone(),
413 path: uri,
414 operation: Default::default(),
415 operation_id: Some(case.description.clone()),
416 },
417 path_params: HashMap::new(),
418 query_params: HashMap::new(),
423 headers,
424 body,
425 });
426 }
427
428 templates
429}
430
431pub fn parse_traffic_file(content: &str, source: &str) -> Result<WafBenchFile> {
438 match serde_yaml::from_str::<WafBenchFile>(content) {
439 Ok(file) => Ok(file),
440 Err(wafbench_err) => match serde_yaml::from_str::<Vec<SimpleTrafficCase>>(content) {
441 Ok(cases) => {
442 let parsed = cases.len();
443 let (omitted, cases): (Vec<_>, Vec<_>) =
444 cases.into_iter().partition(|c| c.omit_rule == Some(true));
445
446 for case in &omitted {
447 tracing::warn!(
448 "{source}: skipping `{}` -- it sets `omit_rule: true`, which asks for the \
449 request to be sent with the rule under test disabled. mockforge sends \
450 traffic; it cannot toggle your WAF's rules. Sending it anyway would put \
451 a byte-identical request on the wire as its non-omitted twin while \
452 asserting the opposite status, so exactly one of the pair would always \
453 fail regardless of whether the rule works. Run the same file against a \
454 WAF configuration with that rule disabled to get the baseline.",
455 case.title.as_deref().unwrap_or("<untitled>")
456 );
457 }
458
459 tracing::info!(
460 "{source}: parsed {parsed} case(s) in the simple request/expected format{}",
461 if omitted.is_empty() {
462 String::new()
463 } else {
464 format!(
465 "; {} skipped for `omit_rule: true`, {} will be sent",
466 omitted.len(),
467 cases.len()
468 )
469 }
470 );
471 Ok(WafBenchFile {
472 meta: WafBenchMeta {
473 author: None,
474 description: Some(format!("simple traffic file: {source}")),
475 enabled: true,
476 name: Some(source.to_string()),
477 },
478 tests: cases
479 .into_iter()
480 .enumerate()
481 .map(|(i, c)| c.into_wafbench_test(i))
482 .collect(),
483 omitted_count: omitted.len(),
486 })
487 }
488 Err(simple_err) => Err(BenchError::Other(format!(
489 "Failed to parse traffic file {source}. Two shapes are accepted:\n \
490 (1) WAFBench document -- a `meta:` mapping plus a `tests:` list. Parse error: {wafbench_err}\n \
491 (2) simple list -- `- title: .. / request: {{method, uri, headers, body}} / expected: 403`. Parse error: {simple_err}"
492 ))),
493 },
494 }
495}
496
497#[derive(Debug, Clone, Deserialize, Serialize)]
499pub struct WafBenchFile {
500 pub meta: WafBenchMeta,
502 #[serde(default)]
504 pub tests: Vec<WafBenchTest>,
505 #[serde(default, skip)]
508 pub omitted_count: usize,
509}
510
511#[derive(Debug, Clone)]
513pub struct WafBenchTestCase {
514 pub test_id: String,
516 pub description: String,
518 pub rule_id: String,
520 pub category: SecurityCategory,
522 pub method: String,
524 pub payloads: Vec<WafBenchPayload>,
526 pub expects_block: bool,
528}
529
530#[derive(Debug, Clone)]
532pub struct WafBenchPayload {
533 pub location: PayloadLocation,
535 pub value: String,
537 pub header_name: Option<String>,
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
543pub enum PayloadLocation {
544 Uri,
546 Header,
548 Body,
550}
551
552impl std::fmt::Display for PayloadLocation {
553 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
554 match self {
555 Self::Uri => write!(f, "uri"),
556 Self::Header => write!(f, "header"),
557 Self::Body => write!(f, "body"),
558 }
559 }
560}
561
562pub struct WafBenchLoader {
564 test_cases: Vec<WafBenchTestCase>,
566 stats: WafBenchStats,
568}
569
570#[derive(Debug, Clone, Default)]
573pub struct TrafficFileSummary {
574 pub file: String,
576 pub sent: usize,
578 pub attack: usize,
580 pub normal: usize,
582 pub omitted: usize,
584 pub other: usize,
586}
587
588#[derive(Debug, Clone, Default)]
590pub struct WafBenchStats {
591 pub files_processed: usize,
593 pub test_cases_loaded: usize,
595 pub payloads_extracted: usize,
597 pub by_category: HashMap<SecurityCategory, usize>,
599 pub parse_errors: Vec<String>,
601 pub per_file: Vec<TrafficFileSummary>,
603}
604
605pub fn summarize_traffic_file(file: &WafBenchFile, source: &str) -> TrafficFileSummary {
608 let mut attack = 0;
609 let mut normal = 0;
610 let mut other = 0;
611 for test in &file.tests {
612 let mut has_403 = false;
613 let mut has_200 = false;
614 for stage in &test.stages {
615 if let Some(output) = stage.get_output() {
616 has_403 |= output.status.contains(&403);
617 has_200 |= output.status.contains(&200);
618 }
619 }
620 if has_403 {
621 attack += 1;
622 } else if has_200 {
623 normal += 1;
624 } else {
625 other += 1;
626 }
627 }
628 TrafficFileSummary {
629 file: source.to_string(),
630 sent: file.tests.len(),
631 attack,
632 normal,
633 omitted: file.omitted_count,
634 other,
635 }
636}
637
638impl WafBenchLoader {
639 pub fn new() -> Self {
641 Self {
642 test_cases: Vec::new(),
643 stats: WafBenchStats::default(),
644 }
645 }
646
647 pub fn load_from_pattern(&mut self, pattern: &str) -> Result<()> {
658 if !pattern.contains('*') && !pattern.contains('?') {
660 let path = Path::new(pattern);
661 if path.is_file() {
662 return self.load_file(path);
664 } else if path.is_dir() {
665 return self.load_from_directory(path);
666 } else {
667 return Err(BenchError::Other(format!(
668 "WAFBench path does not exist: {}",
669 pattern
670 )));
671 }
672 }
673
674 let entries = glob(pattern).map_err(|e| {
676 BenchError::Other(format!("Invalid WAFBench pattern '{}': {}", pattern, e))
677 })?;
678
679 for entry in entries {
680 match entry {
681 Ok(path) => {
682 if path.is_file()
683 && path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml")
684 {
685 if let Err(e) = self.load_file(&path) {
686 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
687 }
688 } else if path.is_dir() {
689 if let Err(e) = self.load_from_directory(&path) {
690 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
691 }
692 }
693 }
694 Err(e) => {
695 self.stats.parse_errors.push(format!("Glob error: {}", e));
696 }
697 }
698 }
699
700 Ok(())
701 }
702
703 pub fn load_from_directory(&mut self, dir: &Path) -> Result<()> {
705 if !dir.is_dir() {
706 return Err(BenchError::Other(format!(
707 "WAFBench path is not a directory: {}",
708 dir.display()
709 )));
710 }
711
712 self.load_directory_recursive(dir)?;
713 Ok(())
714 }
715
716 fn load_directory_recursive(&mut self, dir: &Path) -> Result<()> {
717 let entries = std::fs::read_dir(dir)
718 .map_err(|e| BenchError::Other(format!("Failed to read WAFBench directory: {}", e)))?;
719
720 for entry in entries.flatten() {
721 let path = entry.path();
722 if path.is_dir() {
723 self.load_directory_recursive(&path)?;
725 } else if path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml") {
726 if let Err(e) = self.load_file(&path) {
727 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
728 }
729 }
730 }
731
732 Ok(())
733 }
734
735 pub fn load_file(&mut self, path: &Path) -> Result<()> {
737 let content = std::fs::read_to_string(path).map_err(|e| {
738 BenchError::Other(format!("Failed to read WAFBench file {}: {}", path.display(), e))
739 })?;
740
741 let source = path.display().to_string();
742 let wafbench_file = parse_traffic_file(&content, &source)?;
743
744 if !wafbench_file.meta.enabled {
746 return Ok(());
747 }
748
749 self.stats.per_file.push(summarize_traffic_file(&wafbench_file, &source));
750 self.stats.files_processed += 1;
751
752 let category = self.detect_category(path, &wafbench_file.meta);
754
755 for test in wafbench_file.tests {
757 if let Some(test_case) = self.parse_test_case(&test, category) {
758 self.stats.payloads_extracted += test_case.payloads.len();
759 *self.stats.by_category.entry(category).or_insert(0) += 1;
760 self.test_cases.push(test_case);
761 self.stats.test_cases_loaded += 1;
762 }
763 }
764
765 Ok(())
766 }
767
768 fn detect_category(&self, path: &Path, _meta: &WafBenchMeta) -> SecurityCategory {
770 let path_str = path.to_string_lossy().to_uppercase();
771
772 if path_str.contains("XSS") || path_str.contains("941") {
773 SecurityCategory::Xss
774 } else if path_str.contains("SQLI") || path_str.contains("942") {
775 SecurityCategory::SqlInjection
776 } else if path_str.contains("RCE") || path_str.contains("932") {
777 SecurityCategory::CommandInjection
778 } else if path_str.contains("LFI") || path_str.contains("930") {
779 SecurityCategory::PathTraversal
780 } else if path_str.contains("LDAP") {
781 SecurityCategory::LdapInjection
782 } else if path_str.contains("XXE") || path_str.contains("XML") {
783 SecurityCategory::Xxe
784 } else if path_str.contains("TEMPLATE") || path_str.contains("SSTI") {
785 SecurityCategory::Ssti
786 } else {
787 SecurityCategory::Xss
789 }
790 }
791
792 fn parse_test_case(
794 &self,
795 test: &WafBenchTest,
796 category: SecurityCategory,
797 ) -> Option<WafBenchTestCase> {
798 let rule_id = test.test_title.split('-').next().unwrap_or(&test.test_title).to_string();
800
801 let mut payloads = Vec::new();
802 let mut method = "GET".to_string();
803 let mut expects_block = false;
804
805 for stage in &test.stages {
806 let Some(input) = stage.get_input() else {
808 continue;
809 };
810
811 method = input.method.clone();
812
813 if let Some(output) = stage.get_output() {
815 if output.status.contains(&403) {
816 expects_block = true;
817 }
818 }
819
820 if let Some(uri) = &input.uri {
825 if !uri.is_empty() {
826 payloads.push(WafBenchPayload {
827 location: PayloadLocation::Uri,
828 value: uri.clone(),
829 header_name: None,
830 });
831 }
832 }
833
834 for (header_name, header_value) in &input.headers {
836 if !header_value.is_empty() {
837 payloads.push(WafBenchPayload {
838 location: PayloadLocation::Header,
839 value: header_value.clone(),
840 header_name: Some(header_name.clone()),
841 });
842 }
843 }
844
845 if let Some(data) = &input.data {
847 if !data.is_empty() {
848 payloads.push(WafBenchPayload {
849 location: PayloadLocation::Body,
850 value: data.clone(),
851 header_name: None,
852 });
853 }
854 }
855 }
856
857 if payloads.is_empty() {
859 if let Some(stage) = test.stages.first() {
860 if let Some(input) = stage.get_input() {
861 if let Some(uri) = &input.uri {
862 payloads.push(WafBenchPayload {
863 location: PayloadLocation::Uri,
864 value: uri.clone(),
865 header_name: None,
866 });
867 }
868 }
869 }
870 }
871
872 if payloads.is_empty() {
873 return None;
874 }
875
876 let description = test.desc.clone().unwrap_or_else(|| format!("CRS Rule {} test", rule_id));
877
878 Some(WafBenchTestCase {
879 test_id: test.test_title.clone(),
880 description,
881 rule_id,
882 category,
883 method,
884 payloads,
885 expects_block,
886 })
887 }
888
889 #[cfg(test)]
891 fn looks_like_attack(&self, s: &str) -> bool {
892 let attack_patterns = [
894 "<script",
895 "javascript:",
896 "onerror=",
897 "onload=",
898 "onclick=",
899 "onfocus=",
900 "onmouseover=",
901 "eval(",
902 "alert(",
903 "document.",
904 "window.",
905 "'--",
906 "' OR ",
907 "' AND ",
908 "1=1",
909 "UNION SELECT",
910 "CONCAT(",
911 "CHAR(",
912 "../",
913 "..\\",
914 "/etc/passwd",
915 "cmd.exe",
916 "powershell",
917 "; ls",
918 "| cat",
919 "${",
920 "{{",
921 "<%",
922 "<?",
923 "<!ENTITY",
924 "SYSTEM \"",
925 ];
926
927 let lower = s.to_lowercase();
928 attack_patterns.iter().any(|p| lower.contains(&p.to_lowercase()))
929 }
930
931 pub fn test_cases(&self) -> &[WafBenchTestCase] {
933 &self.test_cases
934 }
935
936 pub fn stats(&self) -> &WafBenchStats {
938 &self.stats
939 }
940
941 fn decode_form_encoded_body(value: &str) -> String {
946 let plus_decoded = value.replace('+', " ");
948 let decoded = urlencoding::decode(&plus_decoded)
950 .map(|s| s.into_owned())
951 .unwrap_or(plus_decoded);
952 Self::strip_form_key(&decoded)
956 }
957
958 fn strip_form_key(value: &str) -> String {
963 if let Some(eq_pos) = value.find('=') {
966 let key = &value[..eq_pos];
967 if !key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
969 return value[eq_pos + 1..].to_string();
970 }
971 }
972 value.to_string()
973 }
974
975 fn ensure_form_encoded(value: &str) -> String {
984 value
985 .split('&')
986 .map(|pair| {
987 if let Some(eq_pos) = pair.find('=') {
988 let key = &pair[..eq_pos];
989 let val = &pair[eq_pos + 1..];
990 let key_plus = key.replace('+', " ");
992 let val_plus = val.replace('+', " ");
993 let decoded_key = urlencoding::decode(&key_plus).unwrap_or(key.into());
994 let decoded_val = urlencoding::decode(&val_plus).unwrap_or(val.into());
995 let enc_key = urlencoding::encode(&decoded_key).replace("%20", "+");
997 let enc_val = urlencoding::encode(&decoded_val).replace("%20", "+");
998 format!("{enc_key}={enc_val}")
999 } else {
1000 let pair_plus = pair.replace('+', " ");
1002 let decoded = urlencoding::decode(&pair_plus).unwrap_or(pair.into());
1003 urlencoding::encode(&decoded).replace("%20", "+").to_string()
1004 }
1005 })
1006 .collect::<Vec<_>>()
1007 .join("&")
1008 }
1009
1010 pub fn to_security_payloads(&self) -> Vec<SecurityPayload> {
1012 let mut payloads = Vec::new();
1013
1014 for test_case in &self.test_cases {
1015 let group_id = if test_case.payloads.len() > 1 {
1017 Some(test_case.test_id.clone())
1018 } else {
1019 None
1020 };
1021
1022 for payload in &test_case.payloads {
1023 let payload_str = match payload.location {
1025 PayloadLocation::Body => {
1026 Self::decode_form_encoded_body(&payload.value)
1028 }
1029 PayloadLocation::Uri => {
1030 self.extract_uri_payload(&payload.value)
1032 }
1033 PayloadLocation::Header => {
1034 payload.value.clone()
1036 }
1037 };
1038
1039 let location = match payload.location {
1041 PayloadLocation::Uri => SecurityPayloadLocation::Uri,
1042 PayloadLocation::Header => SecurityPayloadLocation::Header,
1043 PayloadLocation::Body => SecurityPayloadLocation::Body,
1044 };
1045
1046 let mut sec_payload = SecurityPayload::new(
1047 payload_str,
1048 test_case.category,
1049 format!(
1050 "[WAFBench {}] {} ({})",
1051 test_case.rule_id, test_case.description, payload.location
1052 ),
1053 )
1054 .high_risk()
1055 .with_location(location);
1056
1057 if let Some(header_name) = &payload.header_name {
1059 sec_payload = sec_payload.with_header_name(header_name.clone());
1060 }
1061
1062 if let Some(gid) = &group_id {
1064 sec_payload = sec_payload.with_group_id(gid.clone());
1065 }
1066
1067 if payload.location == PayloadLocation::Uri && !payload.value.contains('?') {
1070 sec_payload = sec_payload.with_inject_as_path();
1071 }
1072
1073 if payload.location == PayloadLocation::Body {
1076 sec_payload = sec_payload
1077 .with_form_encoded_body(Self::ensure_form_encoded(&payload.value));
1078 }
1079
1080 payloads.push(sec_payload);
1081 }
1082 }
1083
1084 payloads
1085 }
1086
1087 fn extract_uri_payload(&self, value: &str) -> String {
1095 if value.contains('?') {
1098 if let Some(query) = value.split('?').nth(1) {
1099 for param in query.split('&') {
1100 if let Some(val) = param.split('=').nth(1) {
1101 let decoded = urlencoding::decode(val).unwrap_or_else(|_| val.into());
1102 if !decoded.is_empty() {
1103 return decoded.to_string();
1104 }
1105 }
1106 }
1107 }
1108 }
1109
1110 let decoded = urlencoding::decode(value)
1113 .map(|s| s.into_owned())
1114 .unwrap_or_else(|_| value.to_string());
1115 let trimmed = decoded.trim_start_matches('/');
1116 if trimmed.is_empty() {
1117 return decoded;
1119 }
1120 trimmed.to_string()
1121 }
1122}
1123
1124impl Default for WafBenchLoader {
1125 fn default() -> Self {
1126 Self::new()
1127 }
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132 use super::*;
1133
1134 #[test]
1135 fn test_parse_wafbench_yaml() {
1136 let yaml = r#"
1137meta:
1138 author: test
1139 description: Test XSS rules
1140 enabled: true
1141 name: test.yaml
1142
1143tests:
1144 - desc: "XSS in URI parameter"
1145 test_title: "941100-1"
1146 stages:
1147 - input:
1148 dest_addr: "127.0.0.1"
1149 headers:
1150 Host: "localhost"
1151 User-Agent: "Mozilla/5.0"
1152 method: "GET"
1153 port: 80
1154 uri: "/test?param=<script>alert(1)</script>"
1155 output:
1156 status: [403]
1157"#;
1158
1159 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1160 assert!(file.meta.enabled);
1161 assert_eq!(file.tests.len(), 1);
1162 assert_eq!(file.tests[0].test_title, "941100-1");
1163 }
1164
1165 #[test]
1166 fn test_detect_category() {
1167 let loader = WafBenchLoader::new();
1168 let meta = WafBenchMeta {
1169 author: None,
1170 description: None,
1171 enabled: true,
1172 name: None,
1173 };
1174
1175 assert_eq!(
1176 loader.detect_category(Path::new("/wafbench/REQUEST-941-XSS/test.yaml"), &meta),
1177 SecurityCategory::Xss
1178 );
1179
1180 assert_eq!(
1181 loader.detect_category(Path::new("/wafbench/REQUEST-942-SQLI/test.yaml"), &meta),
1182 SecurityCategory::SqlInjection
1183 );
1184 }
1185
1186 #[test]
1187 fn test_looks_like_attack() {
1188 let loader = WafBenchLoader::new();
1189
1190 assert!(loader.looks_like_attack("<script>alert(1)</script>"));
1191 assert!(loader.looks_like_attack("' OR '1'='1"));
1192 assert!(loader.looks_like_attack("../../../etc/passwd"));
1193 assert!(loader.looks_like_attack("; ls -la"));
1194 assert!(!loader.looks_like_attack("normal text"));
1195 assert!(!loader.looks_like_attack("hello world"));
1196 }
1197
1198 #[test]
1199 fn test_extract_uri_payload_with_query_params() {
1200 let loader = WafBenchLoader::new();
1201
1202 let uri = "/test?param=%3Cscript%3Ealert(1)%3C/script%3E";
1204 let payload = loader.extract_uri_payload(uri);
1205 assert_eq!(payload, "<script>alert(1)</script>");
1206 }
1207
1208 #[test]
1209 fn test_extract_uri_payload_path_only() {
1210 let loader = WafBenchLoader::new();
1211
1212 let uri = "/1234%20OR%201=1";
1214 let payload = loader.extract_uri_payload(uri);
1215 assert_eq!(payload, "1234 OR 1=1");
1216
1217 let uri2 = "/foo')waitfor%20delay'5%3a0%3a20'--";
1219 let payload2 = loader.extract_uri_payload(uri2);
1220 assert_eq!(payload2, "foo')waitfor delay'5:0:20'--");
1221
1222 let uri3 = "/";
1224 let payload3 = loader.extract_uri_payload(uri3);
1225 assert_eq!(payload3, "/");
1226 }
1227
1228 #[test]
1229 fn test_group_id_assigned_for_multi_part_test_cases() {
1230 let yaml = r#"
1231meta:
1232 author: test
1233 description: Multi-part test
1234 enabled: true
1235 name: test.yaml
1236
1237tests:
1238 - desc: "Multi-part attack with URI and header"
1239 test_title: "942290-1"
1240 stages:
1241 - input:
1242 dest_addr: "127.0.0.1"
1243 headers:
1244 Host: "localhost"
1245 User-Agent: "ModSecurity CRS 3 Tests"
1246 method: "GET"
1247 port: 80
1248 uri: "/test?param=attack"
1249 output:
1250 status: [403]
1251"#;
1252
1253 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1254 let mut loader = WafBenchLoader::new();
1255 loader.stats.files_processed += 1;
1256
1257 let category = SecurityCategory::SqlInjection;
1258 for test in &file.tests {
1259 if let Some(test_case) = loader.parse_test_case(test, category) {
1260 loader.test_cases.push(test_case);
1261 }
1262 }
1263
1264 let payloads = loader.to_security_payloads();
1265 assert!(payloads.len() >= 2, "Should have at least 2 payloads");
1267 let group_ids: Vec<_> = payloads.iter().map(|p| p.group_id.clone()).collect();
1268 assert!(
1269 group_ids.iter().all(|g| g.is_some()),
1270 "All payloads in multi-part test should have group_id"
1271 );
1272 assert!(
1273 group_ids.iter().all(|g| g.as_deref() == Some("942290-1")),
1274 "All payloads should share the same group_id"
1275 );
1276 }
1277
1278 #[test]
1279 fn test_single_payload_no_group_id() {
1280 let yaml = r#"
1281meta:
1282 author: test
1283 description: Single payload test
1284 enabled: true
1285 name: test.yaml
1286
1287tests:
1288 - desc: "Simple XSS"
1289 test_title: "941100-1"
1290 stages:
1291 - input:
1292 dest_addr: "127.0.0.1"
1293 headers: {}
1294 method: "GET"
1295 port: 80
1296 uri: "/test?param=<script>alert(1)</script>"
1297 output:
1298 status: [403]
1299"#;
1300
1301 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1302 let mut loader = WafBenchLoader::new();
1303 loader.stats.files_processed += 1;
1304
1305 let category = SecurityCategory::Xss;
1306 for test in &file.tests {
1307 if let Some(test_case) = loader.parse_test_case(test, category) {
1308 loader.test_cases.push(test_case);
1309 }
1310 }
1311
1312 let payloads = loader.to_security_payloads();
1313 assert_eq!(payloads.len(), 1, "Should have exactly 1 payload");
1314 assert!(payloads[0].group_id.is_none(), "Single-payload test should NOT have group_id");
1315 }
1316
1317 #[test]
1318 fn test_body_payload_form_url_decoded() {
1319 let yaml = r#"
1320meta:
1321 author: test
1322 description: Body payload test
1323 enabled: true
1324 name: test.yaml
1325
1326tests:
1327 - desc: "SQL injection in body"
1328 test_title: "942240-1"
1329 stages:
1330 - stage:
1331 input:
1332 dest_addr: 127.0.0.1
1333 headers:
1334 Host: localhost
1335 method: POST
1336 port: 80
1337 uri: "/"
1338 data: "%22+WAITFOR+DELAY+%270%3A0%3A5%27"
1339 output:
1340 log_contains: id "942240"
1341"#;
1342
1343 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1344 let mut loader = WafBenchLoader::new();
1345 loader.stats.files_processed += 1;
1346
1347 let category = SecurityCategory::SqlInjection;
1348 for test in &file.tests {
1349 if let Some(test_case) = loader.parse_test_case(test, category) {
1350 loader.test_cases.push(test_case);
1351 }
1352 }
1353
1354 let payloads = loader.to_security_payloads();
1355 let body_payload = payloads
1357 .iter()
1358 .find(|p| p.location == SecurityPayloadLocation::Body)
1359 .expect("Should have a body payload");
1360
1361 assert!(
1363 body_payload.payload.contains('"'),
1364 "Body payload should have decoded %22 to double-quote: {}",
1365 body_payload.payload
1366 );
1367 assert!(
1368 body_payload.payload.contains(' '),
1369 "Body payload should have decoded + to space: {}",
1370 body_payload.payload
1371 );
1372 assert!(
1373 !body_payload.payload.contains("%22"),
1374 "Body payload should NOT contain literal %22: {}",
1375 body_payload.payload
1376 );
1377 }
1378
1379 #[test]
1380 fn test_decode_form_encoded_body() {
1381 assert_eq!(
1383 WafBenchLoader::decode_form_encoded_body("%22+WAITFOR+DELAY+%27%0A"),
1384 "\" WAITFOR DELAY '\n"
1385 );
1386 assert_eq!(WafBenchLoader::decode_form_encoded_body("normal+text"), "normal text");
1387 assert_eq!(
1388 WafBenchLoader::decode_form_encoded_body("no+encoding+needed"),
1389 "no encoding needed"
1390 );
1391 assert_eq!(
1393 WafBenchLoader::decode_form_encoded_body("var%3D%3B%3Bdd+foo+bar"),
1394 ";;dd foo bar"
1395 );
1396 assert_eq!(WafBenchLoader::decode_form_encoded_body("pay%3Dexec+%28%40%0A"), "exec (@\n");
1398 assert_eq!(WafBenchLoader::decode_form_encoded_body("%22+WAITFOR"), "\" WAITFOR");
1400 }
1401
1402 #[test]
1403 fn test_strip_form_key() {
1404 assert_eq!(WafBenchLoader::strip_form_key("var=;;dd foo bar"), ";;dd foo bar");
1406 assert_eq!(WafBenchLoader::strip_form_key("pay=exec (@\n"), "exec (@\n");
1407 assert_eq!(WafBenchLoader::strip_form_key("pay=DECLARE/**/@x\n"), "DECLARE/**/@x\n");
1408 assert_eq!(WafBenchLoader::strip_form_key("\" WAITFOR DELAY '\n"), "\" WAITFOR DELAY '\n");
1410 assert_eq!(WafBenchLoader::strip_form_key("' OR 1=1"), "' OR 1=1");
1412 assert_eq!(WafBenchLoader::strip_form_key(""), "");
1414 assert_eq!(WafBenchLoader::strip_form_key("var="), "");
1416 }
1417
1418 #[test]
1419 fn test_ensure_form_encoded() {
1420 assert_eq!(
1422 WafBenchLoader::ensure_form_encoded("var=%3B%3Bdd+foo+bar"),
1423 "var=%3B%3Bdd+foo+bar"
1424 );
1425 assert_eq!(WafBenchLoader::ensure_form_encoded("var=;;dd foo bar"), "var=%3B%3Bdd+foo+bar");
1427 assert_eq!(
1429 WafBenchLoader::ensure_form_encoded("var=-------------------&var2=whatever"),
1430 "var=-------------------&var2=whatever"
1431 );
1432 assert_eq!(
1434 WafBenchLoader::ensure_form_encoded("key=%22value%22&other=test+data"),
1435 "key=%22value%22&other=test+data"
1436 );
1437 assert_eq!(
1439 WafBenchLoader::ensure_form_encoded("key=\"value\"&other=test data"),
1440 "key=%22value%22&other=test+data"
1441 );
1442 assert_eq!(WafBenchLoader::ensure_form_encoded("plain text"), "plain+text");
1444 assert_eq!(WafBenchLoader::ensure_form_encoded(""), "");
1446 }
1447
1448 #[test]
1449 fn test_uri_path_only_gets_inject_as_path() {
1450 let yaml = r#"
1451meta:
1452 author: test
1453 description: Path injection test
1454 enabled: true
1455 name: test.yaml
1456
1457tests:
1458 - desc: "Path-based SQL injection"
1459 test_title: "942101-1"
1460 stages:
1461 - stage:
1462 input:
1463 dest_addr: 127.0.0.1
1464 headers:
1465 Host: localhost
1466 method: POST
1467 port: 80
1468 uri: "/1234%20OR%201=1"
1469 output:
1470 log_contains: id "942101"
1471"#;
1472
1473 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1474 let mut loader = WafBenchLoader::new();
1475 loader.stats.files_processed += 1;
1476
1477 let category = SecurityCategory::SqlInjection;
1478 for test in &file.tests {
1479 if let Some(test_case) = loader.parse_test_case(test, category) {
1480 loader.test_cases.push(test_case);
1481 }
1482 }
1483
1484 let payloads = loader.to_security_payloads();
1485 let uri_payload = payloads
1486 .iter()
1487 .find(|p| p.location == SecurityPayloadLocation::Uri)
1488 .expect("Should have URI payload");
1489
1490 assert_eq!(
1491 uri_payload.inject_as_path,
1492 Some(true),
1493 "Path-only URI should have inject_as_path=true"
1494 );
1495 }
1496
1497 #[test]
1498 fn test_uri_with_query_no_inject_as_path() {
1499 let yaml = r#"
1500meta:
1501 author: test
1502 description: Query param test
1503 enabled: true
1504 name: test.yaml
1505
1506tests:
1507 - desc: "Query-param SQL injection"
1508 test_title: "942100-1"
1509 stages:
1510 - stage:
1511 input:
1512 dest_addr: 127.0.0.1
1513 headers: {}
1514 method: GET
1515 port: 80
1516 uri: "/test?param=1+OR+1%3D1"
1517 output:
1518 log_contains: id "942100"
1519"#;
1520
1521 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1522 let mut loader = WafBenchLoader::new();
1523 loader.stats.files_processed += 1;
1524
1525 let category = SecurityCategory::SqlInjection;
1526 for test in &file.tests {
1527 if let Some(test_case) = loader.parse_test_case(test, category) {
1528 loader.test_cases.push(test_case);
1529 }
1530 }
1531
1532 let payloads = loader.to_security_payloads();
1533 let uri_payload = payloads
1534 .iter()
1535 .find(|p| p.location == SecurityPayloadLocation::Uri)
1536 .expect("Should have URI payload");
1537
1538 assert!(
1539 uri_payload.inject_as_path.is_none(),
1540 "URI with query params should NOT have inject_as_path"
1541 );
1542 }
1543
1544 #[test]
1545 fn test_body_payload_gets_form_encoded_body() {
1546 let yaml = r#"
1547meta:
1548 author: test
1549 description: Form body test
1550 enabled: true
1551 name: test.yaml
1552
1553tests:
1554 - desc: "Form-encoded body attack"
1555 test_title: "942432-1"
1556 stages:
1557 - stage:
1558 input:
1559 dest_addr: 127.0.0.1
1560 headers:
1561 Host: localhost
1562 method: POST
1563 port: 80
1564 uri: "/"
1565 data: "var=%3B%3Bdd+foo+bar"
1566 output:
1567 log_contains: id "942432"
1568"#;
1569
1570 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1571 let mut loader = WafBenchLoader::new();
1572 loader.stats.files_processed += 1;
1573
1574 let category = SecurityCategory::SqlInjection;
1575 for test in &file.tests {
1576 if let Some(test_case) = loader.parse_test_case(test, category) {
1577 loader.test_cases.push(test_case);
1578 }
1579 }
1580
1581 let payloads = loader.to_security_payloads();
1582 let body_payload = payloads
1583 .iter()
1584 .find(|p| p.location == SecurityPayloadLocation::Body)
1585 .expect("Should have body payload");
1586
1587 assert!(
1588 body_payload.form_encoded_body.is_some(),
1589 "Body payload should have form_encoded_body set"
1590 );
1591 assert_eq!(
1593 body_payload.form_encoded_body.as_deref().unwrap(),
1594 "var=%3B%3Bdd+foo+bar",
1595 "form_encoded_body should be properly URL-encoded"
1596 );
1597 }
1598
1599 #[test]
1600 fn test_body_payload_decoded_yaml_gets_encoded() {
1601 let yaml = r#"
1603meta:
1604 author: test
1605 description: Form body test (decoded)
1606 enabled: true
1607 name: test.yaml
1608
1609tests:
1610 - desc: "Form-encoded body attack (decoded)"
1611 test_title: "942432-2"
1612 stages:
1613 - stage:
1614 input:
1615 dest_addr: 127.0.0.1
1616 headers:
1617 Host: localhost
1618 method: POST
1619 port: 80
1620 uri: "/"
1621 data: "var=;;dd foo bar"
1622 output:
1623 log_contains: id "942432"
1624"#;
1625
1626 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1627 let mut loader = WafBenchLoader::new();
1628 loader.stats.files_processed += 1;
1629
1630 let category = SecurityCategory::SqlInjection;
1631 for test in &file.tests {
1632 if let Some(test_case) = loader.parse_test_case(test, category) {
1633 loader.test_cases.push(test_case);
1634 }
1635 }
1636
1637 let payloads = loader.to_security_payloads();
1638 let body_payload = payloads
1639 .iter()
1640 .find(|p| p.location == SecurityPayloadLocation::Body)
1641 .expect("Should have body payload");
1642
1643 assert!(
1644 body_payload.form_encoded_body.is_some(),
1645 "Body payload should have form_encoded_body set"
1646 );
1647 let encoded = body_payload.form_encoded_body.as_deref().unwrap();
1649 assert!(
1650 encoded.contains("%3B%3B") || encoded.contains("%3b%3b"),
1651 "Semicolons must be URL-encoded: {encoded}"
1652 );
1653 assert!(!encoded.contains(' '), "Spaces must be encoded as + in form body: {encoded}");
1654 assert!(encoded.starts_with("var="), "Form key must be preserved: {encoded}");
1655 }
1656
1657 #[test]
1658 fn test_parse_crs_v33_format() {
1659 let yaml = r#"
1661meta:
1662 author: "Christian Folini"
1663 description: Various SQL injection tests
1664 enabled: true
1665 name: 942100.yaml
1666
1667tests:
1668 - test_title: 942100-1
1669 desc: "Simple SQL Injection"
1670 stages:
1671 - stage:
1672 input:
1673 dest_addr: 127.0.0.1
1674 headers:
1675 Host: localhost
1676 method: POST
1677 port: 80
1678 uri: "/"
1679 data: "var=1234 OR 1=1"
1680 version: HTTP/1.0
1681 output:
1682 log_contains: id "942100"
1683"#;
1684
1685 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1686 assert!(file.meta.enabled);
1687 assert_eq!(file.tests.len(), 1);
1688 assert_eq!(file.tests[0].test_title, "942100-1");
1689
1690 let stage = &file.tests[0].stages[0];
1692 let input = stage.get_input().expect("Should have input");
1693 assert_eq!(input.method, "POST");
1694 assert_eq!(input.data.as_deref(), Some("var=1234 OR 1=1"));
1695 }
1696}
1697
1698#[cfg(test)]
1699mod simple_traffic_tests {
1700 use super::*;
1701
1702 const SRIKANTH_YAML: &str = r#"
1705- title: utf-7 charset blocked
1706 request:
1707 method: POST
1708 uri: /graphql
1709 headers:
1710 Content-Type: application/json; charset=utf-7
1711 body: '+AHsAIgBxAHUAZQByAHkAIgA6ACIAewBfAF8AdAB5AHAAZQBuAGEAbQBlAH0AIgB9-'
1712 expected: 403
1713- title: utf-8 charset allowed
1714 request:
1715 method: POST
1716 uri: /graphql
1717 headers:
1718 Content-Type: application/json; charset=utf-8
1719 body: '{"query":"{__typename}"}'
1720 expected: 200
1721"#;
1722
1723 #[test]
1724 fn simple_sequence_parses_and_maps_every_field() {
1725 let file = parse_traffic_file(SRIKANTH_YAML, "test_cases.yaml").expect("should parse");
1726 assert_eq!(file.tests.len(), 2);
1727 assert!(file.meta.enabled, "synthesised meta must not disable the file");
1728
1729 let first = &file.tests[0];
1730 assert_eq!(first.test_title, "utf-7 charset blocked");
1731 let stage = &first.stages[0];
1732 let input = stage.input.as_ref().expect("input mapped");
1733 assert_eq!(input.method, "POST");
1734 assert_eq!(input.uri.as_deref(), Some("/graphql"));
1735 assert_eq!(
1736 input.headers.get("Content-Type").map(String::as_str),
1737 Some("application/json; charset=utf-7")
1738 );
1739 assert!(
1740 input.data.as_deref().unwrap().starts_with("+AHsAIgBxAHUAZQ"),
1741 "`body` must map onto WAFBench's `data`, payload intact"
1742 );
1743 assert_eq!(stage.output.as_ref().unwrap().status, vec![403]);
1744 }
1745
1746 #[test]
1748 fn wafbench_document_still_parses() {
1749 let yaml = r#"
1750meta:
1751 author: crs
1752 enabled: true
1753 name: 941100
1754tests:
1755 - test_title: 941100-1
1756 stages:
1757 - stage:
1758 input:
1759 method: GET
1760 uri: /?x=<script>alert(1)</script>
1761 output:
1762 status: [403]
1763"#;
1764 let file = parse_traffic_file(yaml, "941100.yaml").expect("should parse");
1765 assert_eq!(file.tests.len(), 1);
1766 assert_eq!(file.meta.author.as_deref(), Some("crs"));
1767 }
1768
1769 #[test]
1771 fn expected_accepts_scalar_sequence_and_mapping() {
1772 let scalar: serde_yaml::Value = serde_yaml::from_str("403").unwrap();
1773 assert_eq!(extract_expected_statuses(Some(&scalar)), vec![403]);
1774
1775 let seq: serde_yaml::Value = serde_yaml::from_str("[403, 406]").unwrap();
1776 assert_eq!(extract_expected_statuses(Some(&seq)), vec![403, 406]);
1777
1778 let map: serde_yaml::Value = serde_yaml::from_str("status: 403").unwrap();
1779 assert_eq!(extract_expected_statuses(Some(&map)), vec![403]);
1780
1781 let map_seq: serde_yaml::Value = serde_yaml::from_str("status: [403, 406]").unwrap();
1782 assert_eq!(extract_expected_statuses(Some(&map_seq)), vec![403, 406]);
1783 }
1784
1785 #[test]
1788 fn unusable_expected_yields_no_statuses_not_an_error() {
1789 let junk: serde_yaml::Value = serde_yaml::from_str("'not a status'").unwrap();
1790 assert!(extract_expected_statuses(Some(&junk)).is_empty());
1791 assert!(extract_expected_statuses(None).is_empty());
1792
1793 let yaml = "- request:\n uri: /a\n";
1794 let file = parse_traffic_file(yaml, "x.yaml").expect("case without expected still parses");
1795 assert_eq!(file.tests.len(), 1);
1796 assert_eq!(
1797 file.tests[0].stages[0].input.as_ref().unwrap().method,
1798 "GET",
1799 "method defaults"
1800 );
1801 assert_eq!(file.tests[0].test_title, "case-1", "title is synthesised when absent");
1802 }
1803
1804 #[test]
1807 fn parse_error_names_both_accepted_shapes() {
1808 let err = parse_traffic_file("just a string", "bad.yaml").unwrap_err().to_string();
1809 assert!(err.contains("meta"), "must mention the WAFBench shape: {err}");
1810 assert!(err.contains("tests"), "must mention the WAFBench shape: {err}");
1811 assert!(err.contains("expected"), "must mention the simple shape: {err}");
1812 assert!(err.contains("request"), "must mention the simple shape: {err}");
1813 }
1814}
1815
1816#[cfg(test)]
1817mod verbatim_tests {
1818 use super::*;
1819
1820 fn load(yaml: &str) -> Vec<crate::request_gen::RequestTemplate> {
1821 let file = parse_traffic_file(yaml, "t.yaml").expect("parses");
1822 let mut loader = WafBenchLoader::new();
1823 for test in &file.tests {
1824 if let Some(case) = loader.parse_test_case(test, SecurityCategory::Xss) {
1825 loader.test_cases.push(case);
1826 }
1827 }
1828 traffic_cases_to_templates(loader.test_cases())
1829 }
1830
1831 #[test]
1836 fn verbatim_preserves_every_query_parameter() {
1837 let t = load(
1838 "- title: oauth open redirect\n request:\n method: GET\n uri: /oauth/authorize?response_type=totally-unsupported&redirect_uri=https%3A%2F%2Fevil.example%2Flanding&state=s1\n expected: 403\n",
1839 );
1840 assert_eq!(t.len(), 1);
1841 let path = t[0].generate_path();
1842 assert!(path.starts_with("/oauth/authorize"), "path must survive, got {path}");
1843 assert!(path.contains("redirect_uri="), "redirect_uri must reach the wire: {path}");
1844 assert!(path.contains("response_type="), "response_type must reach the wire: {path}");
1845 assert!(path.contains("state=s1"), "trailing params must survive: {path}");
1846 assert_eq!(t[0].operation.method, "GET");
1847 }
1848
1849 #[test]
1853 fn verbatim_does_not_reencode_the_uri() {
1854 let t = load(
1855 "- title: encoded\n request:\n method: GET\n uri: /a?u=https%3A%2F%2Fevil.example%2Fx&b=%2E%2E%2F\n",
1856 );
1857 let path = t[0].generate_path();
1858 assert!(path.contains("https%3A%2F%2Fevil.example%2Fx"), "must not decode: {path}");
1859 assert!(path.contains("%2E%2E%2F"), "must not normalise traversal: {path}");
1860 assert!(!path.contains("://evil.example"), "must not decode to a real URL: {path}");
1861 }
1862
1863 #[test]
1864 fn verbatim_carries_headers_and_body() {
1865 let t = load(
1866 "- title: post\n request:\n method: POST\n uri: /graphql\n headers:\n Content-Type: application/json; charset=utf-7\n body: '{\"query\":\"{__typename}\"}'\n",
1867 );
1868 assert_eq!(t[0].operation.method, "POST");
1869 assert_eq!(
1870 t[0].headers.get("Content-Type").map(String::as_str),
1871 Some("application/json; charset=utf-7"),
1872 "the charset is the attack; it must not be normalised"
1873 );
1874 assert!(t[0].body.is_some(), "body must be carried");
1875 }
1876
1877 #[test]
1879 fn verbatim_passes_through_non_json_body() {
1880 let t = load("- title: form\n request:\n method: POST\n uri: /f\n body: 'a=1&b=<script>'\n");
1881 let body = t[0].body.as_ref().expect("body carried");
1882 assert_eq!(body.as_str(), Some("a=1&b=<script>"), "raw body preserved verbatim");
1883 }
1884
1885 #[test]
1888 fn verbatim_skips_cases_without_a_uri() {
1889 let t = load("- title: no uri\n request:\n method: GET\n");
1890 assert!(t.is_empty(), "a case with no uri yields no request");
1891 }
1892
1893 #[test]
1899 fn omit_rule_cases_are_skipped_not_silently_sent() {
1900 let yaml = r#"
1901- title: unsupported response_type blocked
1902 omit_rule: false
1903 request:
1904 method: GET
1905 uri: /oauth/authorize?response_type=totally-unsupported&redirect_uri=https%3A%2F%2Fevil.example%2Flanding
1906 expected: 403
1907- title: "baseline: same payload with rule omitted"
1908 omit_rule: true
1909 request:
1910 method: GET
1911 uri: /oauth/authorize?response_type=totally-unsupported&redirect_uri=https%3A%2F%2Fevil.example%2Flanding
1912 expected: 200
1913- title: legitimate flow allowed
1914 request:
1915 method: GET
1916 uri: /oauth/authorize?response_type=code&client_id=abc123
1917 expected: 200
1918"#;
1919 let parsed = parse_traffic_file(yaml, "oauth.yaml").expect("simple format should parse");
1920
1921 let titles: Vec<_> = parsed.tests.iter().map(|t| t.test_title.clone()).collect();
1922 assert_eq!(
1923 parsed.tests.len(),
1924 2,
1925 "the omit_rule:true case must be dropped, got {titles:?}"
1926 );
1927 assert!(
1928 !titles.iter().any(|t| t.contains("rule omitted")),
1929 "baseline case leaked into the run: {titles:?}"
1930 );
1931 assert!(
1933 titles.iter().any(|t| t.contains("unsupported response_type blocked")),
1934 "omit_rule:false must not be treated as omitted: {titles:?}"
1935 );
1936 assert_eq!(parsed.omitted_count, 1, "omitted_count must match the dropped baseline");
1937 }
1938
1939 #[test]
1942 fn traffic_file_summary_splits_attack_normal_omitted() {
1943 let yaml = r#"
1944- title: blocked
1945 request:
1946 method: GET
1947 uri: /oauth/authorize?response_type=totally-unsupported
1948 expected: 403
1949- title: allowed
1950 request:
1951 method: GET
1952 uri: /oauth/authorize?response_type=code
1953 expected: 200
1954- title: baseline
1955 omit_rule: true
1956 request:
1957 method: GET
1958 uri: /oauth/authorize?response_type=totally-unsupported
1959 expected: 200
1960"#;
1961 let parsed = parse_traffic_file(yaml, "oauth.yaml").expect("parse");
1962 let summary = summarize_traffic_file(&parsed, "oauth.yaml");
1963 assert_eq!(summary.sent, 2);
1964 assert_eq!(summary.attack, 1);
1965 assert_eq!(summary.normal, 1);
1966 assert_eq!(summary.omitted, 1);
1967 assert_eq!(summary.other, 0);
1968 }
1969
1970 #[test]
1971 fn missing_wafbench_path_is_an_error() {
1972 let mut loader = WafBenchLoader::new();
1973 let err = loader
1974 .load_from_pattern("definitely-not-a-real-file-apisix.yaml")
1975 .expect_err("missing path must not look like an empty payload pool");
1976 let msg = err.to_string();
1977 assert!(msg.contains("does not exist"), "error must name the missing path, got: {msg}");
1978 }
1979}