use crate::error::{BenchError, Result};
use crate::security_payloads::{
PayloadLocation as SecurityPayloadLocation, SecurityCategory, SecurityPayload,
};
use glob::glob;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WafBenchMeta {
pub author: Option<String>,
pub description: Option<String>,
#[serde(default = "default_enabled")]
pub enabled: bool,
pub name: Option<String>,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WafBenchTest {
pub desc: Option<String>,
pub test_title: String,
#[serde(default)]
pub stages: Vec<WafBenchStage>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WafBenchStage {
pub input: Option<WafBenchInput>,
pub output: Option<WafBenchOutput>,
pub stage: Option<WafBenchStageInner>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WafBenchStageInner {
pub input: WafBenchInput,
pub output: Option<WafBenchOutput>,
}
impl WafBenchStage {
pub fn get_input(&self) -> Option<&WafBenchInput> {
if let Some(stage) = &self.stage {
Some(&stage.input)
} else {
self.input.as_ref()
}
}
pub fn get_output(&self) -> Option<&WafBenchOutput> {
if let Some(stage) = &self.stage {
stage.output.as_ref()
} else {
self.output.as_ref()
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WafBenchInput {
pub dest_addr: Option<String>,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default = "default_method")]
pub method: String,
#[serde(default = "default_port")]
pub port: u16,
pub uri: Option<String>,
pub data: Option<String>,
pub version: Option<String>,
}
fn default_method() -> String {
"GET".to_string()
}
fn default_port() -> u16 {
80
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WafBenchOutput {
#[serde(default)]
pub status: Vec<u16>,
#[serde(default)]
pub response_headers: HashMap<String, String>,
#[serde(default, deserialize_with = "deserialize_string_or_vec")]
pub log_contains: Vec<String>,
#[serde(default, deserialize_with = "deserialize_string_or_vec")]
pub no_log_contains: Vec<String>,
}
fn deserialize_string_or_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{self, Visitor};
struct StringOrVec;
impl<'de> Visitor<'de> for StringOrVec {
type Value = Vec<String>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("string or array of strings")
}
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
Ok(vec![value.to_string()])
}
fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
Ok(vec![value])
}
fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
where
A: de::SeqAccess<'de>,
{
let mut vec = Vec::new();
while let Some(value) = seq.next_element::<String>()? {
vec.push(value);
}
Ok(vec)
}
fn visit_none<E>(self) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
Ok(Vec::new())
}
fn visit_unit<E>(self) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
Ok(Vec::new())
}
}
deserializer.deserialize_any(StringOrVec)
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SimpleTrafficCase {
pub title: Option<String>,
pub request: SimpleTrafficRequest,
pub expected: Option<serde_yaml::Value>,
#[serde(default)]
pub omit_rule: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SimpleTrafficRequest {
#[serde(default = "default_method")]
pub method: String,
pub uri: Option<String>,
#[serde(default)]
pub headers: HashMap<String, String>,
pub body: Option<String>,
}
fn extract_expected_statuses(value: Option<&serde_yaml::Value>) -> Vec<u16> {
fn as_status(v: &serde_yaml::Value) -> Option<u16> {
v.as_u64().and_then(|n| u16::try_from(n).ok())
}
let Some(value) = value else {
return Vec::new();
};
if let Some(one) = as_status(value) {
return vec![one];
}
if let Some(seq) = value.as_sequence() {
return seq.iter().filter_map(as_status).collect();
}
if let Some(map) = value.as_mapping() {
if let Some(status) = map.get(serde_yaml::Value::String("status".to_string())) {
if let Some(one) = as_status(status) {
return vec![one];
}
if let Some(seq) = status.as_sequence() {
return seq.iter().filter_map(as_status).collect();
}
}
}
Vec::new()
}
impl SimpleTrafficCase {
fn into_wafbench_test(self, index: usize) -> WafBenchTest {
let statuses = extract_expected_statuses(self.expected.as_ref());
let title = self.title.unwrap_or_else(|| format!("case-{}", index + 1));
WafBenchTest {
desc: Some(title.clone()),
test_title: title,
stages: vec![WafBenchStage {
input: Some(WafBenchInput {
dest_addr: None,
headers: self.request.headers,
method: self.request.method,
port: default_port(),
uri: self.request.uri,
data: self.request.body,
version: None,
}),
output: Some(WafBenchOutput {
status: statuses,
response_headers: HashMap::new(),
log_contains: Vec::new(),
no_log_contains: Vec::new(),
}),
stage: None,
}],
}
}
}
pub fn traffic_cases_to_templates(
cases: &[WafBenchTestCase],
) -> Vec<crate::request_gen::RequestTemplate> {
use crate::request_gen::RequestTemplate;
use crate::spec_parser::ApiOperation;
let mut templates = Vec::new();
for case in cases {
let Some(uri) = case
.payloads
.iter()
.find(|p| p.location == PayloadLocation::Uri)
.map(|p| p.value.clone())
.filter(|u| !u.is_empty())
else {
continue;
};
let headers: HashMap<String, String> = case
.payloads
.iter()
.filter(|p| p.location == PayloadLocation::Header)
.filter_map(|p| p.header_name.clone().map(|n| (n, p.value.clone())))
.collect();
let body = case.payloads.iter().find(|p| p.location == PayloadLocation::Body).map(|p| {
serde_json::from_str::<serde_json::Value>(&p.value)
.unwrap_or_else(|_| serde_json::Value::String(p.value.clone()))
});
templates.push(RequestTemplate {
operation: ApiOperation {
method: case.method.clone(),
path: uri,
operation: Default::default(),
operation_id: Some(case.description.clone()),
},
path_params: HashMap::new(),
query_params: HashMap::new(),
headers,
body,
});
}
templates
}
pub fn parse_traffic_file(content: &str, source: &str) -> Result<WafBenchFile> {
match serde_yaml::from_str::<WafBenchFile>(content) {
Ok(file) => Ok(file),
Err(wafbench_err) => match serde_yaml::from_str::<Vec<SimpleTrafficCase>>(content) {
Ok(cases) => {
let parsed = cases.len();
let (omitted, cases): (Vec<_>, Vec<_>) =
cases.into_iter().partition(|c| c.omit_rule == Some(true));
for case in &omitted {
tracing::warn!(
"{source}: skipping `{}` -- it sets `omit_rule: true`, which asks for the \
request to be sent with the rule under test disabled. mockforge sends \
traffic; it cannot toggle your WAF's rules. Sending it anyway would put \
a byte-identical request on the wire as its non-omitted twin while \
asserting the opposite status, so exactly one of the pair would always \
fail regardless of whether the rule works. Run the same file against a \
WAF configuration with that rule disabled to get the baseline.",
case.title.as_deref().unwrap_or("<untitled>")
);
}
tracing::info!(
"{source}: parsed {parsed} case(s) in the simple request/expected format{}",
if omitted.is_empty() {
String::new()
} else {
format!(
"; {} skipped for `omit_rule: true`, {} will be sent",
omitted.len(),
cases.len()
)
}
);
Ok(WafBenchFile {
meta: WafBenchMeta {
author: None,
description: Some(format!("simple traffic file: {source}")),
enabled: true,
name: Some(source.to_string()),
},
tests: cases
.into_iter()
.enumerate()
.map(|(i, c)| c.into_wafbench_test(i))
.collect(),
omitted_count: omitted.len(),
})
}
Err(simple_err) => Err(BenchError::Other(format!(
"Failed to parse traffic file {source}. Two shapes are accepted:\n \
(1) WAFBench document -- a `meta:` mapping plus a `tests:` list. Parse error: {wafbench_err}\n \
(2) simple list -- `- title: .. / request: {{method, uri, headers, body}} / expected: 403`. Parse error: {simple_err}"
))),
},
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WafBenchFile {
pub meta: WafBenchMeta,
#[serde(default)]
pub tests: Vec<WafBenchTest>,
#[serde(default, skip)]
pub omitted_count: usize,
}
#[derive(Debug, Clone)]
pub struct WafBenchTestCase {
pub test_id: String,
pub description: String,
pub rule_id: String,
pub category: SecurityCategory,
pub method: String,
pub payloads: Vec<WafBenchPayload>,
pub expects_block: bool,
}
#[derive(Debug, Clone)]
pub struct WafBenchPayload {
pub location: PayloadLocation,
pub value: String,
pub header_name: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PayloadLocation {
Uri,
Header,
Body,
}
impl std::fmt::Display for PayloadLocation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Uri => write!(f, "uri"),
Self::Header => write!(f, "header"),
Self::Body => write!(f, "body"),
}
}
}
pub struct WafBenchLoader {
test_cases: Vec<WafBenchTestCase>,
stats: WafBenchStats,
}
#[derive(Debug, Clone, Default)]
pub struct TrafficFileSummary {
pub file: String,
pub sent: usize,
pub attack: usize,
pub normal: usize,
pub omitted: usize,
pub other: usize,
}
#[derive(Debug, Clone, Default)]
pub struct WafBenchStats {
pub files_processed: usize,
pub test_cases_loaded: usize,
pub payloads_extracted: usize,
pub by_category: HashMap<SecurityCategory, usize>,
pub parse_errors: Vec<String>,
pub per_file: Vec<TrafficFileSummary>,
}
pub fn summarize_traffic_file(file: &WafBenchFile, source: &str) -> TrafficFileSummary {
let mut attack = 0;
let mut normal = 0;
let mut other = 0;
for test in &file.tests {
let mut has_403 = false;
let mut has_200 = false;
for stage in &test.stages {
if let Some(output) = stage.get_output() {
has_403 |= output.status.contains(&403);
has_200 |= output.status.contains(&200);
}
}
if has_403 {
attack += 1;
} else if has_200 {
normal += 1;
} else {
other += 1;
}
}
TrafficFileSummary {
file: source.to_string(),
sent: file.tests.len(),
attack,
normal,
omitted: file.omitted_count,
other,
}
}
impl WafBenchLoader {
pub fn new() -> Self {
Self {
test_cases: Vec::new(),
stats: WafBenchStats::default(),
}
}
pub fn load_from_pattern(&mut self, pattern: &str) -> Result<()> {
if !pattern.contains('*') && !pattern.contains('?') {
let path = Path::new(pattern);
if path.is_file() {
return self.load_file(path);
} else if path.is_dir() {
return self.load_from_directory(path);
} else {
return Err(BenchError::Other(format!(
"WAFBench path does not exist: {}",
pattern
)));
}
}
let entries = glob(pattern).map_err(|e| {
BenchError::Other(format!("Invalid WAFBench pattern '{}': {}", pattern, e))
})?;
for entry in entries {
match entry {
Ok(path) => {
if path.is_file()
&& path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml")
{
if let Err(e) = self.load_file(&path) {
self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
}
} else if path.is_dir() {
if let Err(e) = self.load_from_directory(&path) {
self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
}
}
}
Err(e) => {
self.stats.parse_errors.push(format!("Glob error: {}", e));
}
}
}
Ok(())
}
pub fn load_from_directory(&mut self, dir: &Path) -> Result<()> {
if !dir.is_dir() {
return Err(BenchError::Other(format!(
"WAFBench path is not a directory: {}",
dir.display()
)));
}
self.load_directory_recursive(dir)?;
Ok(())
}
fn load_directory_recursive(&mut self, dir: &Path) -> Result<()> {
let entries = std::fs::read_dir(dir)
.map_err(|e| BenchError::Other(format!("Failed to read WAFBench directory: {}", e)))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
self.load_directory_recursive(&path)?;
} else if path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml") {
if let Err(e) = self.load_file(&path) {
self.stats.parse_errors.push(format!("{}: {}", path.display(), e));
}
}
}
Ok(())
}
pub fn load_file(&mut self, path: &Path) -> Result<()> {
let content = std::fs::read_to_string(path).map_err(|e| {
BenchError::Other(format!("Failed to read WAFBench file {}: {}", path.display(), e))
})?;
let source = path.display().to_string();
let wafbench_file = parse_traffic_file(&content, &source)?;
if !wafbench_file.meta.enabled {
return Ok(());
}
self.stats.per_file.push(summarize_traffic_file(&wafbench_file, &source));
self.stats.files_processed += 1;
let category = self.detect_category(path, &wafbench_file.meta);
for test in wafbench_file.tests {
if let Some(test_case) = self.parse_test_case(&test, category) {
self.stats.payloads_extracted += test_case.payloads.len();
*self.stats.by_category.entry(category).or_insert(0) += 1;
self.test_cases.push(test_case);
self.stats.test_cases_loaded += 1;
}
}
Ok(())
}
fn detect_category(&self, path: &Path, _meta: &WafBenchMeta) -> SecurityCategory {
let path_str = path.to_string_lossy().to_uppercase();
if path_str.contains("XSS") || path_str.contains("941") {
SecurityCategory::Xss
} else if path_str.contains("SQLI") || path_str.contains("942") {
SecurityCategory::SqlInjection
} else if path_str.contains("RCE") || path_str.contains("932") {
SecurityCategory::CommandInjection
} else if path_str.contains("LFI") || path_str.contains("930") {
SecurityCategory::PathTraversal
} else if path_str.contains("LDAP") {
SecurityCategory::LdapInjection
} else if path_str.contains("XXE") || path_str.contains("XML") {
SecurityCategory::Xxe
} else if path_str.contains("TEMPLATE") || path_str.contains("SSTI") {
SecurityCategory::Ssti
} else {
SecurityCategory::Xss
}
}
fn parse_test_case(
&self,
test: &WafBenchTest,
category: SecurityCategory,
) -> Option<WafBenchTestCase> {
let rule_id = test.test_title.split('-').next().unwrap_or(&test.test_title).to_string();
let mut payloads = Vec::new();
let mut method = "GET".to_string();
let mut expects_block = false;
for stage in &test.stages {
let Some(input) = stage.get_input() else {
continue;
};
method = input.method.clone();
if let Some(output) = stage.get_output() {
if output.status.contains(&403) {
expects_block = true;
}
}
if let Some(uri) = &input.uri {
if !uri.is_empty() {
payloads.push(WafBenchPayload {
location: PayloadLocation::Uri,
value: uri.clone(),
header_name: None,
});
}
}
for (header_name, header_value) in &input.headers {
if !header_value.is_empty() {
payloads.push(WafBenchPayload {
location: PayloadLocation::Header,
value: header_value.clone(),
header_name: Some(header_name.clone()),
});
}
}
if let Some(data) = &input.data {
if !data.is_empty() {
payloads.push(WafBenchPayload {
location: PayloadLocation::Body,
value: data.clone(),
header_name: None,
});
}
}
}
if payloads.is_empty() {
if let Some(stage) = test.stages.first() {
if let Some(input) = stage.get_input() {
if let Some(uri) = &input.uri {
payloads.push(WafBenchPayload {
location: PayloadLocation::Uri,
value: uri.clone(),
header_name: None,
});
}
}
}
}
if payloads.is_empty() {
return None;
}
let description = test.desc.clone().unwrap_or_else(|| format!("CRS Rule {} test", rule_id));
Some(WafBenchTestCase {
test_id: test.test_title.clone(),
description,
rule_id,
category,
method,
payloads,
expects_block,
})
}
#[cfg(test)]
fn looks_like_attack(&self, s: &str) -> bool {
let attack_patterns = [
"<script",
"javascript:",
"onerror=",
"onload=",
"onclick=",
"onfocus=",
"onmouseover=",
"eval(",
"alert(",
"document.",
"window.",
"'--",
"' OR ",
"' AND ",
"1=1",
"UNION SELECT",
"CONCAT(",
"CHAR(",
"../",
"..\\",
"/etc/passwd",
"cmd.exe",
"powershell",
"; ls",
"| cat",
"${",
"{{",
"<%",
"<?",
"<!ENTITY",
"SYSTEM \"",
];
let lower = s.to_lowercase();
attack_patterns.iter().any(|p| lower.contains(&p.to_lowercase()))
}
pub fn test_cases(&self) -> &[WafBenchTestCase] {
&self.test_cases
}
pub fn stats(&self) -> &WafBenchStats {
&self.stats
}
fn decode_form_encoded_body(value: &str) -> String {
let plus_decoded = value.replace('+', " ");
let decoded = urlencoding::decode(&plus_decoded)
.map(|s| s.into_owned())
.unwrap_or(plus_decoded);
Self::strip_form_key(&decoded)
}
fn strip_form_key(value: &str) -> String {
if let Some(eq_pos) = value.find('=') {
let key = &value[..eq_pos];
if !key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return value[eq_pos + 1..].to_string();
}
}
value.to_string()
}
fn ensure_form_encoded(value: &str) -> String {
value
.split('&')
.map(|pair| {
if let Some(eq_pos) = pair.find('=') {
let key = &pair[..eq_pos];
let val = &pair[eq_pos + 1..];
let key_plus = key.replace('+', " ");
let val_plus = val.replace('+', " ");
let decoded_key = urlencoding::decode(&key_plus).unwrap_or(key.into());
let decoded_val = urlencoding::decode(&val_plus).unwrap_or(val.into());
let enc_key = urlencoding::encode(&decoded_key).replace("%20", "+");
let enc_val = urlencoding::encode(&decoded_val).replace("%20", "+");
format!("{enc_key}={enc_val}")
} else {
let pair_plus = pair.replace('+', " ");
let decoded = urlencoding::decode(&pair_plus).unwrap_or(pair.into());
urlencoding::encode(&decoded).replace("%20", "+").to_string()
}
})
.collect::<Vec<_>>()
.join("&")
}
pub fn to_security_payloads(&self) -> Vec<SecurityPayload> {
let mut payloads = Vec::new();
for test_case in &self.test_cases {
let group_id = if test_case.payloads.len() > 1 {
Some(test_case.test_id.clone())
} else {
None
};
for payload in &test_case.payloads {
let payload_str = match payload.location {
PayloadLocation::Body => {
Self::decode_form_encoded_body(&payload.value)
}
PayloadLocation::Uri => {
self.extract_uri_payload(&payload.value)
}
PayloadLocation::Header => {
payload.value.clone()
}
};
let location = match payload.location {
PayloadLocation::Uri => SecurityPayloadLocation::Uri,
PayloadLocation::Header => SecurityPayloadLocation::Header,
PayloadLocation::Body => SecurityPayloadLocation::Body,
};
let mut sec_payload = SecurityPayload::new(
payload_str,
test_case.category,
format!(
"[WAFBench {}] {} ({})",
test_case.rule_id, test_case.description, payload.location
),
)
.high_risk()
.with_location(location);
if let Some(header_name) = &payload.header_name {
sec_payload = sec_payload.with_header_name(header_name.clone());
}
if let Some(gid) = &group_id {
sec_payload = sec_payload.with_group_id(gid.clone());
}
if payload.location == PayloadLocation::Uri && !payload.value.contains('?') {
sec_payload = sec_payload.with_inject_as_path();
}
if payload.location == PayloadLocation::Body {
sec_payload = sec_payload
.with_form_encoded_body(Self::ensure_form_encoded(&payload.value));
}
payloads.push(sec_payload);
}
}
payloads
}
fn extract_uri_payload(&self, value: &str) -> String {
if value.contains('?') {
if let Some(query) = value.split('?').nth(1) {
for param in query.split('&') {
if let Some(val) = param.split('=').nth(1) {
let decoded = urlencoding::decode(val).unwrap_or_else(|_| val.into());
if !decoded.is_empty() {
return decoded.to_string();
}
}
}
}
}
let decoded = urlencoding::decode(value)
.map(|s| s.into_owned())
.unwrap_or_else(|_| value.to_string());
let trimmed = decoded.trim_start_matches('/');
if trimmed.is_empty() {
return decoded;
}
trimmed.to_string()
}
}
impl Default for WafBenchLoader {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_wafbench_yaml() {
let yaml = r#"
meta:
author: test
description: Test XSS rules
enabled: true
name: test.yaml
tests:
- desc: "XSS in URI parameter"
test_title: "941100-1"
stages:
- input:
dest_addr: "127.0.0.1"
headers:
Host: "localhost"
User-Agent: "Mozilla/5.0"
method: "GET"
port: 80
uri: "/test?param=<script>alert(1)</script>"
output:
status: [403]
"#;
let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
assert!(file.meta.enabled);
assert_eq!(file.tests.len(), 1);
assert_eq!(file.tests[0].test_title, "941100-1");
}
#[test]
fn test_detect_category() {
let loader = WafBenchLoader::new();
let meta = WafBenchMeta {
author: None,
description: None,
enabled: true,
name: None,
};
assert_eq!(
loader.detect_category(Path::new("/wafbench/REQUEST-941-XSS/test.yaml"), &meta),
SecurityCategory::Xss
);
assert_eq!(
loader.detect_category(Path::new("/wafbench/REQUEST-942-SQLI/test.yaml"), &meta),
SecurityCategory::SqlInjection
);
}
#[test]
fn test_looks_like_attack() {
let loader = WafBenchLoader::new();
assert!(loader.looks_like_attack("<script>alert(1)</script>"));
assert!(loader.looks_like_attack("' OR '1'='1"));
assert!(loader.looks_like_attack("../../../etc/passwd"));
assert!(loader.looks_like_attack("; ls -la"));
assert!(!loader.looks_like_attack("normal text"));
assert!(!loader.looks_like_attack("hello world"));
}
#[test]
fn test_extract_uri_payload_with_query_params() {
let loader = WafBenchLoader::new();
let uri = "/test?param=%3Cscript%3Ealert(1)%3C/script%3E";
let payload = loader.extract_uri_payload(uri);
assert_eq!(payload, "<script>alert(1)</script>");
}
#[test]
fn test_extract_uri_payload_path_only() {
let loader = WafBenchLoader::new();
let uri = "/1234%20OR%201=1";
let payload = loader.extract_uri_payload(uri);
assert_eq!(payload, "1234 OR 1=1");
let uri2 = "/foo')waitfor%20delay'5%3a0%3a20'--";
let payload2 = loader.extract_uri_payload(uri2);
assert_eq!(payload2, "foo')waitfor delay'5:0:20'--");
let uri3 = "/";
let payload3 = loader.extract_uri_payload(uri3);
assert_eq!(payload3, "/");
}
#[test]
fn test_group_id_assigned_for_multi_part_test_cases() {
let yaml = r#"
meta:
author: test
description: Multi-part test
enabled: true
name: test.yaml
tests:
- desc: "Multi-part attack with URI and header"
test_title: "942290-1"
stages:
- input:
dest_addr: "127.0.0.1"
headers:
Host: "localhost"
User-Agent: "ModSecurity CRS 3 Tests"
method: "GET"
port: 80
uri: "/test?param=attack"
output:
status: [403]
"#;
let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
let mut loader = WafBenchLoader::new();
loader.stats.files_processed += 1;
let category = SecurityCategory::SqlInjection;
for test in &file.tests {
if let Some(test_case) = loader.parse_test_case(test, category) {
loader.test_cases.push(test_case);
}
}
let payloads = loader.to_security_payloads();
assert!(payloads.len() >= 2, "Should have at least 2 payloads");
let group_ids: Vec<_> = payloads.iter().map(|p| p.group_id.clone()).collect();
assert!(
group_ids.iter().all(|g| g.is_some()),
"All payloads in multi-part test should have group_id"
);
assert!(
group_ids.iter().all(|g| g.as_deref() == Some("942290-1")),
"All payloads should share the same group_id"
);
}
#[test]
fn test_single_payload_no_group_id() {
let yaml = r#"
meta:
author: test
description: Single payload test
enabled: true
name: test.yaml
tests:
- desc: "Simple XSS"
test_title: "941100-1"
stages:
- input:
dest_addr: "127.0.0.1"
headers: {}
method: "GET"
port: 80
uri: "/test?param=<script>alert(1)</script>"
output:
status: [403]
"#;
let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
let mut loader = WafBenchLoader::new();
loader.stats.files_processed += 1;
let category = SecurityCategory::Xss;
for test in &file.tests {
if let Some(test_case) = loader.parse_test_case(test, category) {
loader.test_cases.push(test_case);
}
}
let payloads = loader.to_security_payloads();
assert_eq!(payloads.len(), 1, "Should have exactly 1 payload");
assert!(payloads[0].group_id.is_none(), "Single-payload test should NOT have group_id");
}
#[test]
fn test_body_payload_form_url_decoded() {
let yaml = r#"
meta:
author: test
description: Body payload test
enabled: true
name: test.yaml
tests:
- desc: "SQL injection in body"
test_title: "942240-1"
stages:
- stage:
input:
dest_addr: 127.0.0.1
headers:
Host: localhost
method: POST
port: 80
uri: "/"
data: "%22+WAITFOR+DELAY+%270%3A0%3A5%27"
output:
log_contains: id "942240"
"#;
let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
let mut loader = WafBenchLoader::new();
loader.stats.files_processed += 1;
let category = SecurityCategory::SqlInjection;
for test in &file.tests {
if let Some(test_case) = loader.parse_test_case(test, category) {
loader.test_cases.push(test_case);
}
}
let payloads = loader.to_security_payloads();
let body_payload = payloads
.iter()
.find(|p| p.location == SecurityPayloadLocation::Body)
.expect("Should have a body payload");
assert!(
body_payload.payload.contains('"'),
"Body payload should have decoded %22 to double-quote: {}",
body_payload.payload
);
assert!(
body_payload.payload.contains(' '),
"Body payload should have decoded + to space: {}",
body_payload.payload
);
assert!(
!body_payload.payload.contains("%22"),
"Body payload should NOT contain literal %22: {}",
body_payload.payload
);
}
#[test]
fn test_decode_form_encoded_body() {
assert_eq!(
WafBenchLoader::decode_form_encoded_body("%22+WAITFOR+DELAY+%27%0A"),
"\" WAITFOR DELAY '\n"
);
assert_eq!(WafBenchLoader::decode_form_encoded_body("normal+text"), "normal text");
assert_eq!(
WafBenchLoader::decode_form_encoded_body("no+encoding+needed"),
"no encoding needed"
);
assert_eq!(
WafBenchLoader::decode_form_encoded_body("var%3D%3B%3Bdd+foo+bar"),
";;dd foo bar"
);
assert_eq!(WafBenchLoader::decode_form_encoded_body("pay%3Dexec+%28%40%0A"), "exec (@\n");
assert_eq!(WafBenchLoader::decode_form_encoded_body("%22+WAITFOR"), "\" WAITFOR");
}
#[test]
fn test_strip_form_key() {
assert_eq!(WafBenchLoader::strip_form_key("var=;;dd foo bar"), ";;dd foo bar");
assert_eq!(WafBenchLoader::strip_form_key("pay=exec (@\n"), "exec (@\n");
assert_eq!(WafBenchLoader::strip_form_key("pay=DECLARE/**/@x\n"), "DECLARE/**/@x\n");
assert_eq!(WafBenchLoader::strip_form_key("\" WAITFOR DELAY '\n"), "\" WAITFOR DELAY '\n");
assert_eq!(WafBenchLoader::strip_form_key("' OR 1=1"), "' OR 1=1");
assert_eq!(WafBenchLoader::strip_form_key(""), "");
assert_eq!(WafBenchLoader::strip_form_key("var="), "");
}
#[test]
fn test_ensure_form_encoded() {
assert_eq!(
WafBenchLoader::ensure_form_encoded("var=%3B%3Bdd+foo+bar"),
"var=%3B%3Bdd+foo+bar"
);
assert_eq!(WafBenchLoader::ensure_form_encoded("var=;;dd foo bar"), "var=%3B%3Bdd+foo+bar");
assert_eq!(
WafBenchLoader::ensure_form_encoded("var=-------------------&var2=whatever"),
"var=-------------------&var2=whatever"
);
assert_eq!(
WafBenchLoader::ensure_form_encoded("key=%22value%22&other=test+data"),
"key=%22value%22&other=test+data"
);
assert_eq!(
WafBenchLoader::ensure_form_encoded("key=\"value\"&other=test data"),
"key=%22value%22&other=test+data"
);
assert_eq!(WafBenchLoader::ensure_form_encoded("plain text"), "plain+text");
assert_eq!(WafBenchLoader::ensure_form_encoded(""), "");
}
#[test]
fn test_uri_path_only_gets_inject_as_path() {
let yaml = r#"
meta:
author: test
description: Path injection test
enabled: true
name: test.yaml
tests:
- desc: "Path-based SQL injection"
test_title: "942101-1"
stages:
- stage:
input:
dest_addr: 127.0.0.1
headers:
Host: localhost
method: POST
port: 80
uri: "/1234%20OR%201=1"
output:
log_contains: id "942101"
"#;
let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
let mut loader = WafBenchLoader::new();
loader.stats.files_processed += 1;
let category = SecurityCategory::SqlInjection;
for test in &file.tests {
if let Some(test_case) = loader.parse_test_case(test, category) {
loader.test_cases.push(test_case);
}
}
let payloads = loader.to_security_payloads();
let uri_payload = payloads
.iter()
.find(|p| p.location == SecurityPayloadLocation::Uri)
.expect("Should have URI payload");
assert_eq!(
uri_payload.inject_as_path,
Some(true),
"Path-only URI should have inject_as_path=true"
);
}
#[test]
fn test_uri_with_query_no_inject_as_path() {
let yaml = r#"
meta:
author: test
description: Query param test
enabled: true
name: test.yaml
tests:
- desc: "Query-param SQL injection"
test_title: "942100-1"
stages:
- stage:
input:
dest_addr: 127.0.0.1
headers: {}
method: GET
port: 80
uri: "/test?param=1+OR+1%3D1"
output:
log_contains: id "942100"
"#;
let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
let mut loader = WafBenchLoader::new();
loader.stats.files_processed += 1;
let category = SecurityCategory::SqlInjection;
for test in &file.tests {
if let Some(test_case) = loader.parse_test_case(test, category) {
loader.test_cases.push(test_case);
}
}
let payloads = loader.to_security_payloads();
let uri_payload = payloads
.iter()
.find(|p| p.location == SecurityPayloadLocation::Uri)
.expect("Should have URI payload");
assert!(
uri_payload.inject_as_path.is_none(),
"URI with query params should NOT have inject_as_path"
);
}
#[test]
fn test_body_payload_gets_form_encoded_body() {
let yaml = r#"
meta:
author: test
description: Form body test
enabled: true
name: test.yaml
tests:
- desc: "Form-encoded body attack"
test_title: "942432-1"
stages:
- stage:
input:
dest_addr: 127.0.0.1
headers:
Host: localhost
method: POST
port: 80
uri: "/"
data: "var=%3B%3Bdd+foo+bar"
output:
log_contains: id "942432"
"#;
let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
let mut loader = WafBenchLoader::new();
loader.stats.files_processed += 1;
let category = SecurityCategory::SqlInjection;
for test in &file.tests {
if let Some(test_case) = loader.parse_test_case(test, category) {
loader.test_cases.push(test_case);
}
}
let payloads = loader.to_security_payloads();
let body_payload = payloads
.iter()
.find(|p| p.location == SecurityPayloadLocation::Body)
.expect("Should have body payload");
assert!(
body_payload.form_encoded_body.is_some(),
"Body payload should have form_encoded_body set"
);
assert_eq!(
body_payload.form_encoded_body.as_deref().unwrap(),
"var=%3B%3Bdd+foo+bar",
"form_encoded_body should be properly URL-encoded"
);
}
#[test]
fn test_body_payload_decoded_yaml_gets_encoded() {
let yaml = r#"
meta:
author: test
description: Form body test (decoded)
enabled: true
name: test.yaml
tests:
- desc: "Form-encoded body attack (decoded)"
test_title: "942432-2"
stages:
- stage:
input:
dest_addr: 127.0.0.1
headers:
Host: localhost
method: POST
port: 80
uri: "/"
data: "var=;;dd foo bar"
output:
log_contains: id "942432"
"#;
let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
let mut loader = WafBenchLoader::new();
loader.stats.files_processed += 1;
let category = SecurityCategory::SqlInjection;
for test in &file.tests {
if let Some(test_case) = loader.parse_test_case(test, category) {
loader.test_cases.push(test_case);
}
}
let payloads = loader.to_security_payloads();
let body_payload = payloads
.iter()
.find(|p| p.location == SecurityPayloadLocation::Body)
.expect("Should have body payload");
assert!(
body_payload.form_encoded_body.is_some(),
"Body payload should have form_encoded_body set"
);
let encoded = body_payload.form_encoded_body.as_deref().unwrap();
assert!(
encoded.contains("%3B%3B") || encoded.contains("%3b%3b"),
"Semicolons must be URL-encoded: {encoded}"
);
assert!(!encoded.contains(' '), "Spaces must be encoded as + in form body: {encoded}");
assert!(encoded.starts_with("var="), "Form key must be preserved: {encoded}");
}
#[test]
fn test_parse_crs_v33_format() {
let yaml = r#"
meta:
author: "Christian Folini"
description: Various SQL injection tests
enabled: true
name: 942100.yaml
tests:
- test_title: 942100-1
desc: "Simple SQL Injection"
stages:
- stage:
input:
dest_addr: 127.0.0.1
headers:
Host: localhost
method: POST
port: 80
uri: "/"
data: "var=1234 OR 1=1"
version: HTTP/1.0
output:
log_contains: id "942100"
"#;
let file: WafBenchFile = serde_yaml::from_str(yaml).unwrap();
assert!(file.meta.enabled);
assert_eq!(file.tests.len(), 1);
assert_eq!(file.tests[0].test_title, "942100-1");
let stage = &file.tests[0].stages[0];
let input = stage.get_input().expect("Should have input");
assert_eq!(input.method, "POST");
assert_eq!(input.data.as_deref(), Some("var=1234 OR 1=1"));
}
}
#[cfg(test)]
mod simple_traffic_tests {
use super::*;
const SRIKANTH_YAML: &str = r#"
- title: utf-7 charset blocked
request:
method: POST
uri: /graphql
headers:
Content-Type: application/json; charset=utf-7
body: '+AHsAIgBxAHUAZQByAHkAIgA6ACIAewBfAF8AdAB5AHAAZQBuAGEAbQBlAH0AIgB9-'
expected: 403
- title: utf-8 charset allowed
request:
method: POST
uri: /graphql
headers:
Content-Type: application/json; charset=utf-8
body: '{"query":"{__typename}"}'
expected: 200
"#;
#[test]
fn simple_sequence_parses_and_maps_every_field() {
let file = parse_traffic_file(SRIKANTH_YAML, "test_cases.yaml").expect("should parse");
assert_eq!(file.tests.len(), 2);
assert!(file.meta.enabled, "synthesised meta must not disable the file");
let first = &file.tests[0];
assert_eq!(first.test_title, "utf-7 charset blocked");
let stage = &first.stages[0];
let input = stage.input.as_ref().expect("input mapped");
assert_eq!(input.method, "POST");
assert_eq!(input.uri.as_deref(), Some("/graphql"));
assert_eq!(
input.headers.get("Content-Type").map(String::as_str),
Some("application/json; charset=utf-7")
);
assert!(
input.data.as_deref().unwrap().starts_with("+AHsAIgBxAHUAZQ"),
"`body` must map onto WAFBench's `data`, payload intact"
);
assert_eq!(stage.output.as_ref().unwrap().status, vec![403]);
}
#[test]
fn wafbench_document_still_parses() {
let yaml = r#"
meta:
author: crs
enabled: true
name: 941100
tests:
- test_title: 941100-1
stages:
- stage:
input:
method: GET
uri: /?x=<script>alert(1)</script>
output:
status: [403]
"#;
let file = parse_traffic_file(yaml, "941100.yaml").expect("should parse");
assert_eq!(file.tests.len(), 1);
assert_eq!(file.meta.author.as_deref(), Some("crs"));
}
#[test]
fn expected_accepts_scalar_sequence_and_mapping() {
let scalar: serde_yaml::Value = serde_yaml::from_str("403").unwrap();
assert_eq!(extract_expected_statuses(Some(&scalar)), vec![403]);
let seq: serde_yaml::Value = serde_yaml::from_str("[403, 406]").unwrap();
assert_eq!(extract_expected_statuses(Some(&seq)), vec![403, 406]);
let map: serde_yaml::Value = serde_yaml::from_str("status: 403").unwrap();
assert_eq!(extract_expected_statuses(Some(&map)), vec![403]);
let map_seq: serde_yaml::Value = serde_yaml::from_str("status: [403, 406]").unwrap();
assert_eq!(extract_expected_statuses(Some(&map_seq)), vec![403, 406]);
}
#[test]
fn unusable_expected_yields_no_statuses_not_an_error() {
let junk: serde_yaml::Value = serde_yaml::from_str("'not a status'").unwrap();
assert!(extract_expected_statuses(Some(&junk)).is_empty());
assert!(extract_expected_statuses(None).is_empty());
let yaml = "- request:\n uri: /a\n";
let file = parse_traffic_file(yaml, "x.yaml").expect("case without expected still parses");
assert_eq!(file.tests.len(), 1);
assert_eq!(
file.tests[0].stages[0].input.as_ref().unwrap().method,
"GET",
"method defaults"
);
assert_eq!(file.tests[0].test_title, "case-1", "title is synthesised when absent");
}
#[test]
fn parse_error_names_both_accepted_shapes() {
let err = parse_traffic_file("just a string", "bad.yaml").unwrap_err().to_string();
assert!(err.contains("meta"), "must mention the WAFBench shape: {err}");
assert!(err.contains("tests"), "must mention the WAFBench shape: {err}");
assert!(err.contains("expected"), "must mention the simple shape: {err}");
assert!(err.contains("request"), "must mention the simple shape: {err}");
}
}
#[cfg(test)]
mod verbatim_tests {
use super::*;
fn load(yaml: &str) -> Vec<crate::request_gen::RequestTemplate> {
let file = parse_traffic_file(yaml, "t.yaml").expect("parses");
let mut loader = WafBenchLoader::new();
for test in &file.tests {
if let Some(case) = loader.parse_test_case(test, SecurityCategory::Xss) {
loader.test_cases.push(case);
}
}
traffic_cases_to_templates(loader.test_cases())
}
#[test]
fn verbatim_preserves_every_query_parameter() {
let t = load(
"- 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",
);
assert_eq!(t.len(), 1);
let path = t[0].generate_path();
assert!(path.starts_with("/oauth/authorize"), "path must survive, got {path}");
assert!(path.contains("redirect_uri="), "redirect_uri must reach the wire: {path}");
assert!(path.contains("response_type="), "response_type must reach the wire: {path}");
assert!(path.contains("state=s1"), "trailing params must survive: {path}");
assert_eq!(t[0].operation.method, "GET");
}
#[test]
fn verbatim_does_not_reencode_the_uri() {
let t = load(
"- title: encoded\n request:\n method: GET\n uri: /a?u=https%3A%2F%2Fevil.example%2Fx&b=%2E%2E%2F\n",
);
let path = t[0].generate_path();
assert!(path.contains("https%3A%2F%2Fevil.example%2Fx"), "must not decode: {path}");
assert!(path.contains("%2E%2E%2F"), "must not normalise traversal: {path}");
assert!(!path.contains("://evil.example"), "must not decode to a real URL: {path}");
}
#[test]
fn verbatim_carries_headers_and_body() {
let t = load(
"- 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",
);
assert_eq!(t[0].operation.method, "POST");
assert_eq!(
t[0].headers.get("Content-Type").map(String::as_str),
Some("application/json; charset=utf-7"),
"the charset is the attack; it must not be normalised"
);
assert!(t[0].body.is_some(), "body must be carried");
}
#[test]
fn verbatim_passes_through_non_json_body() {
let t = load("- title: form\n request:\n method: POST\n uri: /f\n body: 'a=1&b=<script>'\n");
let body = t[0].body.as_ref().expect("body carried");
assert_eq!(body.as_str(), Some("a=1&b=<script>"), "raw body preserved verbatim");
}
#[test]
fn verbatim_skips_cases_without_a_uri() {
let t = load("- title: no uri\n request:\n method: GET\n");
assert!(t.is_empty(), "a case with no uri yields no request");
}
#[test]
fn omit_rule_cases_are_skipped_not_silently_sent() {
let yaml = r#"
- title: unsupported response_type blocked
omit_rule: false
request:
method: GET
uri: /oauth/authorize?response_type=totally-unsupported&redirect_uri=https%3A%2F%2Fevil.example%2Flanding
expected: 403
- title: "baseline: same payload with rule omitted"
omit_rule: true
request:
method: GET
uri: /oauth/authorize?response_type=totally-unsupported&redirect_uri=https%3A%2F%2Fevil.example%2Flanding
expected: 200
- title: legitimate flow allowed
request:
method: GET
uri: /oauth/authorize?response_type=code&client_id=abc123
expected: 200
"#;
let parsed = parse_traffic_file(yaml, "oauth.yaml").expect("simple format should parse");
let titles: Vec<_> = parsed.tests.iter().map(|t| t.test_title.clone()).collect();
assert_eq!(
parsed.tests.len(),
2,
"the omit_rule:true case must be dropped, got {titles:?}"
);
assert!(
!titles.iter().any(|t| t.contains("rule omitted")),
"baseline case leaked into the run: {titles:?}"
);
assert!(
titles.iter().any(|t| t.contains("unsupported response_type blocked")),
"omit_rule:false must not be treated as omitted: {titles:?}"
);
assert_eq!(parsed.omitted_count, 1, "omitted_count must match the dropped baseline");
}
#[test]
fn traffic_file_summary_splits_attack_normal_omitted() {
let yaml = r#"
- title: blocked
request:
method: GET
uri: /oauth/authorize?response_type=totally-unsupported
expected: 403
- title: allowed
request:
method: GET
uri: /oauth/authorize?response_type=code
expected: 200
- title: baseline
omit_rule: true
request:
method: GET
uri: /oauth/authorize?response_type=totally-unsupported
expected: 200
"#;
let parsed = parse_traffic_file(yaml, "oauth.yaml").expect("parse");
let summary = summarize_traffic_file(&parsed, "oauth.yaml");
assert_eq!(summary.sent, 2);
assert_eq!(summary.attack, 1);
assert_eq!(summary.normal, 1);
assert_eq!(summary.omitted, 1);
assert_eq!(summary.other, 0);
}
#[test]
fn missing_wafbench_path_is_an_error() {
let mut loader = WafBenchLoader::new();
let err = loader
.load_from_pattern("definitely-not-a-real-file-apisix.yaml")
.expect_err("missing path must not look like an empty payload pool");
let msg = err.to_string();
assert!(msg.contains("does not exist"), "error must name the missing path, got: {msg}");
}
}