Skip to main content

waf_detection/
xss.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4use regex::RegexSet;
5use tracing::warn;
6use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
7
8use crate::{all_matches, body_str_values, inspectable_header_values, Rule};
9
10// ── rules ─────────────────────────────────────────────────────────────────────
11
12pub static XSS_RULES: &[Rule] = &[
13    Rule {
14        id: "xss-script-tag",
15        // <script followed by whitespace, > or /, covering <script>, <script >, <script/>.
16        pattern: r"(?i)<script[\s>/]",
17        severity: Severity::Critical,
18        paranoia: 1,
19    },
20    Rule {
21        id: "xss-javascript-proto",
22        // `javascript:` scheme leading into a function CALL: scheme, then any run of
23        // non-paren chars, then an identifier immediately followed by `(`. This is the
24        // FP-precise form (Fase 10b): the bare `(?i)javascript\s*:` matched benign prose
25        // like "JavaScript: Basics of JavaScript Language" (Critical/PL1 → 403). The
26        // `regex` crate has no lookaround (§8 linear-time), so precision is by structure:
27        // an attack `javascript:alert(…)` has the call, prose has no `(` after the colon.
28        // (Coverage of call-less sinks / entity-obfuscated schemes is later-batch work.)
29        pattern: r"(?i)javascript\s*:[^()]*[a-z_$][\w$]*\(",
30        severity: Severity::Critical,
31        paranoia: 1,
32    },
33    Rule {
34        id: "xss-event-handler",
35        // Inline event handlers: onerror=, onclick=, onload=, onmouseover=, etc.
36        // Anchored to the real handler names (NOT `on\w+`, which matched benign
37        // query params like ?online=true, ?onsale=1 at Critical/PL1).
38        pattern: r"(?i)\bon(?:error|load|click|auxclick|mouse\w+|pointer\w+|focus|blur|change|submit|key\w+|abort|drag\w+|drop|input|wheel|scroll|toggle|beforetoggle|select|reset|resize|contextmenu|animation\w+|transition\w+|play|pause|ended|canplay|copy|cut|paste)\s*=",
39        severity: Severity::Critical,
40        paranoia: 1,
41    },
42    Rule {
43        id: "xss-dangerous-tag",
44        // Tags that are not used for normal content but are common XSS vectors.
45        pattern: r"(?i)<(?:iframe|object|embed|applet|link|meta|base)[\s>/]",
46        severity: Severity::Warning,
47        paranoia: 2,
48    },
49    Rule {
50        id: "xss-eval",
51        pattern: r"(?i)\beval\s*\(",
52        severity: Severity::Warning,
53        paranoia: 2,
54    },
55    Rule {
56        id: "xss-js-sink-call",
57        // A JS sink invoked directly (`alert(…)`, `prompt(…)`) or via the
58        // parenthesized form (`(alert)(1)`) — gotestwaf xss-scripting bypasses without
59        // a tag/handler/scheme, and also the literal `alert(1)` left inside entity-
60        // obfuscated polyglots. NO `\s*` before `(`: that keeps benign prose with a
61        // paren ("confirm (your order)") clean while still catching the attack forms.
62        // Warning/PL2 = accumulation-only on this high-traffic module (anti-FP).
63        pattern: r"(?i)(?:\b(?:alert|confirm|prompt)(?:\?\.)?\(|\(\s*(?:alert|confirm|prompt)\s*\)\s*\()",
64        severity: Severity::Warning,
65        paranoia: 2,
66    },
67    Rule {
68        id: "xss-js-sink-invocation",
69        // A JS sink invoked via Function.prototype method: `alert.call(…)`,
70        // `confirm.apply(…)`, `prompt.bind(…)` — gotestwaf xss-scripting bypasses that
71        // carry no tag/handler/scheme (`confirm.call(null,1)`, `alert.apply(null,[1])`).
72        // `.call(`/`.apply(`/`.bind(` on a sink name is near-zero FP (prose never writes
73        // it), but Warning/PL2 keeps it accumulation-only on this high-traffic module.
74        pattern: r"(?i)\b(?:alert|confirm|prompt|eval)\s*\.\s*(?:call|apply|bind)\s*\(",
75        severity: Severity::Warning,
76        paranoia: 2,
77    },
78    Rule {
79        id: "xss-document-cookie",
80        // Dot OR bracket access: `document.cookie`, `document["cookie"]`,
81        // `document['cookie']` (gotestwaf community-xss bracket-notation bypass).
82        pattern: r#"(?i)document\s*(?:\.\s*cookie|\[\s*['"]cookie)"#,
83        severity: Severity::Warning,
84        paranoia: 2,
85    },
86    Rule {
87        id: "xss-vbscript-proto",
88        pattern: r"(?i)vbscript\s*:",
89        severity: Severity::Notice,
90        paranoia: 3,
91    },
92    Rule {
93        id: "xss-data-html-uri",
94        // data:text/html URIs used to execute scripts via src or href attributes.
95        pattern: r"(?i)data\s*:\s*text/html",
96        severity: Severity::Notice,
97        paranoia: 3,
98    },
99    Rule {
100        id: "xss-innerhtml",
101        pattern: r"(?i)\.innerHTML\s*=",
102        severity: Severity::Notice,
103        paranoia: 3,
104    },
105];
106
107// ── module ────────────────────────────────────────────────────────────────────
108
109#[derive(Default)]
110pub struct XssModule {
111    rule_set: Option<RegexSet>,
112    /// Rules active at the configured paranoia level, index-aligned with `rule_set`.
113    active_rules: Vec<&'static Rule>,
114}
115
116impl XssModule {
117    pub fn new() -> Self {
118        Self::default()
119    }
120}
121
122impl WafModule for XssModule {
123    fn id(&self) -> &str {
124        "xss"
125    }
126
127    fn phase(&self) -> Phase {
128        Phase::Body
129    }
130
131    fn init(&mut self, cfg: &Config) {
132        let pl = cfg.waf.paranoia_level;
133        self.active_rules = XSS_RULES.iter().filter(|r| r.paranoia <= pl).collect();
134        self.rule_set = Some(
135            RegexSet::new(self.active_rules.iter().map(|r| r.pattern))
136                .expect("XSS rule compilation failed — check patterns at startup"),
137        );
138    }
139
140    fn inspect(&self, ctx: &RequestContext) -> Decision {
141        let Some(rule_set) = &self.rule_set else {
142            return Decision::Allow;
143        };
144
145        // XSS also inspects the normalized path (in-URL tag injection).
146        let path = std::iter::once(ctx.normalized.path.as_str());
147        let query = ctx.normalized.query_params.iter().map(|(_, v)| v.as_str());
148        let cookies = ctx.normalized.cookies.iter().map(|(_, v)| v.as_str());
149        let body_vals = body_str_values(&ctx.normalized.body);
150        let body = body_vals.iter().map(String::as_str);
151        let derived = ctx.normalized.derived_decoded.iter().map(String::as_str);
152
153        // P1-B: also scan the allowlisted request headers (Referer / X-Forwarded-* / custom x-*).
154        let headers = inspectable_header_values(ctx);
155        let matched = all_matches(rule_set, path.chain(query).chain(cookies).chain(body).chain(derived).chain(headers));
156        if matched.is_empty() {
157            return Decision::Allow;
158        }
159
160        let items: Vec<ScoreItem> = matched
161            .iter()
162            .map(|&idx| {
163                let rule = self.active_rules[idx];
164                warn!(
165                    request_id = %ctx.request_id,
166                    rule_id = %rule.id,
167                    severity = ?rule.severity,
168                    "xss detection"
169                );
170                ScoreItem {
171                    rule_id: rule.id.to_string(),
172                    severity: rule.severity,
173                }
174            })
175            .collect();
176
177        Decision::Scores(items)
178    }
179}