1use 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
10pub static XSS_RULES: &[Rule] = &[
13 Rule {
14 id: "xss-script-tag",
15 pattern: r"(?i)<script[\s>/]",
17 severity: Severity::Critical,
18 paranoia: 1,
19 },
20 Rule {
21 id: "xss-javascript-proto",
22 pattern: r"(?i)javascript\s*:[^()]*[a-z_$][\w$]*\(",
30 severity: Severity::Critical,
31 paranoia: 1,
32 },
33 Rule {
34 id: "xss-event-handler",
35 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 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 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 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 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 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#[derive(Default)]
110pub struct XssModule {
111 rule_set: Option<RegexSet>,
112 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 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 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}