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}
256
257#[derive(Debug, Clone, Deserialize, Serialize)]
259pub struct SimpleTrafficRequest {
260 #[serde(default = "default_method")]
262 pub method: String,
263 pub uri: Option<String>,
265 #[serde(default)]
267 pub headers: HashMap<String, String>,
268 pub body: Option<String>,
271}
272
273fn extract_expected_statuses(value: Option<&serde_yaml::Value>) -> Vec<u16> {
280 fn as_status(v: &serde_yaml::Value) -> Option<u16> {
281 v.as_u64().and_then(|n| u16::try_from(n).ok())
282 }
283
284 let Some(value) = value else {
285 return Vec::new();
286 };
287
288 if let Some(one) = as_status(value) {
289 return vec![one];
290 }
291 if let Some(seq) = value.as_sequence() {
292 return seq.iter().filter_map(as_status).collect();
293 }
294 if let Some(map) = value.as_mapping() {
295 if let Some(status) = map.get(serde_yaml::Value::String("status".to_string())) {
296 if let Some(one) = as_status(status) {
297 return vec![one];
298 }
299 if let Some(seq) = status.as_sequence() {
300 return seq.iter().filter_map(as_status).collect();
301 }
302 }
303 }
304 Vec::new()
305}
306
307impl SimpleTrafficCase {
308 fn into_wafbench_test(self, index: usize) -> WafBenchTest {
312 let statuses = extract_expected_statuses(self.expected.as_ref());
313 let title = self.title.unwrap_or_else(|| format!("case-{}", index + 1));
314
315 WafBenchTest {
316 desc: Some(title.clone()),
317 test_title: title,
318 stages: vec![WafBenchStage {
319 input: Some(WafBenchInput {
320 dest_addr: None,
321 headers: self.request.headers,
322 method: self.request.method,
323 port: default_port(),
324 uri: self.request.uri,
325 data: self.request.body,
326 version: None,
327 }),
328 output: Some(WafBenchOutput {
329 status: statuses,
330 response_headers: HashMap::new(),
331 log_contains: Vec::new(),
332 no_log_contains: Vec::new(),
333 }),
334 stage: None,
335 }],
336 }
337 }
338}
339
340pub fn parse_traffic_file(content: &str, source: &str) -> Result<WafBenchFile> {
347 match serde_yaml::from_str::<WafBenchFile>(content) {
348 Ok(file) => Ok(file),
349 Err(wafbench_err) => match serde_yaml::from_str::<Vec<SimpleTrafficCase>>(content) {
350 Ok(cases) => {
351 tracing::info!(
352 "{source}: parsed {} case(s) in the simple request/expected format",
353 cases.len()
354 );
355 Ok(WafBenchFile {
356 meta: WafBenchMeta {
357 author: None,
358 description: Some(format!("simple traffic file: {source}")),
359 enabled: true,
360 name: Some(source.to_string()),
361 },
362 tests: cases
363 .into_iter()
364 .enumerate()
365 .map(|(i, c)| c.into_wafbench_test(i))
366 .collect(),
367 })
368 }
369 Err(simple_err) => Err(BenchError::Other(format!(
370 "Failed to parse traffic file {source}. Two shapes are accepted:\n \
371 (1) WAFBench document -- a `meta:` mapping plus a `tests:` list. Parse error: {wafbench_err}\n \
372 (2) simple list -- `- title: .. / request: {{method, uri, headers, body}} / expected: 403`. Parse error: {simple_err}"
373 ))),
374 },
375 }
376}
377
378#[derive(Debug, Clone, Deserialize, Serialize)]
380pub struct WafBenchFile {
381 pub meta: WafBenchMeta,
383 #[serde(default)]
385 pub tests: Vec<WafBenchTest>,
386}
387
388#[derive(Debug, Clone)]
390pub struct WafBenchTestCase {
391 pub test_id: String,
393 pub description: String,
395 pub rule_id: String,
397 pub category: SecurityCategory,
399 pub method: String,
401 pub payloads: Vec<WafBenchPayload>,
403 pub expects_block: bool,
405}
406
407#[derive(Debug, Clone)]
409pub struct WafBenchPayload {
410 pub location: PayloadLocation,
412 pub value: String,
414 pub header_name: Option<String>,
416}
417
418#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum PayloadLocation {
421 Uri,
423 Header,
425 Body,
427}
428
429impl std::fmt::Display for PayloadLocation {
430 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431 match self {
432 Self::Uri => write!(f, "uri"),
433 Self::Header => write!(f, "header"),
434 Self::Body => write!(f, "body"),
435 }
436 }
437}
438
439pub struct WafBenchLoader {
441 test_cases: Vec<WafBenchTestCase>,
443 stats: WafBenchStats,
445}
446
447#[derive(Debug, Clone, Default)]
449pub struct WafBenchStats {
450 pub files_processed: usize,
452 pub test_cases_loaded: usize,
454 pub payloads_extracted: usize,
456 pub by_category: HashMap<SecurityCategory, usize>,
458 pub parse_errors: Vec<String>,
460}
461
462impl WafBenchLoader {
463 pub fn new() -> Self {
465 Self {
466 test_cases: Vec::new(),
467 stats: WafBenchStats::default(),
468 }
469 }
470
471 pub fn load_from_pattern(&mut self, pattern: &str) -> Result<()> {
482 if !pattern.contains('*') && !pattern.contains('?') {
484 let path = Path::new(pattern);
485 if path.is_file() {
486 return self.load_file(path);
488 } else if path.is_dir() {
489 return self.load_from_directory(path);
490 } else {
491 return Err(BenchError::Other(format!(
492 "WAFBench path does not exist: {}",
493 pattern
494 )));
495 }
496 }
497
498 let entries = glob(pattern).map_err(|e| {
500 BenchError::Other(format!("Invalid WAFBench pattern '{}': {}", pattern, e))
501 })?;
502
503 for entry in entries {
504 match entry {
505 Ok(path) => {
506 if path.is_file()
507 && path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml")
508 {
509 if let Err(e) = self.load_file(&path) {
510 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
511 }
512 } else if path.is_dir() {
513 if let Err(e) = self.load_from_directory(&path) {
514 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
515 }
516 }
517 }
518 Err(e) => {
519 self.stats.parse_errors.push(format!("Glob error: {}", e));
520 }
521 }
522 }
523
524 Ok(())
525 }
526
527 pub fn load_from_directory(&mut self, dir: &Path) -> Result<()> {
529 if !dir.is_dir() {
530 return Err(BenchError::Other(format!(
531 "WAFBench path is not a directory: {}",
532 dir.display()
533 )));
534 }
535
536 self.load_directory_recursive(dir)?;
537 Ok(())
538 }
539
540 fn load_directory_recursive(&mut self, dir: &Path) -> Result<()> {
541 let entries = std::fs::read_dir(dir)
542 .map_err(|e| BenchError::Other(format!("Failed to read WAFBench directory: {}", e)))?;
543
544 for entry in entries.flatten() {
545 let path = entry.path();
546 if path.is_dir() {
547 self.load_directory_recursive(&path)?;
549 } else if path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml") {
550 if let Err(e) = self.load_file(&path) {
551 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
552 }
553 }
554 }
555
556 Ok(())
557 }
558
559 pub fn load_file(&mut self, path: &Path) -> Result<()> {
561 let content = std::fs::read_to_string(path).map_err(|e| {
562 BenchError::Other(format!("Failed to read WAFBench file {}: {}", path.display(), e))
563 })?;
564
565 let wafbench_file = parse_traffic_file(&content, &path.display().to_string())?;
566
567 if !wafbench_file.meta.enabled {
569 return Ok(());
570 }
571
572 self.stats.files_processed += 1;
573
574 let category = self.detect_category(path, &wafbench_file.meta);
576
577 for test in wafbench_file.tests {
579 if let Some(test_case) = self.parse_test_case(&test, category) {
580 self.stats.payloads_extracted += test_case.payloads.len();
581 *self.stats.by_category.entry(category).or_insert(0) += 1;
582 self.test_cases.push(test_case);
583 self.stats.test_cases_loaded += 1;
584 }
585 }
586
587 Ok(())
588 }
589
590 fn detect_category(&self, path: &Path, _meta: &WafBenchMeta) -> SecurityCategory {
592 let path_str = path.to_string_lossy().to_uppercase();
593
594 if path_str.contains("XSS") || path_str.contains("941") {
595 SecurityCategory::Xss
596 } else if path_str.contains("SQLI") || path_str.contains("942") {
597 SecurityCategory::SqlInjection
598 } else if path_str.contains("RCE") || path_str.contains("932") {
599 SecurityCategory::CommandInjection
600 } else if path_str.contains("LFI") || path_str.contains("930") {
601 SecurityCategory::PathTraversal
602 } else if path_str.contains("LDAP") {
603 SecurityCategory::LdapInjection
604 } else if path_str.contains("XXE") || path_str.contains("XML") {
605 SecurityCategory::Xxe
606 } else if path_str.contains("TEMPLATE") || path_str.contains("SSTI") {
607 SecurityCategory::Ssti
608 } else {
609 SecurityCategory::Xss
611 }
612 }
613
614 fn parse_test_case(
616 &self,
617 test: &WafBenchTest,
618 category: SecurityCategory,
619 ) -> Option<WafBenchTestCase> {
620 let rule_id = test.test_title.split('-').next().unwrap_or(&test.test_title).to_string();
622
623 let mut payloads = Vec::new();
624 let mut method = "GET".to_string();
625 let mut expects_block = false;
626
627 for stage in &test.stages {
628 let Some(input) = stage.get_input() else {
630 continue;
631 };
632
633 method = input.method.clone();
634
635 if let Some(output) = stage.get_output() {
637 if output.status.contains(&403) {
638 expects_block = true;
639 }
640 }
641
642 if let Some(uri) = &input.uri {
647 if !uri.is_empty() {
648 payloads.push(WafBenchPayload {
649 location: PayloadLocation::Uri,
650 value: uri.clone(),
651 header_name: None,
652 });
653 }
654 }
655
656 for (header_name, header_value) in &input.headers {
658 if !header_value.is_empty() {
659 payloads.push(WafBenchPayload {
660 location: PayloadLocation::Header,
661 value: header_value.clone(),
662 header_name: Some(header_name.clone()),
663 });
664 }
665 }
666
667 if let Some(data) = &input.data {
669 if !data.is_empty() {
670 payloads.push(WafBenchPayload {
671 location: PayloadLocation::Body,
672 value: data.clone(),
673 header_name: None,
674 });
675 }
676 }
677 }
678
679 if payloads.is_empty() {
681 if let Some(stage) = test.stages.first() {
682 if let Some(input) = stage.get_input() {
683 if let Some(uri) = &input.uri {
684 payloads.push(WafBenchPayload {
685 location: PayloadLocation::Uri,
686 value: uri.clone(),
687 header_name: None,
688 });
689 }
690 }
691 }
692 }
693
694 if payloads.is_empty() {
695 return None;
696 }
697
698 let description = test.desc.clone().unwrap_or_else(|| format!("CRS Rule {} test", rule_id));
699
700 Some(WafBenchTestCase {
701 test_id: test.test_title.clone(),
702 description,
703 rule_id,
704 category,
705 method,
706 payloads,
707 expects_block,
708 })
709 }
710
711 #[cfg(test)]
713 fn looks_like_attack(&self, s: &str) -> bool {
714 let attack_patterns = [
716 "<script",
717 "javascript:",
718 "onerror=",
719 "onload=",
720 "onclick=",
721 "onfocus=",
722 "onmouseover=",
723 "eval(",
724 "alert(",
725 "document.",
726 "window.",
727 "'--",
728 "' OR ",
729 "' AND ",
730 "1=1",
731 "UNION SELECT",
732 "CONCAT(",
733 "CHAR(",
734 "../",
735 "..\\",
736 "/etc/passwd",
737 "cmd.exe",
738 "powershell",
739 "; ls",
740 "| cat",
741 "${",
742 "{{",
743 "<%",
744 "<?",
745 "<!ENTITY",
746 "SYSTEM \"",
747 ];
748
749 let lower = s.to_lowercase();
750 attack_patterns.iter().any(|p| lower.contains(&p.to_lowercase()))
751 }
752
753 pub fn test_cases(&self) -> &[WafBenchTestCase] {
755 &self.test_cases
756 }
757
758 pub fn stats(&self) -> &WafBenchStats {
760 &self.stats
761 }
762
763 fn decode_form_encoded_body(value: &str) -> String {
768 let plus_decoded = value.replace('+', " ");
770 let decoded = urlencoding::decode(&plus_decoded)
772 .map(|s| s.into_owned())
773 .unwrap_or(plus_decoded);
774 Self::strip_form_key(&decoded)
778 }
779
780 fn strip_form_key(value: &str) -> String {
785 if let Some(eq_pos) = value.find('=') {
788 let key = &value[..eq_pos];
789 if !key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
791 return value[eq_pos + 1..].to_string();
792 }
793 }
794 value.to_string()
795 }
796
797 fn ensure_form_encoded(value: &str) -> String {
806 value
807 .split('&')
808 .map(|pair| {
809 if let Some(eq_pos) = pair.find('=') {
810 let key = &pair[..eq_pos];
811 let val = &pair[eq_pos + 1..];
812 let key_plus = key.replace('+', " ");
814 let val_plus = val.replace('+', " ");
815 let decoded_key = urlencoding::decode(&key_plus).unwrap_or(key.into());
816 let decoded_val = urlencoding::decode(&val_plus).unwrap_or(val.into());
817 let enc_key = urlencoding::encode(&decoded_key).replace("%20", "+");
819 let enc_val = urlencoding::encode(&decoded_val).replace("%20", "+");
820 format!("{enc_key}={enc_val}")
821 } else {
822 let pair_plus = pair.replace('+', " ");
824 let decoded = urlencoding::decode(&pair_plus).unwrap_or(pair.into());
825 urlencoding::encode(&decoded).replace("%20", "+").to_string()
826 }
827 })
828 .collect::<Vec<_>>()
829 .join("&")
830 }
831
832 pub fn to_security_payloads(&self) -> Vec<SecurityPayload> {
834 let mut payloads = Vec::new();
835
836 for test_case in &self.test_cases {
837 let group_id = if test_case.payloads.len() > 1 {
839 Some(test_case.test_id.clone())
840 } else {
841 None
842 };
843
844 for payload in &test_case.payloads {
845 let payload_str = match payload.location {
847 PayloadLocation::Body => {
848 Self::decode_form_encoded_body(&payload.value)
850 }
851 PayloadLocation::Uri => {
852 self.extract_uri_payload(&payload.value)
854 }
855 PayloadLocation::Header => {
856 payload.value.clone()
858 }
859 };
860
861 let location = match payload.location {
863 PayloadLocation::Uri => SecurityPayloadLocation::Uri,
864 PayloadLocation::Header => SecurityPayloadLocation::Header,
865 PayloadLocation::Body => SecurityPayloadLocation::Body,
866 };
867
868 let mut sec_payload = SecurityPayload::new(
869 payload_str,
870 test_case.category,
871 format!(
872 "[WAFBench {}] {} ({})",
873 test_case.rule_id, test_case.description, payload.location
874 ),
875 )
876 .high_risk()
877 .with_location(location);
878
879 if let Some(header_name) = &payload.header_name {
881 sec_payload = sec_payload.with_header_name(header_name.clone());
882 }
883
884 if let Some(gid) = &group_id {
886 sec_payload = sec_payload.with_group_id(gid.clone());
887 }
888
889 if payload.location == PayloadLocation::Uri && !payload.value.contains('?') {
892 sec_payload = sec_payload.with_inject_as_path();
893 }
894
895 if payload.location == PayloadLocation::Body {
898 sec_payload = sec_payload
899 .with_form_encoded_body(Self::ensure_form_encoded(&payload.value));
900 }
901
902 payloads.push(sec_payload);
903 }
904 }
905
906 payloads
907 }
908
909 fn extract_uri_payload(&self, value: &str) -> String {
917 if value.contains('?') {
920 if let Some(query) = value.split('?').nth(1) {
921 for param in query.split('&') {
922 if let Some(val) = param.split('=').nth(1) {
923 let decoded = urlencoding::decode(val).unwrap_or_else(|_| val.into());
924 if !decoded.is_empty() {
925 return decoded.to_string();
926 }
927 }
928 }
929 }
930 }
931
932 let decoded = urlencoding::decode(value)
935 .map(|s| s.into_owned())
936 .unwrap_or_else(|_| value.to_string());
937 let trimmed = decoded.trim_start_matches('/');
938 if trimmed.is_empty() {
939 return decoded;
941 }
942 trimmed.to_string()
943 }
944}
945
946impl Default for WafBenchLoader {
947 fn default() -> Self {
948 Self::new()
949 }
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955
956 #[test]
957 fn test_parse_wafbench_yaml() {
958 let yaml = r#"
959meta:
960 author: test
961 description: Test XSS rules
962 enabled: true
963 name: test.yaml
964
965tests:
966 - desc: "XSS in URI parameter"
967 test_title: "941100-1"
968 stages:
969 - input:
970 dest_addr: "127.0.0.1"
971 headers:
972 Host: "localhost"
973 User-Agent: "Mozilla/5.0"
974 method: "GET"
975 port: 80
976 uri: "/test?param=<script>alert(1)</script>"
977 output:
978 status: [403]
979"#;
980
981 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
982 assert!(file.meta.enabled);
983 assert_eq!(file.tests.len(), 1);
984 assert_eq!(file.tests[0].test_title, "941100-1");
985 }
986
987 #[test]
988 fn test_detect_category() {
989 let loader = WafBenchLoader::new();
990 let meta = WafBenchMeta {
991 author: None,
992 description: None,
993 enabled: true,
994 name: None,
995 };
996
997 assert_eq!(
998 loader.detect_category(Path::new("/wafbench/REQUEST-941-XSS/test.yaml"), &meta),
999 SecurityCategory::Xss
1000 );
1001
1002 assert_eq!(
1003 loader.detect_category(Path::new("/wafbench/REQUEST-942-SQLI/test.yaml"), &meta),
1004 SecurityCategory::SqlInjection
1005 );
1006 }
1007
1008 #[test]
1009 fn test_looks_like_attack() {
1010 let loader = WafBenchLoader::new();
1011
1012 assert!(loader.looks_like_attack("<script>alert(1)</script>"));
1013 assert!(loader.looks_like_attack("' OR '1'='1"));
1014 assert!(loader.looks_like_attack("../../../etc/passwd"));
1015 assert!(loader.looks_like_attack("; ls -la"));
1016 assert!(!loader.looks_like_attack("normal text"));
1017 assert!(!loader.looks_like_attack("hello world"));
1018 }
1019
1020 #[test]
1021 fn test_extract_uri_payload_with_query_params() {
1022 let loader = WafBenchLoader::new();
1023
1024 let uri = "/test?param=%3Cscript%3Ealert(1)%3C/script%3E";
1026 let payload = loader.extract_uri_payload(uri);
1027 assert_eq!(payload, "<script>alert(1)</script>");
1028 }
1029
1030 #[test]
1031 fn test_extract_uri_payload_path_only() {
1032 let loader = WafBenchLoader::new();
1033
1034 let uri = "/1234%20OR%201=1";
1036 let payload = loader.extract_uri_payload(uri);
1037 assert_eq!(payload, "1234 OR 1=1");
1038
1039 let uri2 = "/foo')waitfor%20delay'5%3a0%3a20'--";
1041 let payload2 = loader.extract_uri_payload(uri2);
1042 assert_eq!(payload2, "foo')waitfor delay'5:0:20'--");
1043
1044 let uri3 = "/";
1046 let payload3 = loader.extract_uri_payload(uri3);
1047 assert_eq!(payload3, "/");
1048 }
1049
1050 #[test]
1051 fn test_group_id_assigned_for_multi_part_test_cases() {
1052 let yaml = r#"
1053meta:
1054 author: test
1055 description: Multi-part test
1056 enabled: true
1057 name: test.yaml
1058
1059tests:
1060 - desc: "Multi-part attack with URI and header"
1061 test_title: "942290-1"
1062 stages:
1063 - input:
1064 dest_addr: "127.0.0.1"
1065 headers:
1066 Host: "localhost"
1067 User-Agent: "ModSecurity CRS 3 Tests"
1068 method: "GET"
1069 port: 80
1070 uri: "/test?param=attack"
1071 output:
1072 status: [403]
1073"#;
1074
1075 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1076 let mut loader = WafBenchLoader::new();
1077 loader.stats.files_processed += 1;
1078
1079 let category = SecurityCategory::SqlInjection;
1080 for test in &file.tests {
1081 if let Some(test_case) = loader.parse_test_case(test, category) {
1082 loader.test_cases.push(test_case);
1083 }
1084 }
1085
1086 let payloads = loader.to_security_payloads();
1087 assert!(payloads.len() >= 2, "Should have at least 2 payloads");
1089 let group_ids: Vec<_> = payloads.iter().map(|p| p.group_id.clone()).collect();
1090 assert!(
1091 group_ids.iter().all(|g| g.is_some()),
1092 "All payloads in multi-part test should have group_id"
1093 );
1094 assert!(
1095 group_ids.iter().all(|g| g.as_deref() == Some("942290-1")),
1096 "All payloads should share the same group_id"
1097 );
1098 }
1099
1100 #[test]
1101 fn test_single_payload_no_group_id() {
1102 let yaml = r#"
1103meta:
1104 author: test
1105 description: Single payload test
1106 enabled: true
1107 name: test.yaml
1108
1109tests:
1110 - desc: "Simple XSS"
1111 test_title: "941100-1"
1112 stages:
1113 - input:
1114 dest_addr: "127.0.0.1"
1115 headers: {}
1116 method: "GET"
1117 port: 80
1118 uri: "/test?param=<script>alert(1)</script>"
1119 output:
1120 status: [403]
1121"#;
1122
1123 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1124 let mut loader = WafBenchLoader::new();
1125 loader.stats.files_processed += 1;
1126
1127 let category = SecurityCategory::Xss;
1128 for test in &file.tests {
1129 if let Some(test_case) = loader.parse_test_case(test, category) {
1130 loader.test_cases.push(test_case);
1131 }
1132 }
1133
1134 let payloads = loader.to_security_payloads();
1135 assert_eq!(payloads.len(), 1, "Should have exactly 1 payload");
1136 assert!(payloads[0].group_id.is_none(), "Single-payload test should NOT have group_id");
1137 }
1138
1139 #[test]
1140 fn test_body_payload_form_url_decoded() {
1141 let yaml = r#"
1142meta:
1143 author: test
1144 description: Body payload test
1145 enabled: true
1146 name: test.yaml
1147
1148tests:
1149 - desc: "SQL injection in body"
1150 test_title: "942240-1"
1151 stages:
1152 - stage:
1153 input:
1154 dest_addr: 127.0.0.1
1155 headers:
1156 Host: localhost
1157 method: POST
1158 port: 80
1159 uri: "/"
1160 data: "%22+WAITFOR+DELAY+%270%3A0%3A5%27"
1161 output:
1162 log_contains: id "942240"
1163"#;
1164
1165 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1166 let mut loader = WafBenchLoader::new();
1167 loader.stats.files_processed += 1;
1168
1169 let category = SecurityCategory::SqlInjection;
1170 for test in &file.tests {
1171 if let Some(test_case) = loader.parse_test_case(test, category) {
1172 loader.test_cases.push(test_case);
1173 }
1174 }
1175
1176 let payloads = loader.to_security_payloads();
1177 let body_payload = payloads
1179 .iter()
1180 .find(|p| p.location == SecurityPayloadLocation::Body)
1181 .expect("Should have a body payload");
1182
1183 assert!(
1185 body_payload.payload.contains('"'),
1186 "Body payload should have decoded %22 to double-quote: {}",
1187 body_payload.payload
1188 );
1189 assert!(
1190 body_payload.payload.contains(' '),
1191 "Body payload should have decoded + to space: {}",
1192 body_payload.payload
1193 );
1194 assert!(
1195 !body_payload.payload.contains("%22"),
1196 "Body payload should NOT contain literal %22: {}",
1197 body_payload.payload
1198 );
1199 }
1200
1201 #[test]
1202 fn test_decode_form_encoded_body() {
1203 assert_eq!(
1205 WafBenchLoader::decode_form_encoded_body("%22+WAITFOR+DELAY+%27%0A"),
1206 "\" WAITFOR DELAY '\n"
1207 );
1208 assert_eq!(WafBenchLoader::decode_form_encoded_body("normal+text"), "normal text");
1209 assert_eq!(
1210 WafBenchLoader::decode_form_encoded_body("no+encoding+needed"),
1211 "no encoding needed"
1212 );
1213 assert_eq!(
1215 WafBenchLoader::decode_form_encoded_body("var%3D%3B%3Bdd+foo+bar"),
1216 ";;dd foo bar"
1217 );
1218 assert_eq!(WafBenchLoader::decode_form_encoded_body("pay%3Dexec+%28%40%0A"), "exec (@\n");
1220 assert_eq!(WafBenchLoader::decode_form_encoded_body("%22+WAITFOR"), "\" WAITFOR");
1222 }
1223
1224 #[test]
1225 fn test_strip_form_key() {
1226 assert_eq!(WafBenchLoader::strip_form_key("var=;;dd foo bar"), ";;dd foo bar");
1228 assert_eq!(WafBenchLoader::strip_form_key("pay=exec (@\n"), "exec (@\n");
1229 assert_eq!(WafBenchLoader::strip_form_key("pay=DECLARE/**/@x\n"), "DECLARE/**/@x\n");
1230 assert_eq!(WafBenchLoader::strip_form_key("\" WAITFOR DELAY '\n"), "\" WAITFOR DELAY '\n");
1232 assert_eq!(WafBenchLoader::strip_form_key("' OR 1=1"), "' OR 1=1");
1234 assert_eq!(WafBenchLoader::strip_form_key(""), "");
1236 assert_eq!(WafBenchLoader::strip_form_key("var="), "");
1238 }
1239
1240 #[test]
1241 fn test_ensure_form_encoded() {
1242 assert_eq!(
1244 WafBenchLoader::ensure_form_encoded("var=%3B%3Bdd+foo+bar"),
1245 "var=%3B%3Bdd+foo+bar"
1246 );
1247 assert_eq!(WafBenchLoader::ensure_form_encoded("var=;;dd foo bar"), "var=%3B%3Bdd+foo+bar");
1249 assert_eq!(
1251 WafBenchLoader::ensure_form_encoded("var=-------------------&var2=whatever"),
1252 "var=-------------------&var2=whatever"
1253 );
1254 assert_eq!(
1256 WafBenchLoader::ensure_form_encoded("key=%22value%22&other=test+data"),
1257 "key=%22value%22&other=test+data"
1258 );
1259 assert_eq!(
1261 WafBenchLoader::ensure_form_encoded("key=\"value\"&other=test data"),
1262 "key=%22value%22&other=test+data"
1263 );
1264 assert_eq!(WafBenchLoader::ensure_form_encoded("plain text"), "plain+text");
1266 assert_eq!(WafBenchLoader::ensure_form_encoded(""), "");
1268 }
1269
1270 #[test]
1271 fn test_uri_path_only_gets_inject_as_path() {
1272 let yaml = r#"
1273meta:
1274 author: test
1275 description: Path injection test
1276 enabled: true
1277 name: test.yaml
1278
1279tests:
1280 - desc: "Path-based SQL injection"
1281 test_title: "942101-1"
1282 stages:
1283 - stage:
1284 input:
1285 dest_addr: 127.0.0.1
1286 headers:
1287 Host: localhost
1288 method: POST
1289 port: 80
1290 uri: "/1234%20OR%201=1"
1291 output:
1292 log_contains: id "942101"
1293"#;
1294
1295 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1296 let mut loader = WafBenchLoader::new();
1297 loader.stats.files_processed += 1;
1298
1299 let category = SecurityCategory::SqlInjection;
1300 for test in &file.tests {
1301 if let Some(test_case) = loader.parse_test_case(test, category) {
1302 loader.test_cases.push(test_case);
1303 }
1304 }
1305
1306 let payloads = loader.to_security_payloads();
1307 let uri_payload = payloads
1308 .iter()
1309 .find(|p| p.location == SecurityPayloadLocation::Uri)
1310 .expect("Should have URI payload");
1311
1312 assert_eq!(
1313 uri_payload.inject_as_path,
1314 Some(true),
1315 "Path-only URI should have inject_as_path=true"
1316 );
1317 }
1318
1319 #[test]
1320 fn test_uri_with_query_no_inject_as_path() {
1321 let yaml = r#"
1322meta:
1323 author: test
1324 description: Query param test
1325 enabled: true
1326 name: test.yaml
1327
1328tests:
1329 - desc: "Query-param SQL injection"
1330 test_title: "942100-1"
1331 stages:
1332 - stage:
1333 input:
1334 dest_addr: 127.0.0.1
1335 headers: {}
1336 method: GET
1337 port: 80
1338 uri: "/test?param=1+OR+1%3D1"
1339 output:
1340 log_contains: id "942100"
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 uri_payload = payloads
1356 .iter()
1357 .find(|p| p.location == SecurityPayloadLocation::Uri)
1358 .expect("Should have URI payload");
1359
1360 assert!(
1361 uri_payload.inject_as_path.is_none(),
1362 "URI with query params should NOT have inject_as_path"
1363 );
1364 }
1365
1366 #[test]
1367 fn test_body_payload_gets_form_encoded_body() {
1368 let yaml = r#"
1369meta:
1370 author: test
1371 description: Form body test
1372 enabled: true
1373 name: test.yaml
1374
1375tests:
1376 - desc: "Form-encoded body attack"
1377 test_title: "942432-1"
1378 stages:
1379 - stage:
1380 input:
1381 dest_addr: 127.0.0.1
1382 headers:
1383 Host: localhost
1384 method: POST
1385 port: 80
1386 uri: "/"
1387 data: "var=%3B%3Bdd+foo+bar"
1388 output:
1389 log_contains: id "942432"
1390"#;
1391
1392 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1393 let mut loader = WafBenchLoader::new();
1394 loader.stats.files_processed += 1;
1395
1396 let category = SecurityCategory::SqlInjection;
1397 for test in &file.tests {
1398 if let Some(test_case) = loader.parse_test_case(test, category) {
1399 loader.test_cases.push(test_case);
1400 }
1401 }
1402
1403 let payloads = loader.to_security_payloads();
1404 let body_payload = payloads
1405 .iter()
1406 .find(|p| p.location == SecurityPayloadLocation::Body)
1407 .expect("Should have body payload");
1408
1409 assert!(
1410 body_payload.form_encoded_body.is_some(),
1411 "Body payload should have form_encoded_body set"
1412 );
1413 assert_eq!(
1415 body_payload.form_encoded_body.as_deref().unwrap(),
1416 "var=%3B%3Bdd+foo+bar",
1417 "form_encoded_body should be properly URL-encoded"
1418 );
1419 }
1420
1421 #[test]
1422 fn test_body_payload_decoded_yaml_gets_encoded() {
1423 let yaml = r#"
1425meta:
1426 author: test
1427 description: Form body test (decoded)
1428 enabled: true
1429 name: test.yaml
1430
1431tests:
1432 - desc: "Form-encoded body attack (decoded)"
1433 test_title: "942432-2"
1434 stages:
1435 - stage:
1436 input:
1437 dest_addr: 127.0.0.1
1438 headers:
1439 Host: localhost
1440 method: POST
1441 port: 80
1442 uri: "/"
1443 data: "var=;;dd foo bar"
1444 output:
1445 log_contains: id "942432"
1446"#;
1447
1448 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1449 let mut loader = WafBenchLoader::new();
1450 loader.stats.files_processed += 1;
1451
1452 let category = SecurityCategory::SqlInjection;
1453 for test in &file.tests {
1454 if let Some(test_case) = loader.parse_test_case(test, category) {
1455 loader.test_cases.push(test_case);
1456 }
1457 }
1458
1459 let payloads = loader.to_security_payloads();
1460 let body_payload = payloads
1461 .iter()
1462 .find(|p| p.location == SecurityPayloadLocation::Body)
1463 .expect("Should have body payload");
1464
1465 assert!(
1466 body_payload.form_encoded_body.is_some(),
1467 "Body payload should have form_encoded_body set"
1468 );
1469 let encoded = body_payload.form_encoded_body.as_deref().unwrap();
1471 assert!(
1472 encoded.contains("%3B%3B") || encoded.contains("%3b%3b"),
1473 "Semicolons must be URL-encoded: {encoded}"
1474 );
1475 assert!(!encoded.contains(' '), "Spaces must be encoded as + in form body: {encoded}");
1476 assert!(encoded.starts_with("var="), "Form key must be preserved: {encoded}");
1477 }
1478
1479 #[test]
1480 fn test_parse_crs_v33_format() {
1481 let yaml = r#"
1483meta:
1484 author: "Christian Folini"
1485 description: Various SQL injection tests
1486 enabled: true
1487 name: 942100.yaml
1488
1489tests:
1490 - test_title: 942100-1
1491 desc: "Simple SQL Injection"
1492 stages:
1493 - stage:
1494 input:
1495 dest_addr: 127.0.0.1
1496 headers:
1497 Host: localhost
1498 method: POST
1499 port: 80
1500 uri: "/"
1501 data: "var=1234 OR 1=1"
1502 version: HTTP/1.0
1503 output:
1504 log_contains: id "942100"
1505"#;
1506
1507 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1508 assert!(file.meta.enabled);
1509 assert_eq!(file.tests.len(), 1);
1510 assert_eq!(file.tests[0].test_title, "942100-1");
1511
1512 let stage = &file.tests[0].stages[0];
1514 let input = stage.get_input().expect("Should have input");
1515 assert_eq!(input.method, "POST");
1516 assert_eq!(input.data.as_deref(), Some("var=1234 OR 1=1"));
1517 }
1518}
1519
1520#[cfg(test)]
1521mod simple_traffic_tests {
1522 use super::*;
1523
1524 const SRIKANTH_YAML: &str = r#"
1527- title: utf-7 charset blocked
1528 request:
1529 method: POST
1530 uri: /graphql
1531 headers:
1532 Content-Type: application/json; charset=utf-7
1533 body: '+AHsAIgBxAHUAZQByAHkAIgA6ACIAewBfAF8AdAB5AHAAZQBuAGEAbQBlAH0AIgB9-'
1534 expected: 403
1535- title: utf-8 charset allowed
1536 request:
1537 method: POST
1538 uri: /graphql
1539 headers:
1540 Content-Type: application/json; charset=utf-8
1541 body: '{"query":"{__typename}"}'
1542 expected: 200
1543"#;
1544
1545 #[test]
1546 fn simple_sequence_parses_and_maps_every_field() {
1547 let file = parse_traffic_file(SRIKANTH_YAML, "test_cases.yaml").expect("should parse");
1548 assert_eq!(file.tests.len(), 2);
1549 assert!(file.meta.enabled, "synthesised meta must not disable the file");
1550
1551 let first = &file.tests[0];
1552 assert_eq!(first.test_title, "utf-7 charset blocked");
1553 let stage = &first.stages[0];
1554 let input = stage.input.as_ref().expect("input mapped");
1555 assert_eq!(input.method, "POST");
1556 assert_eq!(input.uri.as_deref(), Some("/graphql"));
1557 assert_eq!(
1558 input.headers.get("Content-Type").map(String::as_str),
1559 Some("application/json; charset=utf-7")
1560 );
1561 assert!(
1562 input.data.as_deref().unwrap().starts_with("+AHsAIgBxAHUAZQ"),
1563 "`body` must map onto WAFBench's `data`, payload intact"
1564 );
1565 assert_eq!(stage.output.as_ref().unwrap().status, vec![403]);
1566 }
1567
1568 #[test]
1570 fn wafbench_document_still_parses() {
1571 let yaml = r#"
1572meta:
1573 author: crs
1574 enabled: true
1575 name: 941100
1576tests:
1577 - test_title: 941100-1
1578 stages:
1579 - stage:
1580 input:
1581 method: GET
1582 uri: /?x=<script>alert(1)</script>
1583 output:
1584 status: [403]
1585"#;
1586 let file = parse_traffic_file(yaml, "941100.yaml").expect("should parse");
1587 assert_eq!(file.tests.len(), 1);
1588 assert_eq!(file.meta.author.as_deref(), Some("crs"));
1589 }
1590
1591 #[test]
1593 fn expected_accepts_scalar_sequence_and_mapping() {
1594 let scalar: serde_yaml::Value = serde_yaml::from_str("403").unwrap();
1595 assert_eq!(extract_expected_statuses(Some(&scalar)), vec![403]);
1596
1597 let seq: serde_yaml::Value = serde_yaml::from_str("[403, 406]").unwrap();
1598 assert_eq!(extract_expected_statuses(Some(&seq)), vec![403, 406]);
1599
1600 let map: serde_yaml::Value = serde_yaml::from_str("status: 403").unwrap();
1601 assert_eq!(extract_expected_statuses(Some(&map)), vec![403]);
1602
1603 let map_seq: serde_yaml::Value = serde_yaml::from_str("status: [403, 406]").unwrap();
1604 assert_eq!(extract_expected_statuses(Some(&map_seq)), vec![403, 406]);
1605 }
1606
1607 #[test]
1610 fn unusable_expected_yields_no_statuses_not_an_error() {
1611 let junk: serde_yaml::Value = serde_yaml::from_str("'not a status'").unwrap();
1612 assert!(extract_expected_statuses(Some(&junk)).is_empty());
1613 assert!(extract_expected_statuses(None).is_empty());
1614
1615 let yaml = "- request:\n uri: /a\n";
1616 let file = parse_traffic_file(yaml, "x.yaml").expect("case without expected still parses");
1617 assert_eq!(file.tests.len(), 1);
1618 assert_eq!(
1619 file.tests[0].stages[0].input.as_ref().unwrap().method,
1620 "GET",
1621 "method defaults"
1622 );
1623 assert_eq!(file.tests[0].test_title, "case-1", "title is synthesised when absent");
1624 }
1625
1626 #[test]
1629 fn parse_error_names_both_accepted_shapes() {
1630 let err = parse_traffic_file("just a string", "bad.yaml").unwrap_err().to_string();
1631 assert!(err.contains("meta"), "must mention the WAFBench shape: {err}");
1632 assert!(err.contains("tests"), "must mention the WAFBench shape: {err}");
1633 assert!(err.contains("expected"), "must mention the simple shape: {err}");
1634 assert!(err.contains("request"), "must mention the simple shape: {err}");
1635 }
1636}