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