use zentinel_modsec::ModSecurity;
fn run(rules: &str, content_type: &str, body: &[u8]) -> bool {
let m = ModSecurity::from_string(rules).expect("rules should load");
let mut tx = m.new_transaction();
tx.process_uri("/api", "POST", "HTTP/1.1").unwrap();
if !content_type.is_empty() {
tx.add_request_header("Content-Type", content_type).unwrap();
}
tx.process_request_headers().unwrap();
tx.append_request_body(body).unwrap();
tx.process_request_body().unwrap();
tx.has_intervention()
}
fn contains(body: &[u8], needle: &str) -> bool {
let rules =
format!("SecRuleEngine On\nSecRule ARGS \"@contains {needle}\" \"id:1,phase:2,deny\"");
run(&rules, "application/xml", body)
}
const SQLI_RULE: &str = "SecRuleEngine On\n\
SecRule ARGS \"@detectSQLi\" \"id:942100,phase:2,deny,status:403\"";
const BODY_ERROR_RULE: &str = "SecRuleEngine On\n\
SecRule REQBODY_ERROR \"!@eq 0\" \"id:200002,phase:2,deny,status:400\"";
#[test]
fn sqli_in_element_text_is_detected() {
assert!(run(
SQLI_RULE,
"application/xml",
br#"<order><q>1 UNION SELECT password FROM users</q></order>"#
));
}
#[test]
fn sqli_in_an_attribute_is_detected() {
assert!(run(
SQLI_RULE,
"application/xml",
br#"<order q="1 UNION SELECT password FROM users"/>"#
));
}
#[test]
fn sqli_in_cdata_is_detected() {
assert!(run(
SQLI_RULE,
"application/xml",
br#"<o><q><![CDATA[1 UNION SELECT password FROM users]]></q></o>"#
));
}
#[test]
fn sqli_nested_deeply_is_detected() {
assert!(run(
SQLI_RULE,
"application/xml",
br#"<a><b><c><d>1 UNION SELECT password FROM users</d></c></b></a>"#
));
}
#[test]
fn clean_xml_traffic_passes() {
assert!(!run(
SQLI_RULE,
"application/xml",
br#"<order><q>laptop</q><qty>2</qty></order>"#
));
}
#[test]
fn xml_content_types_are_recognised() {
let payload = br#"<o><q>1 UNION SELECT password FROM users</q></o>"#;
for ct in [
"application/xml",
"text/xml",
"APPLICATION/XML",
"application/xml; charset=utf-8",
"application/soap+xml",
"application/atom+xml",
] {
assert!(run(SQLI_RULE, ct, payload), "{ct} should be parsed as XML");
}
}
#[test]
fn ctl_can_force_the_xml_processor() {
let rules = "SecRuleEngine On\n\
SecAction \"id:1,phase:1,pass,nolog,ctl:requestBodyProcessor=XML\"\n\
SecRule ARGS \"@detectSQLi\" \"id:942100,phase:2,deny\"";
assert!(run(
rules,
"",
br#"<o><q>1 UNION SELECT password FROM users</q></o>"#
));
}
#[test]
fn reqbody_processor_reports_xml() {
let rules = "SecRuleEngine On\n\
SecRule REQBODY_PROCESSOR \"@streq XML\" \"id:1,phase:2,deny\"";
assert!(run(rules, "application/xml", br#"<a>x</a>"#));
}
#[test]
fn elements_are_named_by_their_path() {
let rules = "SecRuleEngine On\n\
SecRule ARGS:xml.order.item \"@streq widget\" \"id:1,phase:2,deny\"";
assert!(run(
rules,
"application/xml",
br#"<order><item>widget</item></order>"#
));
}
#[test]
fn attributes_are_named_with_an_at_sign() {
let rules = "SecRuleEngine On\n\
SecRule ARGS:xml.order.item.@id \"@streq 7\" \"id:1,phase:2,deny\"";
assert!(run(
rules,
"application/xml",
br#"<order><item id="7">widget</item></order>"#
));
}
#[test]
fn predefined_entities_are_decoded() {
assert!(contains(br#"<a>x&y</a>"#, "x&y"));
assert!(contains(br#"<a>a<b</a>"#, "a<b"));
}
#[test]
fn numeric_character_references_are_decoded() {
assert!(contains(br#"<a>UNION SELECT x</a>"#, "UNION SELECT"));
assert!(contains(br#"<a>UNION SELECT x</a>"#, "UNION SELECT"));
}
#[test]
fn a_payload_split_by_an_entity_reference_is_reassembled() {
assert!(run(
SQLI_RULE,
"application/xml",
br#"<o><q>1 UNION SELECT password FROM users</q></o>"#
));
}
#[test]
fn an_external_entity_is_not_resolved() {
let xxe = br#"<?xml version="1.0"?>
<!DOCTYPE d [<!ENTITY x SYSTEM "file:///etc/passwd">]>
<d>&x;</d>"#;
assert!(
!contains(xxe, "root:"),
"external entity content must never reach ARGS"
);
}
#[test]
fn an_entity_expansion_bomb_does_not_expand() {
let bomb = br#"<?xml version="1.0"?><!DOCTYPE lolz [
<!ENTITY lol "lol">
<!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
<!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
<!ENTITY lol5 "&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;">
<!ENTITY lol6 "&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;">
<!ENTITY lol7 "&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;">
<!ENTITY lol8 "&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;">
<!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]><lolz>&lol9;</lolz>"#;
let start = std::time::Instant::now();
let _ = run(SQLI_RULE, "application/xml", bomb);
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(500),
"entity expansion should not be happening at all, took {elapsed:?}"
);
}
#[test]
fn an_unexpanded_custom_entity_sets_reqbody_error() {
let hidden = br#"<?xml version="1.0"?>
<!DOCTYPE d [<!ENTITY p "1 UNION SELECT password FROM users">]>
<d>&p;</d>"#;
assert!(
!contains(hidden, "UNION SELECT"),
"the entity must not be expanded"
);
assert!(
run(BODY_ERROR_RULE, "application/xml", hidden),
"but the operator must be able to see that something was not inspected"
);
}
#[test]
fn ordinary_xml_does_not_set_reqbody_error() {
assert!(!run(
BODY_ERROR_RULE,
"application/xml",
br#"<order><q>laptop</q></order>"#
));
assert!(!run(
BODY_ERROR_RULE,
"application/xml",
br#"<order><q>Bell & Sons <Ltd></q></order>"#
));
}
#[test]
fn mismatched_tags_set_reqbody_error() {
assert!(run(BODY_ERROR_RULE, "application/xml", b"<a><b></a>"));
}
#[test]
fn truncated_markup_sets_reqbody_error() {
assert!(run(BODY_ERROR_RULE, "application/xml", b"<a><b>text"));
}
#[test]
fn an_empty_body_is_not_a_parse_error() {
assert!(!run(BODY_ERROR_RULE, "application/xml", b""));
assert!(!run(BODY_ERROR_RULE, "application/xml", b" \n\t "));
}
#[test]
fn deeply_nested_xml_is_reported_rather_than_silently_truncated() {
let deep = format!("{}payload{}", "<a>".repeat(500), "</a>".repeat(500));
assert!(run(BODY_ERROR_RULE, "application/xml", deep.as_bytes()));
}
#[test]
fn a_huge_number_of_elements_is_reported() {
let many: String = (0..5000).map(|i| format!("<v>{i}</v>")).collect();
let doc = format!("<root>{many}</root>");
assert!(run(BODY_ERROR_RULE, "application/xml", doc.as_bytes()));
}
#[test]
fn a_malformed_body_does_not_abort_the_transaction() {
let rules = "SecRuleEngine On\n\
SecRule REQUEST_URI \"@contains /api\" \"id:1,phase:2,deny\"";
assert!(run(rules, "application/xml", b"<a><b></a>"));
}
#[test]
fn a_non_xml_content_type_is_not_parsed_as_xml() {
let rules = "SecRuleEngine On\n\
SecRule ARGS:xml.a \"@rx .\" \"id:1,phase:2,deny\"";
assert!(!run(rules, "text/plain", b"<a>value</a>"));
}