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(|| {
38 Regex::new(concat!(
39 r"(?i:<script[\s/>]",
40 r"|<iframe[\s/>]",
41 r"|<embed[\s/>]",
42 r"|<object[\s/>]",
43 r"|<link[\s/>]",
44 r"|expression\s*\()",
45 ))
46 .unwrap()
47});
48
49pub struct XssDetector;
50
51impl Detector for XssDetector {
52 fn name(&self) -> &'static str {
53 "xss"
54 }
55
56 fn detect(&self, input: &str) -> Option<DetectionResult> {
57 regex_detect(
58 std::slice::from_ref(&*STRONG_PATTERNS),
59 self.name(),
60 AttackCategory::Injection,
61 Severity::Critical,
62 "XSS cross-site scripting detected",
63 input,
64 )
65 .or_else(|| {
66 regex_detect(
67 std::slice::from_ref(&*WEAK_PATTERNS),
68 self.name(),
69 AttackCategory::Injection,
70 Severity::Low,
71 "XSS tag present (weak signal)",
72 input,
73 )
74 })
75 }
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81
82 fn det() -> XssDetector {
83 XssDetector
84 }
85
86 fn assert_hit(input: &str) {
87 assert_hit_at(input, Severity::Critical);
88 }
89
90 fn assert_hit_at(input: &str, severity: Severity) {
91 crate::test_helpers::assert_detected(&det(), input, AttackCategory::Injection, severity);
92 }
93
94 #[test]
95 fn name_is_xss() {
96 assert_eq!(det().name(), "xss");
97 }
98
99 #[test]
100 fn detects_common_payloads() {
101 for (input, severity) in [
102 ("<img src=x onerror=alert(1)>", Severity::Critical),
104 ("javascript:alert(document.cookie)", Severity::Critical),
105 ("vbscript:msgbox(1)", Severity::Critical),
106 ("<svg onload=alert(1)>", Severity::Critical),
107 ("<script>alert(1)</script>", Severity::Low),
109 ("<iframe src=\"//evil.com\"></iframe>", Severity::Low),
110 ("data:text/html,<script>alert(1)</script>", Severity::Low),
111 ] {
112 assert_hit_at(input, severity);
113 }
114 }
115
116 #[test]
119 fn tag_presence_is_low_not_critical() {
120 for input in [
121 r#"<link rel="stylesheet" href="/s.css">"#,
122 r#"<script src="/app.js" defer></script>"#,
123 r#"<iframe src="https://maps.example.com/embed"></iframe>"#,
124 r#"<embed src="/whitepaper.pdf" type="application/pdf">"#,
125 r#"<object data="logo.svg" type="image/svg+xml"></object>"#,
126 ] {
127 assert_hit_at(input, Severity::Low);
128 }
129 }
130
131 #[test]
135 fn detects_event_handler_families() {
136 for input in [
137 "<img src=x onerror=alert(1)>",
138 "<body onload=alert(1)>",
139 "<div onclick=alert(1)>x</div>",
140 "<div onmouseenter=alert(1)>",
141 "<img src=x onpointerover=alert(1)>",
142 "<input onpaste=alert(1)>",
143 "<b ondragstart=alert(1)>",
144 "<div oncontextmenu=alert(1)>",
145 "<div onanimationiteration=x>",
146 "<div onbeforeunload=x>",
147 ] {
148 assert_hit(input);
149 }
150 }
151
152 #[test]
156 fn real_world_inputs_not_detected() {
157 for input in [
158 r#"<!DOCTYPE html>
160<html lang="en"><head><title>Docs</title></head>
161<body><h1>Welcome</h1><p>Read the <a href="/about">about page</a>.</p>
162<img src="/logo.png" alt="logo" width="120">
163</body></html>"#,
164 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
166 <path d="M12 2 2 22h20z" fill="none" stroke="#333" stroke-width="2"/>
167 <circle cx="12" cy="12" r="4" fill="#09f"/>
168</svg>"##,
169 r#"const url = window.location.href;
171document.cookie = "session=abc; path=/";
172const total = price * qty && discount > 0;
173const out = eval(userCode);"#,
174 "body { margin: 0; font-family: system-ui, sans-serif; }\n.card { display: flex; gap: 8px; color: #334155; }",
176 "| Name | Age | City |\n|------|-----|------|\n| Ada | 36 | London |",
178 "if (a && b) { run(); }",
179 "The window.location object gives you the current URL.",
181 "Cookies are read via document.cookie and written with document.write().",
182 "The legacy page used vbscript: syntax in an old IE-only build.",
184 "phone=555-1234",
186 "zone=UTC&timezone=Europe/Berlin",
187 "<div one=1>",
188 ] {
189 crate::test_helpers::assert_clean(&det(), input);
190 }
191 }
192
193 #[test]
194 fn benign_inputs_not_detected() {
195 for input in [
196 "Hello, this is a normal text input. Nothing suspicious here.",
197 "The weather today is sunny with a high of 25 degrees.",
198 "Please call the office at 555-1234 for assistance.",
199 "Welcome to our website, please enjoy your stay.",
200 ] {
201 assert!(det().detect(input).is_none(), "false positive: {input}");
202 }
203 }
204
205 #[test]
206 fn edge_cases() {
207 assert!(det().detect("").is_none());
208 assert!(det().detect(" \t\n ").is_none());
209 assert!(det().detect("こんにちは世界 你好").is_none());
210 assert!(det().detect("script alert(1)").is_none());
212 assert!(det().detect("javascript alert(1)").is_none());
213 assert!(det().detect("vbscript msgbox(1)").is_none());
214 assert!(det().detect("evaluate this expression carefully").is_none());
215 }
216
217 #[test]
218 fn obfuscated_variants_detected() {
219 for (input, severity) in [
220 ("<img src=x OnErRoR=alert(1)>", Severity::Critical),
221 ("JaVaScRiPt:alert(1)", Severity::Critical),
222 ("<SVG/onload=alert(1)>", Severity::Critical),
223 ("<SCRIPT>alert(1)</SCRIPT>", Severity::Low),
224 ] {
225 assert_hit_at(input, severity);
226 }
227 }
228}