guard_core_engine/user_agent.rs
1//! The blocked user-agent pattern filter.
2//!
3//! This is the Rust family's port of the reference engine's user-agent
4//! matching (`guard_core/core/checks/helpers.py::check_user_agent_allowed`
5//! and `guard_core/_utils/access_control.py::is_user_agent_allowed`, backed
6//! by `guard_core/_utils/detection_scan.py::_user_agent_matches_blocked_pattern`).
7//! One call, [`UserAgentFilter::is_blocked`], answers whether a
8//! `User-Agent` header value matches any configured blocked pattern:
9//!
10//! ```text
11//! subject = user_agent truncated to the first 512 code points
12//! blocked = any pattern matches the subject (search semantics,
13//! case-insensitive, multiline)
14//! ```
15//!
16//! The config constructor mirrors the reference's
17//! `_validate_blocked_user_agents_value`: every entry is a regular
18//! expression, and a pattern the ReDoS validator flags is a config error
19//! (the reference raises `ValueError` at config construction). This port
20//! also fails closed on an entry the regex engine cannot compile - the
21//! reference would instead raise at request time and trip the pipeline's
22//! fail-secure 500, so the failure moves earlier without changing what a
23//! valid config does.
24//!
25//! Matching semantics mirror the reference compiler exactly: the Python
26//! side compiles with `re.IGNORECASE | re.MULTILINE` and scans with
27//! `finditer` (search semantics, not fullmatch), and the 512-code-point
28//! truncation is `_MAX_USER_AGENT_MATCH_LENGTH`. Patterns are evaluated in
29//! configured order and the first match wins.
30//!
31//! # Example
32//!
33//! ```
34//! use guard_core_engine::user_agent::UserAgentFilter;
35//!
36//! let filter = UserAgentFilter::new(["sqlmap", r"^\S*\(compatible; Googlebot\)"])
37//! .expect("valid patterns");
38//!
39//! // Search semantics: a substring match is enough, case-insensitively.
40//! assert!(filter.is_blocked("Mozilla/5.0 (sqlmap/1.8)"));
41//! assert!(filter.is_blocked("SQLMAP"));
42//! assert!(!filter.is_blocked("Mozilla/5.0 (X11; Linux x86_64)"));
43//!
44//! // A missing User-Agent header reads as the empty string, which only a
45//! // pattern targeting emptiness can block.
46//! assert!(!filter.is_blocked(""));
47//!
48//! // The default (no patterns) never blocks.
49//! assert!(!UserAgentFilter::default().is_blocked("sqlmap"));
50//! ```
51
52use std::fmt;
53
54use regex::Regex;
55
56use crate::compiler;
57
58/// The match subject cap: a `User-Agent` header value is truncated to its
59/// first 512 code points before pattern evaluation
60/// (`_MAX_USER_AGENT_MATCH_LENGTH` in the reference).
61pub const MAX_USER_AGENT_MATCH_LENGTH: usize = 512;
62
63/// An invalid `blocked_user_agents` entry: the config error
64/// [`UserAgentFilter::new`] fails closed with.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct UserAgentConfigError {
67 /// The rejected pattern.
68 pub entry: String,
69 /// Why it was rejected (the ReDoS validator's reason, or the compile
70 /// error).
71 pub reason: String,
72}
73
74impl fmt::Display for UserAgentConfigError {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 write!(
77 f,
78 "invalid blocked_user_agents pattern '{}': {}",
79 self.entry, self.reason
80 )
81 }
82}
83
84impl std::error::Error for UserAgentConfigError {}
85
86/// The compiled blocked user-agent patterns.
87///
88/// Build it once at startup with [`UserAgentFilter::new`] (which fails
89/// closed on an unsafe or uncompilable pattern) and evaluate header values
90/// with [`UserAgentFilter::is_blocked`]. The default value holds no
91/// patterns and never blocks. Cheap to clone; clones share the compiled
92/// regexes.
93#[derive(Debug, Clone, Default)]
94pub struct UserAgentFilter {
95 patterns: Vec<Regex>,
96}
97
98impl UserAgentFilter {
99 /// Compile and validate the blocked patterns, failing closed on the
100 /// first rejected entry.
101 ///
102 /// Every entry is validated with the engine's ReDoS validator (the
103 /// reference's `validate_pattern_safety`, max content length 512) and
104 /// compiled case-insensitive + multiline, the reference compiler's
105 /// flags.
106 ///
107 /// # Errors
108 ///
109 /// [`UserAgentConfigError`] naming the first entry that is flagged
110 /// ReDoS-unsafe or that the regex engine cannot compile.
111 pub fn new<I, S>(patterns: I) -> Result<Self, UserAgentConfigError>
112 where
113 I: IntoIterator<Item = S>,
114 S: AsRef<str>,
115 {
116 let mut compiled = Vec::new();
117 for pattern in patterns {
118 let pattern = pattern.as_ref();
119 let (is_safe, reason) = compiler::validate_pattern_safety(pattern);
120 if !is_safe {
121 return Err(UserAgentConfigError {
122 entry: pattern.to_owned(),
123 reason: format!("rejected by ReDoS validator ({reason})"),
124 });
125 }
126 let regex = compiler::compile(pattern).map_err(|error| UserAgentConfigError {
127 entry: pattern.to_owned(),
128 reason: format!("expected a compilable regular expression ({error})"),
129 })?;
130 compiled.push(regex);
131 }
132 Ok(Self { patterns: compiled })
133 }
134
135 /// Whether the `User-Agent` value matches any blocked pattern.
136 ///
137 /// The subject is the value truncated to its first
138 /// [`MAX_USER_AGENT_MATCH_LENGTH`] code points (the reference's
139 /// `_MAX_USER_AGENT_MATCH_LENGTH` slice); matching is search semantics
140 /// under the reference's case-insensitive + multiline flags. A missing
141 /// header reads as the empty string.
142 #[must_use]
143 pub fn is_blocked(&self, user_agent: &str) -> bool {
144 let subject: String = user_agent
145 .chars()
146 .take(MAX_USER_AGENT_MATCH_LENGTH)
147 .collect();
148 self.patterns.iter().any(|regex| regex.is_match(&subject))
149 }
150
151 /// How many patterns the filter holds.
152 #[must_use]
153 pub const fn len(&self) -> usize {
154 self.patterns.len()
155 }
156
157 /// Whether the filter holds no patterns (it never blocks).
158 #[must_use]
159 pub const fn is_empty(&self) -> bool {
160 self.patterns.is_empty()
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn default_filter_is_empty_and_never_blocks() {
170 let filter = UserAgentFilter::default();
171 assert!(filter.is_empty());
172 assert_eq!(filter.len(), 0);
173 for agent in ["sqlmap", "", "Mozilla/5.0"] {
174 assert!(
175 !filter.is_blocked(agent),
176 "{agent} must pass an empty filter"
177 );
178 }
179 }
180
181 #[test]
182 fn matches_are_search_semantics_case_insensitive() {
183 let filter = UserAgentFilter::new(["sqlmap", "havij"]).expect("valid patterns");
184 assert_eq!(filter.len(), 2);
185 assert!(filter.is_blocked("sqlmap"));
186 assert!(filter.is_blocked("Mozilla/5.0 (SQLMAP/1.8)"));
187 assert!(filter.is_blocked("user agent: HaViJ probe"));
188 assert!(
189 filter.is_blocked("innocent-looking sqlmapbot client"),
190 "search semantics: any substring occurrence is enough"
191 );
192 assert!(!filter.is_blocked("Mozilla/5.0 (X11; Linux x86_64)"));
193 }
194
195 #[test]
196 fn patterns_are_multiline_and_anchored_patterns_behave_like_python() {
197 // ^ with MULTILINE anchors per line, like the reference compiler's
198 // re.IGNORECASE | re.MULTILINE.
199 let filter = UserAgentFilter::new(["^bot/"]).expect("valid pattern");
200 assert!(filter.is_blocked("bot/1.0"));
201 assert!(!filter.is_blocked("see bot/1.0"));
202 }
203
204 #[test]
205 fn subject_is_truncated_to_512_code_points() {
206 let filter = UserAgentFilter::new(["tail-marker"]).expect("valid pattern");
207 let mut agent = "a".repeat(600);
208 agent.push_str(" tail-marker");
209 assert!(
210 !filter.is_blocked(&agent),
211 "a match living past the 512-code-point cap must not block"
212 );
213 let mut inside = "x".repeat(500);
214 inside.push_str(" tail-marker");
215 assert!(
216 filter.is_blocked(&inside),
217 "a match inside the cap must block"
218 );
219 // Code points, not bytes: the cap lands past a multi-byte prefix.
220 let mut multibyte = "\u{00e9}".repeat(500);
221 multibyte.push_str("tail-marker");
222 assert!(filter.is_blocked(&multibyte));
223 }
224
225 #[test]
226 fn missing_header_reads_as_the_empty_string() {
227 let filter = UserAgentFilter::new(["^$"]).expect("valid pattern");
228 assert!(filter.is_blocked(""), "an empty value matches ^$");
229 assert!(!filter.is_blocked("Mozilla/5.0"));
230 }
231
232 #[test]
233 fn new_fails_closed_on_an_uncompilable_pattern() {
234 let error = UserAgentFilter::new(["(unclosed"]).unwrap_err();
235 assert_eq!(error.entry, "(unclosed");
236 assert!(
237 error
238 .reason
239 .starts_with("expected a compilable regular expression"),
240 "unexpected reason: {}",
241 error.reason
242 );
243 assert!(
244 error
245 .to_string()
246 .starts_with("invalid blocked_user_agents pattern '(unclosed':"),
247 "unexpected display: {error}"
248 );
249 }
250
251 #[test]
252 fn new_fails_closed_on_a_redos_unsafe_pattern() {
253 // Nested quantifiers are the canonical catastrophic-backtracking
254 // shape the reference validator rejects.
255 let error = UserAgentFilter::new(["(a+)+b"]).unwrap_err();
256 assert_eq!(error.entry, "(a+)+b");
257 assert!(
258 error.reason.contains("rejected by ReDoS validator"),
259 "unexpected reason: {}",
260 error.reason
261 );
262 assert!(
263 error
264 .to_string()
265 .starts_with("invalid blocked_user_agents pattern '(a+)+b':")
266 );
267 }
268
269 #[test]
270 fn first_rejected_entry_wins_and_valid_prefixes_are_not_retained() {
271 let error = UserAgentFilter::new(["sqlmap", "(bad", "worse]"]).unwrap_err();
272 assert_eq!(error.entry, "(bad");
273 }
274
275 #[test]
276 fn clone_shares_nothing_observable() {
277 let filter = UserAgentFilter::new(["sqlmap"]).expect("valid pattern");
278 let clone = filter.clone();
279 assert!(clone.is_blocked("SQLMAP"));
280 assert_eq!(clone.len(), filter.len());
281 }
282}