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/// 标签名后的字符类是 `[\s/>]` 而不是更宽的 `\w`/`.`:`<linked`、`<objective`
34/// (C 头文件、普通单词前缀)不在其中。`|$` 补上「标签名后就是输入结束」这一格 ——
35/// 载荷被截断或以 `<script` 收尾时不再整条漏报。
36/// 6 条分支合并成 1 条 alternation —— `regex_detect` 对列表里每条 `Regex` 各跑一次
37/// `find`,干净输入下 6 次全文扫描变 1 次。分支顺序 = 原 vec 顺序(同一位置上取最左
38/// 分支;与「按列表顺序取第一条命中的模式」相比偏移量可能不同,档位不变)。
39/// 各分支共同的 `(?i)` 提升为外层 `(?i:…)`,作用域正好覆盖全部 6 条分支。
40static 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            // 强信号:可执行形态,Critical
106            ("<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            // 弱信号:标签出现本身,Low(聚合升级由调用方决定)
111            ("<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    /// 标签族分档:`<script src>` 这类在真实网页里遍地都是,必须是 Low 而不是
120    /// Critical,否则「带外链脚本的正常页面」仍会被整页拒绝。
121    #[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    /// 弱档的边界补齐:`[\s/>]` 要求标签名后**还有**一个字节,于是恰好以 `<script`、
135    /// `<iframe` 收尾的输入(截断的载荷、切片后的文本、`abc<script` 这种拼接)一个都不报。
136    /// `(?:[\s/>]|$)` 补上「输入结束」这一格;字符类本身不能放宽,否则 `<linked`、
137    /// `<objective`(C 头文件、单词前缀)会误报——见下面的干净方向。
138    #[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    /// 事件处理器族。旧枚举只列到 `pointerdown|pointerup`、`mouse(over|out|down|up|move)`、
161    /// `animationstart|animationend`,下面这些曾整体漏检;`onerror` / `onload` / `onclick`
162    /// 是最常见的三个 handle,必须仍然命中。
163    #[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    /// 真实语料。弱上下文模式(`window.location` / `document.cookie` / `eval(` /
182    /// `data:text/html` / `<svg` / `<meta`)曾把下面每一条都判成 Critical,
183    /// 而它们全是普通页面内容 —— 这是「整个正常网页被拒」的根因。
184    #[test]
185    fn real_world_inputs_not_detected() {
186        for input in [
187            // 普通 HTML 页面片段
188            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            // 普通 SVG 文件(`docs/pet.svg` 曾被自家扫描器判 Critical)
194            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            // 普通 JS 文本
199            r#"const url = window.location.href;
200document.cookie = "session=abc; path=/";
201const total = price * qty && discount > 0;
202const out = eval(userCode);"#,
203            // 普通 CSS 块
204            "body { margin: 0; font-family: system-ui, sans-serif; }\n.card { display: flex; gap: 8px; color: #334155; }",
205            // Markdown 表格与 JS 逻辑与表达式
206            "| Name | Age | City |\n|------|-----|------|\n| Ada | 36 | London |",
207            "if (a && b) { run(); }",
208            // 散文里的 DOM 名词
209            "The window.location object gives you the current URL.",
210            "Cookies are read via document.cookie and written with document.write().",
211            // 散文里的 bare `vbscript:`(紧跟空格)不是 scheme 用法
212            "The legacy page used vbscript: syntax in an old IE-only build.",
213            // 含 `on` 前缀的普通属性值:朴素 `on\w*=` 会在这三处误报
214            "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        // near misses: keyword present but not the payload form
240        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}