1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
14 Regex::new(concat!(
15 r"(?i:on(?:error|load\w*|click|mouse\w*|key\w*|focus|blur|before\w*|change|submit|reset|scroll|resize|abort|select|start|drag\w*|drop|play|pause|ended|volumechange|animation\w*|transition\w*|touch\w*|pointer\w*|wheel|auxclick|can\w*|close|cue\w*|dblclick|duration\w*|emptied|fullscreenchange|got\w*|input|invalid|lost\w*|offline|online|page\w*|pop\w*|progress|ratechange|securitypolicyviolation|seek\w*|show|stalled|suspend|timeupdate|toggle|waiting|paste|copy|cut|contextmenu)\s*=",
21 r"|javascript:[^\s]",
23 r"|vbscript:[^\s])",
26 ))
27 .unwrap()
28});
29
30static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
41 Regex::new(concat!(
42 r"(?i:<script(?:[\s/>]|$)",
43 r"|<iframe(?:[\s/>]|$)",
44 r"|<embed(?:[\s/>]|$)",
45 r"|<object(?:[\s/>]|$)",
46 r"|<link(?:[\s/>]|$)",
47 r"|expression\s*\()",
48 ))
49 .unwrap()
50});
51
52pub struct XssDetector;
53
54impl Detector for XssDetector {
55 fn name(&self) -> &'static str {
56 "xss"
57 }
58
59 fn detect(&self, input: &str) -> Option<DetectionResult> {
60 regex_detect(
61 std::slice::from_ref(&*STRONG_PATTERNS),
62 self.name(),
63 AttackCategory::Injection,
64 Severity::Critical,
65 "XSS cross-site scripting detected",
66 input,
67 )
68 .or_else(|| {
69 regex_detect(
70 std::slice::from_ref(&*WEAK_PATTERNS),
71 self.name(),
72 AttackCategory::Injection,
73 Severity::Low,
74 "XSS tag present (weak signal)",
75 input,
76 )
77 })
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 fn det() -> XssDetector {
86 XssDetector
87 }
88
89 fn assert_hit(input: &str) {
90 assert_hit_at(input, Severity::Critical);
91 }
92
93 fn assert_hit_at(input: &str, severity: Severity) {
94 crate::test_helpers::assert_detected(&det(), input, AttackCategory::Injection, severity);
95 }
96
97 #[test]
98 fn name_is_xss() {
99 assert_eq!(det().name(), "xss");
100 }
101
102 #[test]
103 fn detects_common_payloads() {
104 for (input, severity) in [
105 ("<img src=x onerror=alert(1)>", Severity::Critical),
107 ("javascript:alert(document.cookie)", Severity::Critical),
108 ("vbscript:msgbox(1)", Severity::Critical),
109 ("<svg onload=alert(1)>", Severity::Critical),
110 ("<script>alert(1)</script>", Severity::Low),
112 ("<iframe src=\"//evil.com\"></iframe>", Severity::Low),
113 ("data:text/html,<script>alert(1)</script>", Severity::Low),
114 ] {
115 assert_hit_at(input, severity);
116 }
117 }
118
119 #[test]
122 fn tag_presence_is_low_not_critical() {
123 for input in [
124 r#"<link rel="stylesheet" href="/s.css">"#,
125 r#"<script src="/app.js" defer></script>"#,
126 r#"<iframe src="https://maps.example.com/embed"></iframe>"#,
127 r#"<embed src="/whitepaper.pdf" type="application/pdf">"#,
128 r#"<object data="logo.svg" type="image/svg+xml"></object>"#,
129 ] {
130 assert_hit_at(input, Severity::Low);
131 }
132 }
133
134 #[test]
139 fn truncated_tags_at_end_of_input_are_detected() {
140 for input in [
141 "<script",
142 "abc<script",
143 "<SCRIPT",
144 "<iframe",
145 "<embed",
146 "<object",
147 "<link",
148 ] {
149 assert_hit_at(input, Severity::Low);
150 }
151 for input in [
152 "#include <linked_list.h>",
153 "The header <linked_list.h> defines the node type.",
154 "See <objective-c-spec.md> for the plan.",
155 ] {
156 crate::test_helpers::assert_clean(&det(), input);
157 }
158 }
159
160 #[test]
164 fn detects_event_handler_families() {
165 for input in [
166 "<img src=x onerror=alert(1)>",
167 "<body onload=alert(1)>",
168 "<div onclick=alert(1)>x</div>",
169 "<div onmouseenter=alert(1)>",
170 "<img src=x onpointerover=alert(1)>",
171 "<input onpaste=alert(1)>",
172 "<b ondragstart=alert(1)>",
173 "<div oncontextmenu=alert(1)>",
174 "<div onanimationiteration=x>",
175 "<div onbeforeunload=x>",
176 ] {
177 assert_hit(input);
178 }
179 }
180
181 #[test]
185 fn real_world_inputs_not_detected() {
186 for input in [
187 r#"<!DOCTYPE html>
189<html lang="en"><head><title>Docs</title></head>
190<body><h1>Welcome</h1><p>Read the <a href="/about">about page</a>.</p>
191<img src="/logo.png" alt="logo" width="120">
192</body></html>"#,
193 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
195 <path d="M12 2 2 22h20z" fill="none" stroke="#333" stroke-width="2"/>
196 <circle cx="12" cy="12" r="4" fill="#09f"/>
197</svg>"##,
198 r#"const url = window.location.href;
200document.cookie = "session=abc; path=/";
201const total = price * qty && discount > 0;
202const out = eval(userCode);"#,
203 "body { margin: 0; font-family: system-ui, sans-serif; }\n.card { display: flex; gap: 8px; color: #334155; }",
205 "| Name | Age | City |\n|------|-----|------|\n| Ada | 36 | London |",
207 "if (a && b) { run(); }",
208 "The window.location object gives you the current URL.",
210 "Cookies are read via document.cookie and written with document.write().",
211 "The legacy page used vbscript: syntax in an old IE-only build.",
213 "phone=555-1234",
215 "zone=UTC&timezone=Europe/Berlin",
216 "<div one=1>",
217 ] {
218 crate::test_helpers::assert_clean(&det(), input);
219 }
220 }
221
222 #[test]
223 fn benign_inputs_not_detected() {
224 for input in [
225 "Hello, this is a normal text input. Nothing suspicious here.",
226 "The weather today is sunny with a high of 25 degrees.",
227 "Please call the office at 555-1234 for assistance.",
228 "Welcome to our website, please enjoy your stay.",
229 ] {
230 assert!(det().detect(input).is_none(), "false positive: {input}");
231 }
232 }
233
234 #[test]
235 fn edge_cases() {
236 assert!(det().detect("").is_none());
237 assert!(det().detect(" \t\n ").is_none());
238 assert!(det().detect("こんにちは世界 你好").is_none());
239 assert!(det().detect("script alert(1)").is_none());
241 assert!(det().detect("javascript alert(1)").is_none());
242 assert!(det().detect("vbscript msgbox(1)").is_none());
243 assert!(det().detect("evaluate this expression carefully").is_none());
244 }
245
246 #[test]
247 fn obfuscated_variants_detected() {
248 for (input, severity) in [
249 ("<img src=x OnErRoR=alert(1)>", Severity::Critical),
250 ("JaVaScRiPt:alert(1)", Severity::Critical),
251 ("<SVG/onload=alert(1)>", Severity::Critical),
252 ("<SCRIPT>alert(1)</SCRIPT>", Severity::Low),
253 ] {
254 assert_hit_at(input, severity);
255 }
256 }
257}