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 })
484 }
485 Err(simple_err) => Err(BenchError::Other(format!(
486 "Failed to parse traffic file {source}. Two shapes are accepted:\n \
487 (1) WAFBench document -- a `meta:` mapping plus a `tests:` list. Parse error: {wafbench_err}\n \
488 (2) simple list -- `- title: .. / request: {{method, uri, headers, body}} / expected: 403`. Parse error: {simple_err}"
489 ))),
490 },
491 }
492}
493
494#[derive(Debug, Clone, Deserialize, Serialize)]
496pub struct WafBenchFile {
497 pub meta: WafBenchMeta,
499 #[serde(default)]
501 pub tests: Vec<WafBenchTest>,
502}
503
504#[derive(Debug, Clone)]
506pub struct WafBenchTestCase {
507 pub test_id: String,
509 pub description: String,
511 pub rule_id: String,
513 pub category: SecurityCategory,
515 pub method: String,
517 pub payloads: Vec<WafBenchPayload>,
519 pub expects_block: bool,
521}
522
523#[derive(Debug, Clone)]
525pub struct WafBenchPayload {
526 pub location: PayloadLocation,
528 pub value: String,
530 pub header_name: Option<String>,
532}
533
534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
536pub enum PayloadLocation {
537 Uri,
539 Header,
541 Body,
543}
544
545impl std::fmt::Display for PayloadLocation {
546 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
547 match self {
548 Self::Uri => write!(f, "uri"),
549 Self::Header => write!(f, "header"),
550 Self::Body => write!(f, "body"),
551 }
552 }
553}
554
555pub struct WafBenchLoader {
557 test_cases: Vec<WafBenchTestCase>,
559 stats: WafBenchStats,
561}
562
563#[derive(Debug, Clone, Default)]
565pub struct WafBenchStats {
566 pub files_processed: usize,
568 pub test_cases_loaded: usize,
570 pub payloads_extracted: usize,
572 pub by_category: HashMap<SecurityCategory, usize>,
574 pub parse_errors: Vec<String>,
576}
577
578impl WafBenchLoader {
579 pub fn new() -> Self {
581 Self {
582 test_cases: Vec::new(),
583 stats: WafBenchStats::default(),
584 }
585 }
586
587 pub fn load_from_pattern(&mut self, pattern: &str) -> Result<()> {
598 if !pattern.contains('*') && !pattern.contains('?') {
600 let path = Path::new(pattern);
601 if path.is_file() {
602 return self.load_file(path);
604 } else if path.is_dir() {
605 return self.load_from_directory(path);
606 } else {
607 return Err(BenchError::Other(format!(
608 "WAFBench path does not exist: {}",
609 pattern
610 )));
611 }
612 }
613
614 let entries = glob(pattern).map_err(|e| {
616 BenchError::Other(format!("Invalid WAFBench pattern '{}': {}", pattern, e))
617 })?;
618
619 for entry in entries {
620 match entry {
621 Ok(path) => {
622 if path.is_file()
623 && path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml")
624 {
625 if let Err(e) = self.load_file(&path) {
626 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
627 }
628 } else if path.is_dir() {
629 if let Err(e) = self.load_from_directory(&path) {
630 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
631 }
632 }
633 }
634 Err(e) => {
635 self.stats.parse_errors.push(format!("Glob error: {}", e));
636 }
637 }
638 }
639
640 Ok(())
641 }
642
643 pub fn load_from_directory(&mut self, dir: &Path) -> Result<()> {
645 if !dir.is_dir() {
646 return Err(BenchError::Other(format!(
647 "WAFBench path is not a directory: {}",
648 dir.display()
649 )));
650 }
651
652 self.load_directory_recursive(dir)?;
653 Ok(())
654 }
655
656 fn load_directory_recursive(&mut self, dir: &Path) -> Result<()> {
657 let entries = std::fs::read_dir(dir)
658 .map_err(|e| BenchError::Other(format!("Failed to read WAFBench directory: {}", e)))?;
659
660 for entry in entries.flatten() {
661 let path = entry.path();
662 if path.is_dir() {
663 self.load_directory_recursive(&path)?;
665 } else if path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml") {
666 if let Err(e) = self.load_file(&path) {
667 self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
668 }
669 }
670 }
671
672 Ok(())
673 }
674
675 pub fn load_file(&mut self, path: &Path) -> Result<()> {
677 let content = std::fs::read_to_string(path).map_err(|e| {
678 BenchError::Other(format!("Failed to read WAFBench file {}: {}", path.display(), e))
679 })?;
680
681 let wafbench_file = parse_traffic_file(&content, &path.display().to_string())?;
682
683 if !wafbench_file.meta.enabled {
685 return Ok(());
686 }
687
688 self.stats.files_processed += 1;
689
690 let category = self.detect_category(path, &wafbench_file.meta);
692
693 for test in wafbench_file.tests {
695 if let Some(test_case) = self.parse_test_case(&test, category) {
696 self.stats.payloads_extracted += test_case.payloads.len();
697 *self.stats.by_category.entry(category).or_insert(0) += 1;
698 self.test_cases.push(test_case);
699 self.stats.test_cases_loaded += 1;
700 }
701 }
702
703 Ok(())
704 }
705
706 fn detect_category(&self, path: &Path, _meta: &WafBenchMeta) -> SecurityCategory {
708 let path_str = path.to_string_lossy().to_uppercase();
709
710 if path_str.contains("XSS") || path_str.contains("941") {
711 SecurityCategory::Xss
712 } else if path_str.contains("SQLI") || path_str.contains("942") {
713 SecurityCategory::SqlInjection
714 } else if path_str.contains("RCE") || path_str.contains("932") {
715 SecurityCategory::CommandInjection
716 } else if path_str.contains("LFI") || path_str.contains("930") {
717 SecurityCategory::PathTraversal
718 } else if path_str.contains("LDAP") {
719 SecurityCategory::LdapInjection
720 } else if path_str.contains("XXE") || path_str.contains("XML") {
721 SecurityCategory::Xxe
722 } else if path_str.contains("TEMPLATE") || path_str.contains("SSTI") {
723 SecurityCategory::Ssti
724 } else {
725 SecurityCategory::Xss
727 }
728 }
729
730 fn parse_test_case(
732 &self,
733 test: &WafBenchTest,
734 category: SecurityCategory,
735 ) -> Option<WafBenchTestCase> {
736 let rule_id = test.test_title.split('-').next().unwrap_or(&test.test_title).to_string();
738
739 let mut payloads = Vec::new();
740 let mut method = "GET".to_string();
741 let mut expects_block = false;
742
743 for stage in &test.stages {
744 let Some(input) = stage.get_input() else {
746 continue;
747 };
748
749 method = input.method.clone();
750
751 if let Some(output) = stage.get_output() {
753 if output.status.contains(&403) {
754 expects_block = true;
755 }
756 }
757
758 if let Some(uri) = &input.uri {
763 if !uri.is_empty() {
764 payloads.push(WafBenchPayload {
765 location: PayloadLocation::Uri,
766 value: uri.clone(),
767 header_name: None,
768 });
769 }
770 }
771
772 for (header_name, header_value) in &input.headers {
774 if !header_value.is_empty() {
775 payloads.push(WafBenchPayload {
776 location: PayloadLocation::Header,
777 value: header_value.clone(),
778 header_name: Some(header_name.clone()),
779 });
780 }
781 }
782
783 if let Some(data) = &input.data {
785 if !data.is_empty() {
786 payloads.push(WafBenchPayload {
787 location: PayloadLocation::Body,
788 value: data.clone(),
789 header_name: None,
790 });
791 }
792 }
793 }
794
795 if payloads.is_empty() {
797 if let Some(stage) = test.stages.first() {
798 if let Some(input) = stage.get_input() {
799 if let Some(uri) = &input.uri {
800 payloads.push(WafBenchPayload {
801 location: PayloadLocation::Uri,
802 value: uri.clone(),
803 header_name: None,
804 });
805 }
806 }
807 }
808 }
809
810 if payloads.is_empty() {
811 return None;
812 }
813
814 let description = test.desc.clone().unwrap_or_else(|| format!("CRS Rule {} test", rule_id));
815
816 Some(WafBenchTestCase {
817 test_id: test.test_title.clone(),
818 description,
819 rule_id,
820 category,
821 method,
822 payloads,
823 expects_block,
824 })
825 }
826
827 #[cfg(test)]
829 fn looks_like_attack(&self, s: &str) -> bool {
830 let attack_patterns = [
832 "<script",
833 "javascript:",
834 "onerror=",
835 "onload=",
836 "onclick=",
837 "onfocus=",
838 "onmouseover=",
839 "eval(",
840 "alert(",
841 "document.",
842 "window.",
843 "'--",
844 "' OR ",
845 "' AND ",
846 "1=1",
847 "UNION SELECT",
848 "CONCAT(",
849 "CHAR(",
850 "../",
851 "..\\",
852 "/etc/passwd",
853 "cmd.exe",
854 "powershell",
855 "; ls",
856 "| cat",
857 "${",
858 "{{",
859 "<%",
860 "<?",
861 "<!ENTITY",
862 "SYSTEM \"",
863 ];
864
865 let lower = s.to_lowercase();
866 attack_patterns.iter().any(|p| lower.contains(&p.to_lowercase()))
867 }
868
869 pub fn test_cases(&self) -> &[WafBenchTestCase] {
871 &self.test_cases
872 }
873
874 pub fn stats(&self) -> &WafBenchStats {
876 &self.stats
877 }
878
879 fn decode_form_encoded_body(value: &str) -> String {
884 let plus_decoded = value.replace('+', " ");
886 let decoded = urlencoding::decode(&plus_decoded)
888 .map(|s| s.into_owned())
889 .unwrap_or(plus_decoded);
890 Self::strip_form_key(&decoded)
894 }
895
896 fn strip_form_key(value: &str) -> String {
901 if let Some(eq_pos) = value.find('=') {
904 let key = &value[..eq_pos];
905 if !key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
907 return value[eq_pos + 1..].to_string();
908 }
909 }
910 value.to_string()
911 }
912
913 fn ensure_form_encoded(value: &str) -> String {
922 value
923 .split('&')
924 .map(|pair| {
925 if let Some(eq_pos) = pair.find('=') {
926 let key = &pair[..eq_pos];
927 let val = &pair[eq_pos + 1..];
928 let key_plus = key.replace('+', " ");
930 let val_plus = val.replace('+', " ");
931 let decoded_key = urlencoding::decode(&key_plus).unwrap_or(key.into());
932 let decoded_val = urlencoding::decode(&val_plus).unwrap_or(val.into());
933 let enc_key = urlencoding::encode(&decoded_key).replace("%20", "+");
935 let enc_val = urlencoding::encode(&decoded_val).replace("%20", "+");
936 format!("{enc_key}={enc_val}")
937 } else {
938 let pair_plus = pair.replace('+', " ");
940 let decoded = urlencoding::decode(&pair_plus).unwrap_or(pair.into());
941 urlencoding::encode(&decoded).replace("%20", "+").to_string()
942 }
943 })
944 .collect::<Vec<_>>()
945 .join("&")
946 }
947
948 pub fn to_security_payloads(&self) -> Vec<SecurityPayload> {
950 let mut payloads = Vec::new();
951
952 for test_case in &self.test_cases {
953 let group_id = if test_case.payloads.len() > 1 {
955 Some(test_case.test_id.clone())
956 } else {
957 None
958 };
959
960 for payload in &test_case.payloads {
961 let payload_str = match payload.location {
963 PayloadLocation::Body => {
964 Self::decode_form_encoded_body(&payload.value)
966 }
967 PayloadLocation::Uri => {
968 self.extract_uri_payload(&payload.value)
970 }
971 PayloadLocation::Header => {
972 payload.value.clone()
974 }
975 };
976
977 let location = match payload.location {
979 PayloadLocation::Uri => SecurityPayloadLocation::Uri,
980 PayloadLocation::Header => SecurityPayloadLocation::Header,
981 PayloadLocation::Body => SecurityPayloadLocation::Body,
982 };
983
984 let mut sec_payload = SecurityPayload::new(
985 payload_str,
986 test_case.category,
987 format!(
988 "[WAFBench {}] {} ({})",
989 test_case.rule_id, test_case.description, payload.location
990 ),
991 )
992 .high_risk()
993 .with_location(location);
994
995 if let Some(header_name) = &payload.header_name {
997 sec_payload = sec_payload.with_header_name(header_name.clone());
998 }
999
1000 if let Some(gid) = &group_id {
1002 sec_payload = sec_payload.with_group_id(gid.clone());
1003 }
1004
1005 if payload.location == PayloadLocation::Uri && !payload.value.contains('?') {
1008 sec_payload = sec_payload.with_inject_as_path();
1009 }
1010
1011 if payload.location == PayloadLocation::Body {
1014 sec_payload = sec_payload
1015 .with_form_encoded_body(Self::ensure_form_encoded(&payload.value));
1016 }
1017
1018 payloads.push(sec_payload);
1019 }
1020 }
1021
1022 payloads
1023 }
1024
1025 fn extract_uri_payload(&self, value: &str) -> String {
1033 if value.contains('?') {
1036 if let Some(query) = value.split('?').nth(1) {
1037 for param in query.split('&') {
1038 if let Some(val) = param.split('=').nth(1) {
1039 let decoded = urlencoding::decode(val).unwrap_or_else(|_| val.into());
1040 if !decoded.is_empty() {
1041 return decoded.to_string();
1042 }
1043 }
1044 }
1045 }
1046 }
1047
1048 let decoded = urlencoding::decode(value)
1051 .map(|s| s.into_owned())
1052 .unwrap_or_else(|_| value.to_string());
1053 let trimmed = decoded.trim_start_matches('/');
1054 if trimmed.is_empty() {
1055 return decoded;
1057 }
1058 trimmed.to_string()
1059 }
1060}
1061
1062impl Default for WafBenchLoader {
1063 fn default() -> Self {
1064 Self::new()
1065 }
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071
1072 #[test]
1073 fn test_parse_wafbench_yaml() {
1074 let yaml = r#"
1075meta:
1076 author: test
1077 description: Test XSS rules
1078 enabled: true
1079 name: test.yaml
1080
1081tests:
1082 - desc: "XSS in URI parameter"
1083 test_title: "941100-1"
1084 stages:
1085 - input:
1086 dest_addr: "127.0.0.1"
1087 headers:
1088 Host: "localhost"
1089 User-Agent: "Mozilla/5.0"
1090 method: "GET"
1091 port: 80
1092 uri: "/test?param=<script>alert(1)</script>"
1093 output:
1094 status: [403]
1095"#;
1096
1097 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1098 assert!(file.meta.enabled);
1099 assert_eq!(file.tests.len(), 1);
1100 assert_eq!(file.tests[0].test_title, "941100-1");
1101 }
1102
1103 #[test]
1104 fn test_detect_category() {
1105 let loader = WafBenchLoader::new();
1106 let meta = WafBenchMeta {
1107 author: None,
1108 description: None,
1109 enabled: true,
1110 name: None,
1111 };
1112
1113 assert_eq!(
1114 loader.detect_category(Path::new("/wafbench/REQUEST-941-XSS/test.yaml"), &meta),
1115 SecurityCategory::Xss
1116 );
1117
1118 assert_eq!(
1119 loader.detect_category(Path::new("/wafbench/REQUEST-942-SQLI/test.yaml"), &meta),
1120 SecurityCategory::SqlInjection
1121 );
1122 }
1123
1124 #[test]
1125 fn test_looks_like_attack() {
1126 let loader = WafBenchLoader::new();
1127
1128 assert!(loader.looks_like_attack("<script>alert(1)</script>"));
1129 assert!(loader.looks_like_attack("' OR '1'='1"));
1130 assert!(loader.looks_like_attack("../../../etc/passwd"));
1131 assert!(loader.looks_like_attack("; ls -la"));
1132 assert!(!loader.looks_like_attack("normal text"));
1133 assert!(!loader.looks_like_attack("hello world"));
1134 }
1135
1136 #[test]
1137 fn test_extract_uri_payload_with_query_params() {
1138 let loader = WafBenchLoader::new();
1139
1140 let uri = "/test?param=%3Cscript%3Ealert(1)%3C/script%3E";
1142 let payload = loader.extract_uri_payload(uri);
1143 assert_eq!(payload, "<script>alert(1)</script>");
1144 }
1145
1146 #[test]
1147 fn test_extract_uri_payload_path_only() {
1148 let loader = WafBenchLoader::new();
1149
1150 let uri = "/1234%20OR%201=1";
1152 let payload = loader.extract_uri_payload(uri);
1153 assert_eq!(payload, "1234 OR 1=1");
1154
1155 let uri2 = "/foo')waitfor%20delay'5%3a0%3a20'--";
1157 let payload2 = loader.extract_uri_payload(uri2);
1158 assert_eq!(payload2, "foo')waitfor delay'5:0:20'--");
1159
1160 let uri3 = "/";
1162 let payload3 = loader.extract_uri_payload(uri3);
1163 assert_eq!(payload3, "/");
1164 }
1165
1166 #[test]
1167 fn test_group_id_assigned_for_multi_part_test_cases() {
1168 let yaml = r#"
1169meta:
1170 author: test
1171 description: Multi-part test
1172 enabled: true
1173 name: test.yaml
1174
1175tests:
1176 - desc: "Multi-part attack with URI and header"
1177 test_title: "942290-1"
1178 stages:
1179 - input:
1180 dest_addr: "127.0.0.1"
1181 headers:
1182 Host: "localhost"
1183 User-Agent: "ModSecurity CRS 3 Tests"
1184 method: "GET"
1185 port: 80
1186 uri: "/test?param=attack"
1187 output:
1188 status: [403]
1189"#;
1190
1191 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1192 let mut loader = WafBenchLoader::new();
1193 loader.stats.files_processed += 1;
1194
1195 let category = SecurityCategory::SqlInjection;
1196 for test in &file.tests {
1197 if let Some(test_case) = loader.parse_test_case(test, category) {
1198 loader.test_cases.push(test_case);
1199 }
1200 }
1201
1202 let payloads = loader.to_security_payloads();
1203 assert!(payloads.len() >= 2, "Should have at least 2 payloads");
1205 let group_ids: Vec<_> = payloads.iter().map(|p| p.group_id.clone()).collect();
1206 assert!(
1207 group_ids.iter().all(|g| g.is_some()),
1208 "All payloads in multi-part test should have group_id"
1209 );
1210 assert!(
1211 group_ids.iter().all(|g| g.as_deref() == Some("942290-1")),
1212 "All payloads should share the same group_id"
1213 );
1214 }
1215
1216 #[test]
1217 fn test_single_payload_no_group_id() {
1218 let yaml = r#"
1219meta:
1220 author: test
1221 description: Single payload test
1222 enabled: true
1223 name: test.yaml
1224
1225tests:
1226 - desc: "Simple XSS"
1227 test_title: "941100-1"
1228 stages:
1229 - input:
1230 dest_addr: "127.0.0.1"
1231 headers: {}
1232 method: "GET"
1233 port: 80
1234 uri: "/test?param=<script>alert(1)</script>"
1235 output:
1236 status: [403]
1237"#;
1238
1239 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1240 let mut loader = WafBenchLoader::new();
1241 loader.stats.files_processed += 1;
1242
1243 let category = SecurityCategory::Xss;
1244 for test in &file.tests {
1245 if let Some(test_case) = loader.parse_test_case(test, category) {
1246 loader.test_cases.push(test_case);
1247 }
1248 }
1249
1250 let payloads = loader.to_security_payloads();
1251 assert_eq!(payloads.len(), 1, "Should have exactly 1 payload");
1252 assert!(payloads[0].group_id.is_none(), "Single-payload test should NOT have group_id");
1253 }
1254
1255 #[test]
1256 fn test_body_payload_form_url_decoded() {
1257 let yaml = r#"
1258meta:
1259 author: test
1260 description: Body payload test
1261 enabled: true
1262 name: test.yaml
1263
1264tests:
1265 - desc: "SQL injection in body"
1266 test_title: "942240-1"
1267 stages:
1268 - stage:
1269 input:
1270 dest_addr: 127.0.0.1
1271 headers:
1272 Host: localhost
1273 method: POST
1274 port: 80
1275 uri: "/"
1276 data: "%22+WAITFOR+DELAY+%270%3A0%3A5%27"
1277 output:
1278 log_contains: id "942240"
1279"#;
1280
1281 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1282 let mut loader = WafBenchLoader::new();
1283 loader.stats.files_processed += 1;
1284
1285 let category = SecurityCategory::SqlInjection;
1286 for test in &file.tests {
1287 if let Some(test_case) = loader.parse_test_case(test, category) {
1288 loader.test_cases.push(test_case);
1289 }
1290 }
1291
1292 let payloads = loader.to_security_payloads();
1293 let body_payload = payloads
1295 .iter()
1296 .find(|p| p.location == SecurityPayloadLocation::Body)
1297 .expect("Should have a body payload");
1298
1299 assert!(
1301 body_payload.payload.contains('"'),
1302 "Body payload should have decoded %22 to double-quote: {}",
1303 body_payload.payload
1304 );
1305 assert!(
1306 body_payload.payload.contains(' '),
1307 "Body payload should have decoded + to space: {}",
1308 body_payload.payload
1309 );
1310 assert!(
1311 !body_payload.payload.contains("%22"),
1312 "Body payload should NOT contain literal %22: {}",
1313 body_payload.payload
1314 );
1315 }
1316
1317 #[test]
1318 fn test_decode_form_encoded_body() {
1319 assert_eq!(
1321 WafBenchLoader::decode_form_encoded_body("%22+WAITFOR+DELAY+%27%0A"),
1322 "\" WAITFOR DELAY '\n"
1323 );
1324 assert_eq!(WafBenchLoader::decode_form_encoded_body("normal+text"), "normal text");
1325 assert_eq!(
1326 WafBenchLoader::decode_form_encoded_body("no+encoding+needed"),
1327 "no encoding needed"
1328 );
1329 assert_eq!(
1331 WafBenchLoader::decode_form_encoded_body("var%3D%3B%3Bdd+foo+bar"),
1332 ";;dd foo bar"
1333 );
1334 assert_eq!(WafBenchLoader::decode_form_encoded_body("pay%3Dexec+%28%40%0A"), "exec (@\n");
1336 assert_eq!(WafBenchLoader::decode_form_encoded_body("%22+WAITFOR"), "\" WAITFOR");
1338 }
1339
1340 #[test]
1341 fn test_strip_form_key() {
1342 assert_eq!(WafBenchLoader::strip_form_key("var=;;dd foo bar"), ";;dd foo bar");
1344 assert_eq!(WafBenchLoader::strip_form_key("pay=exec (@\n"), "exec (@\n");
1345 assert_eq!(WafBenchLoader::strip_form_key("pay=DECLARE/**/@x\n"), "DECLARE/**/@x\n");
1346 assert_eq!(WafBenchLoader::strip_form_key("\" WAITFOR DELAY '\n"), "\" WAITFOR DELAY '\n");
1348 assert_eq!(WafBenchLoader::strip_form_key("' OR 1=1"), "' OR 1=1");
1350 assert_eq!(WafBenchLoader::strip_form_key(""), "");
1352 assert_eq!(WafBenchLoader::strip_form_key("var="), "");
1354 }
1355
1356 #[test]
1357 fn test_ensure_form_encoded() {
1358 assert_eq!(
1360 WafBenchLoader::ensure_form_encoded("var=%3B%3Bdd+foo+bar"),
1361 "var=%3B%3Bdd+foo+bar"
1362 );
1363 assert_eq!(WafBenchLoader::ensure_form_encoded("var=;;dd foo bar"), "var=%3B%3Bdd+foo+bar");
1365 assert_eq!(
1367 WafBenchLoader::ensure_form_encoded("var=-------------------&var2=whatever"),
1368 "var=-------------------&var2=whatever"
1369 );
1370 assert_eq!(
1372 WafBenchLoader::ensure_form_encoded("key=%22value%22&other=test+data"),
1373 "key=%22value%22&other=test+data"
1374 );
1375 assert_eq!(
1377 WafBenchLoader::ensure_form_encoded("key=\"value\"&other=test data"),
1378 "key=%22value%22&other=test+data"
1379 );
1380 assert_eq!(WafBenchLoader::ensure_form_encoded("plain text"), "plain+text");
1382 assert_eq!(WafBenchLoader::ensure_form_encoded(""), "");
1384 }
1385
1386 #[test]
1387 fn test_uri_path_only_gets_inject_as_path() {
1388 let yaml = r#"
1389meta:
1390 author: test
1391 description: Path injection test
1392 enabled: true
1393 name: test.yaml
1394
1395tests:
1396 - desc: "Path-based SQL injection"
1397 test_title: "942101-1"
1398 stages:
1399 - stage:
1400 input:
1401 dest_addr: 127.0.0.1
1402 headers:
1403 Host: localhost
1404 method: POST
1405 port: 80
1406 uri: "/1234%20OR%201=1"
1407 output:
1408 log_contains: id "942101"
1409"#;
1410
1411 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1412 let mut loader = WafBenchLoader::new();
1413 loader.stats.files_processed += 1;
1414
1415 let category = SecurityCategory::SqlInjection;
1416 for test in &file.tests {
1417 if let Some(test_case) = loader.parse_test_case(test, category) {
1418 loader.test_cases.push(test_case);
1419 }
1420 }
1421
1422 let payloads = loader.to_security_payloads();
1423 let uri_payload = payloads
1424 .iter()
1425 .find(|p| p.location == SecurityPayloadLocation::Uri)
1426 .expect("Should have URI payload");
1427
1428 assert_eq!(
1429 uri_payload.inject_as_path,
1430 Some(true),
1431 "Path-only URI should have inject_as_path=true"
1432 );
1433 }
1434
1435 #[test]
1436 fn test_uri_with_query_no_inject_as_path() {
1437 let yaml = r#"
1438meta:
1439 author: test
1440 description: Query param test
1441 enabled: true
1442 name: test.yaml
1443
1444tests:
1445 - desc: "Query-param SQL injection"
1446 test_title: "942100-1"
1447 stages:
1448 - stage:
1449 input:
1450 dest_addr: 127.0.0.1
1451 headers: {}
1452 method: GET
1453 port: 80
1454 uri: "/test?param=1+OR+1%3D1"
1455 output:
1456 log_contains: id "942100"
1457"#;
1458
1459 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1460 let mut loader = WafBenchLoader::new();
1461 loader.stats.files_processed += 1;
1462
1463 let category = SecurityCategory::SqlInjection;
1464 for test in &file.tests {
1465 if let Some(test_case) = loader.parse_test_case(test, category) {
1466 loader.test_cases.push(test_case);
1467 }
1468 }
1469
1470 let payloads = loader.to_security_payloads();
1471 let uri_payload = payloads
1472 .iter()
1473 .find(|p| p.location == SecurityPayloadLocation::Uri)
1474 .expect("Should have URI payload");
1475
1476 assert!(
1477 uri_payload.inject_as_path.is_none(),
1478 "URI with query params should NOT have inject_as_path"
1479 );
1480 }
1481
1482 #[test]
1483 fn test_body_payload_gets_form_encoded_body() {
1484 let yaml = r#"
1485meta:
1486 author: test
1487 description: Form body test
1488 enabled: true
1489 name: test.yaml
1490
1491tests:
1492 - desc: "Form-encoded body attack"
1493 test_title: "942432-1"
1494 stages:
1495 - stage:
1496 input:
1497 dest_addr: 127.0.0.1
1498 headers:
1499 Host: localhost
1500 method: POST
1501 port: 80
1502 uri: "/"
1503 data: "var=%3B%3Bdd+foo+bar"
1504 output:
1505 log_contains: id "942432"
1506"#;
1507
1508 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1509 let mut loader = WafBenchLoader::new();
1510 loader.stats.files_processed += 1;
1511
1512 let category = SecurityCategory::SqlInjection;
1513 for test in &file.tests {
1514 if let Some(test_case) = loader.parse_test_case(test, category) {
1515 loader.test_cases.push(test_case);
1516 }
1517 }
1518
1519 let payloads = loader.to_security_payloads();
1520 let body_payload = payloads
1521 .iter()
1522 .find(|p| p.location == SecurityPayloadLocation::Body)
1523 .expect("Should have body payload");
1524
1525 assert!(
1526 body_payload.form_encoded_body.is_some(),
1527 "Body payload should have form_encoded_body set"
1528 );
1529 assert_eq!(
1531 body_payload.form_encoded_body.as_deref().unwrap(),
1532 "var=%3B%3Bdd+foo+bar",
1533 "form_encoded_body should be properly URL-encoded"
1534 );
1535 }
1536
1537 #[test]
1538 fn test_body_payload_decoded_yaml_gets_encoded() {
1539 let yaml = r#"
1541meta:
1542 author: test
1543 description: Form body test (decoded)
1544 enabled: true
1545 name: test.yaml
1546
1547tests:
1548 - desc: "Form-encoded body attack (decoded)"
1549 test_title: "942432-2"
1550 stages:
1551 - stage:
1552 input:
1553 dest_addr: 127.0.0.1
1554 headers:
1555 Host: localhost
1556 method: POST
1557 port: 80
1558 uri: "/"
1559 data: "var=;;dd foo bar"
1560 output:
1561 log_contains: id "942432"
1562"#;
1563
1564 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1565 let mut loader = WafBenchLoader::new();
1566 loader.stats.files_processed += 1;
1567
1568 let category = SecurityCategory::SqlInjection;
1569 for test in &file.tests {
1570 if let Some(test_case) = loader.parse_test_case(test, category) {
1571 loader.test_cases.push(test_case);
1572 }
1573 }
1574
1575 let payloads = loader.to_security_payloads();
1576 let body_payload = payloads
1577 .iter()
1578 .find(|p| p.location == SecurityPayloadLocation::Body)
1579 .expect("Should have body payload");
1580
1581 assert!(
1582 body_payload.form_encoded_body.is_some(),
1583 "Body payload should have form_encoded_body set"
1584 );
1585 let encoded = body_payload.form_encoded_body.as_deref().unwrap();
1587 assert!(
1588 encoded.contains("%3B%3B") || encoded.contains("%3b%3b"),
1589 "Semicolons must be URL-encoded: {encoded}"
1590 );
1591 assert!(!encoded.contains(' '), "Spaces must be encoded as + in form body: {encoded}");
1592 assert!(encoded.starts_with("var="), "Form key must be preserved: {encoded}");
1593 }
1594
1595 #[test]
1596 fn test_parse_crs_v33_format() {
1597 let yaml = r#"
1599meta:
1600 author: "Christian Folini"
1601 description: Various SQL injection tests
1602 enabled: true
1603 name: 942100.yaml
1604
1605tests:
1606 - test_title: 942100-1
1607 desc: "Simple SQL Injection"
1608 stages:
1609 - stage:
1610 input:
1611 dest_addr: 127.0.0.1
1612 headers:
1613 Host: localhost
1614 method: POST
1615 port: 80
1616 uri: "/"
1617 data: "var=1234 OR 1=1"
1618 version: HTTP/1.0
1619 output:
1620 log_contains: id "942100"
1621"#;
1622
1623 let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
1624 assert!(file.meta.enabled);
1625 assert_eq!(file.tests.len(), 1);
1626 assert_eq!(file.tests[0].test_title, "942100-1");
1627
1628 let stage = &file.tests[0].stages[0];
1630 let input = stage.get_input().expect("Should have input");
1631 assert_eq!(input.method, "POST");
1632 assert_eq!(input.data.as_deref(), Some("var=1234 OR 1=1"));
1633 }
1634}
1635
1636#[cfg(test)]
1637mod simple_traffic_tests {
1638 use super::*;
1639
1640 const SRIKANTH_YAML: &str = r#"
1643- title: utf-7 charset blocked
1644 request:
1645 method: POST
1646 uri: /graphql
1647 headers:
1648 Content-Type: application/json; charset=utf-7
1649 body: '+AHsAIgBxAHUAZQByAHkAIgA6ACIAewBfAF8AdAB5AHAAZQBuAGEAbQBlAH0AIgB9-'
1650 expected: 403
1651- title: utf-8 charset allowed
1652 request:
1653 method: POST
1654 uri: /graphql
1655 headers:
1656 Content-Type: application/json; charset=utf-8
1657 body: '{"query":"{__typename}"}'
1658 expected: 200
1659"#;
1660
1661 #[test]
1662 fn simple_sequence_parses_and_maps_every_field() {
1663 let file = parse_traffic_file(SRIKANTH_YAML, "test_cases.yaml").expect("should parse");
1664 assert_eq!(file.tests.len(), 2);
1665 assert!(file.meta.enabled, "synthesised meta must not disable the file");
1666
1667 let first = &file.tests[0];
1668 assert_eq!(first.test_title, "utf-7 charset blocked");
1669 let stage = &first.stages[0];
1670 let input = stage.input.as_ref().expect("input mapped");
1671 assert_eq!(input.method, "POST");
1672 assert_eq!(input.uri.as_deref(), Some("/graphql"));
1673 assert_eq!(
1674 input.headers.get("Content-Type").map(String::as_str),
1675 Some("application/json; charset=utf-7")
1676 );
1677 assert!(
1678 input.data.as_deref().unwrap().starts_with("+AHsAIgBxAHUAZQ"),
1679 "`body` must map onto WAFBench's `data`, payload intact"
1680 );
1681 assert_eq!(stage.output.as_ref().unwrap().status, vec![403]);
1682 }
1683
1684 #[test]
1686 fn wafbench_document_still_parses() {
1687 let yaml = r#"
1688meta:
1689 author: crs
1690 enabled: true
1691 name: 941100
1692tests:
1693 - test_title: 941100-1
1694 stages:
1695 - stage:
1696 input:
1697 method: GET
1698 uri: /?x=<script>alert(1)</script>
1699 output:
1700 status: [403]
1701"#;
1702 let file = parse_traffic_file(yaml, "941100.yaml").expect("should parse");
1703 assert_eq!(file.tests.len(), 1);
1704 assert_eq!(file.meta.author.as_deref(), Some("crs"));
1705 }
1706
1707 #[test]
1709 fn expected_accepts_scalar_sequence_and_mapping() {
1710 let scalar: serde_yaml::Value = serde_yaml::from_str("403").unwrap();
1711 assert_eq!(extract_expected_statuses(Some(&scalar)), vec![403]);
1712
1713 let seq: serde_yaml::Value = serde_yaml::from_str("[403, 406]").unwrap();
1714 assert_eq!(extract_expected_statuses(Some(&seq)), vec![403, 406]);
1715
1716 let map: serde_yaml::Value = serde_yaml::from_str("status: 403").unwrap();
1717 assert_eq!(extract_expected_statuses(Some(&map)), vec![403]);
1718
1719 let map_seq: serde_yaml::Value = serde_yaml::from_str("status: [403, 406]").unwrap();
1720 assert_eq!(extract_expected_statuses(Some(&map_seq)), vec![403, 406]);
1721 }
1722
1723 #[test]
1726 fn unusable_expected_yields_no_statuses_not_an_error() {
1727 let junk: serde_yaml::Value = serde_yaml::from_str("'not a status'").unwrap();
1728 assert!(extract_expected_statuses(Some(&junk)).is_empty());
1729 assert!(extract_expected_statuses(None).is_empty());
1730
1731 let yaml = "- request:\n uri: /a\n";
1732 let file = parse_traffic_file(yaml, "x.yaml").expect("case without expected still parses");
1733 assert_eq!(file.tests.len(), 1);
1734 assert_eq!(
1735 file.tests[0].stages[0].input.as_ref().unwrap().method,
1736 "GET",
1737 "method defaults"
1738 );
1739 assert_eq!(file.tests[0].test_title, "case-1", "title is synthesised when absent");
1740 }
1741
1742 #[test]
1745 fn parse_error_names_both_accepted_shapes() {
1746 let err = parse_traffic_file("just a string", "bad.yaml").unwrap_err().to_string();
1747 assert!(err.contains("meta"), "must mention the WAFBench shape: {err}");
1748 assert!(err.contains("tests"), "must mention the WAFBench shape: {err}");
1749 assert!(err.contains("expected"), "must mention the simple shape: {err}");
1750 assert!(err.contains("request"), "must mention the simple shape: {err}");
1751 }
1752}
1753
1754#[cfg(test)]
1755mod verbatim_tests {
1756 use super::*;
1757
1758 fn load(yaml: &str) -> Vec<crate::request_gen::RequestTemplate> {
1759 let file = parse_traffic_file(yaml, "t.yaml").expect("parses");
1760 let mut loader = WafBenchLoader::new();
1761 for test in &file.tests {
1762 if let Some(case) = loader.parse_test_case(test, SecurityCategory::Xss) {
1763 loader.test_cases.push(case);
1764 }
1765 }
1766 traffic_cases_to_templates(loader.test_cases())
1767 }
1768
1769 #[test]
1774 fn verbatim_preserves_every_query_parameter() {
1775 let t = load(
1776 "- 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",
1777 );
1778 assert_eq!(t.len(), 1);
1779 let path = t[0].generate_path();
1780 assert!(path.starts_with("/oauth/authorize"), "path must survive, got {path}");
1781 assert!(path.contains("redirect_uri="), "redirect_uri must reach the wire: {path}");
1782 assert!(path.contains("response_type="), "response_type must reach the wire: {path}");
1783 assert!(path.contains("state=s1"), "trailing params must survive: {path}");
1784 assert_eq!(t[0].operation.method, "GET");
1785 }
1786
1787 #[test]
1791 fn verbatim_does_not_reencode_the_uri() {
1792 let t = load(
1793 "- title: encoded\n request:\n method: GET\n uri: /a?u=https%3A%2F%2Fevil.example%2Fx&b=%2E%2E%2F\n",
1794 );
1795 let path = t[0].generate_path();
1796 assert!(path.contains("https%3A%2F%2Fevil.example%2Fx"), "must not decode: {path}");
1797 assert!(path.contains("%2E%2E%2F"), "must not normalise traversal: {path}");
1798 assert!(!path.contains("://evil.example"), "must not decode to a real URL: {path}");
1799 }
1800
1801 #[test]
1802 fn verbatim_carries_headers_and_body() {
1803 let t = load(
1804 "- 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",
1805 );
1806 assert_eq!(t[0].operation.method, "POST");
1807 assert_eq!(
1808 t[0].headers.get("Content-Type").map(String::as_str),
1809 Some("application/json; charset=utf-7"),
1810 "the charset is the attack; it must not be normalised"
1811 );
1812 assert!(t[0].body.is_some(), "body must be carried");
1813 }
1814
1815 #[test]
1817 fn verbatim_passes_through_non_json_body() {
1818 let t = load("- title: form\n request:\n method: POST\n uri: /f\n body: 'a=1&b=<script>'\n");
1819 let body = t[0].body.as_ref().expect("body carried");
1820 assert_eq!(body.as_str(), Some("a=1&b=<script>"), "raw body preserved verbatim");
1821 }
1822
1823 #[test]
1826 fn verbatim_skips_cases_without_a_uri() {
1827 let t = load("- title: no uri\n request:\n method: GET\n");
1828 assert!(t.is_empty(), "a case with no uri yields no request");
1829 }
1830
1831 #[test]
1837 fn omit_rule_cases_are_skipped_not_silently_sent() {
1838 let yaml = r#"
1839- title: unsupported response_type blocked
1840 omit_rule: false
1841 request:
1842 method: GET
1843 uri: /oauth/authorize?response_type=totally-unsupported&redirect_uri=https%3A%2F%2Fevil.example%2Flanding
1844 expected: 403
1845- title: "baseline: same payload with rule omitted"
1846 omit_rule: true
1847 request:
1848 method: GET
1849 uri: /oauth/authorize?response_type=totally-unsupported&redirect_uri=https%3A%2F%2Fevil.example%2Flanding
1850 expected: 200
1851- title: legitimate flow allowed
1852 request:
1853 method: GET
1854 uri: /oauth/authorize?response_type=code&client_id=abc123
1855 expected: 200
1856"#;
1857 let parsed = parse_traffic_file(yaml, "oauth.yaml").expect("simple format should parse");
1858
1859 let titles: Vec<_> = parsed.tests.iter().map(|t| t.test_title.clone()).collect();
1860 assert_eq!(
1861 parsed.tests.len(),
1862 2,
1863 "the omit_rule:true case must be dropped, got {titles:?}"
1864 );
1865 assert!(
1866 !titles.iter().any(|t| t.contains("rule omitted")),
1867 "baseline case leaked into the run: {titles:?}"
1868 );
1869 assert!(
1871 titles.iter().any(|t| t.contains("unsupported response_type blocked")),
1872 "omit_rule:false must not be treated as omitted: {titles:?}"
1873 );
1874 }
1875}