Skip to main content

security_rust/injection/
xss.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7/// 强信号:单独出现即可判 Critical —— 攻击者可控的「可执行」形态。
8/// 正常内容里几乎不会出现,出现即是 XSS。
9/// 3 条分支合并成 1 条 alternation —— `regex_detect` 对列表里每条 `Regex` 各跑一次
10/// `find`,干净输入下 3 次全文扫描变 1 次。分支顺序 = 原 vec 顺序(同一位置上取最左
11/// 分支;与「按列表顺序取第一条命中的模式」相比偏移量可能不同,档位不变)。
12/// 各分支共同的 `(?i)` 提升为外层 `(?i:…)`,作用域正好覆盖全部 3 条分支。
13static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
14    Regex::new(concat!(
15        // 事件处理器白名单:每个分支都必须以字面量 `on` 开头,否则 `phone=`、`zone=`、
16        // `one=` 这类普通属性会误报。分支覆盖 handler 族(`mouse\w*` 等),
17        // 而不是逐个枚举成员 —— 枚举漏掉的 `onmouseenter` / `onpointerover` /
18        // `onpaste` / `ondragstart` / `oncontextmenu` / `onanimationiteration` /
19        // `onbeforeunload` 曾整体漏检。
20        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        // 只认「scheme 后紧跟非空白」的形式,裸的 `javascript:` 短语(散文、文档)不报。
22        r"|javascript:[^\s]",
23        // 与 `javascript:` 同样只认「scheme 后紧跟非空白」:散文里的 bare `vbscript:`
24        // 不报(老 IE 时代的技术文档里满地都是),`vbscript:msgbox(1)` 照报。
25        r"|vbscript:[^\s])",
26    ))
27    .unwrap()
28});
29
30/// 弱信号:标签 / 函数名「出现」本身在正常页面里极常见 —— `<script src="/app.js">`
31/// 几乎每个网页都有 —— 但它确实是 XSS 的载体。所以报 Low 而不是 Critical:
32/// 单条 5 分不至于触发拒绝,由调用方按聚合分(多条叠加)决定,误报旋钮留给上层。
33/// 6 条分支合并成 1 条 alternation —— `regex_detect` 对列表里每条 `Regex` 各跑一次
34/// `find`,干净输入下 6 次全文扫描变 1 次。分支顺序 = 原 vec 顺序(同一位置上取最左
35/// 分支;与「按列表顺序取第一条命中的模式」相比偏移量可能不同,档位不变)。
36/// 各分支共同的 `(?i)` 提升为外层 `(?i:…)`,作用域正好覆盖全部 6 条分支。
37static 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            // 强信号:可执行形态,Critical
103            ("<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            // 弱信号:标签出现本身,Low(聚合升级由调用方决定)
108            ("<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    /// 标签族分档:`<script src>` 这类在真实网页里遍地都是,必须是 Low 而不是
117    /// Critical,否则「带外链脚本的正常页面」仍会被整页拒绝。
118    #[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    /// 事件处理器族。旧枚举只列到 `pointerdown|pointerup`、`mouse(over|out|down|up|move)`、
132    /// `animationstart|animationend`,下面这些曾整体漏检;`onerror` / `onload` / `onclick`
133    /// 是最常见的三个 handle,必须仍然命中。
134    #[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    /// 真实语料。弱上下文模式(`window.location` / `document.cookie` / `eval(` /
153    /// `data:text/html` / `<svg` / `<meta`)曾把下面每一条都判成 Critical,
154    /// 而它们全是普通页面内容 —— 这是「整个正常网页被拒」的根因。
155    #[test]
156    fn real_world_inputs_not_detected() {
157        for input in [
158            // 普通 HTML 页面片段
159            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            // 普通 SVG 文件(`docs/pet.svg` 曾被自家扫描器判 Critical)
165            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            // 普通 JS 文本
170            r#"const url = window.location.href;
171document.cookie = "session=abc; path=/";
172const total = price * qty && discount > 0;
173const out = eval(userCode);"#,
174            // 普通 CSS 块
175            "body { margin: 0; font-family: system-ui, sans-serif; }\n.card { display: flex; gap: 8px; color: #334155; }",
176            // Markdown 表格与 JS 逻辑与表达式
177            "| Name | Age | City |\n|------|-----|------|\n| Ada | 36 | London |",
178            "if (a && b) { run(); }",
179            // 散文里的 DOM 名词
180            "The window.location object gives you the current URL.",
181            "Cookies are read via document.cookie and written with document.write().",
182            // 散文里的 bare `vbscript:`(紧跟空格)不是 scheme 用法
183            "The legacy page used vbscript: syntax in an old IE-only build.",
184            // 含 `on` 前缀的普通属性值:朴素 `on\w*=` 会在这三处误报
185            "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        // near misses: keyword present but not the payload form
211        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}