Skip to main content

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 verdict = crate::redos::validate_pattern_safety(
120                pattern,
121                &crate::redos::SafetyMode::CostVerdict {
122                    // The reference validates UA rules against the max
123                    // user-agent match length (512).
124                    max_content_length: Some(MAX_USER_AGENT_MATCH_LENGTH),
125                },
126            );
127            let (is_safe, reason) = (verdict.safe, verdict.reason.to_string());
128            if !is_safe {
129                return Err(UserAgentConfigError {
130                    entry: pattern.to_owned(),
131                    reason: format!("rejected by ReDoS validator ({reason})"),
132                });
133            }
134            let regex = compiler::compile(pattern).map_err(|error| UserAgentConfigError {
135                entry: pattern.to_owned(),
136                reason: format!("expected a compilable regular expression ({error})"),
137            })?;
138            compiled.push(regex);
139        }
140        Ok(Self { patterns: compiled })
141    }
142
143    /// Compile app-authored blocked patterns without the `ReDoS`
144    /// subprocess validation, keeping the compile semantics
145    /// (case-insensitive + multiline, the reference compiler's flags).
146    ///
147    /// This is the per-request lane for the reference's per-route
148    /// `RouteConfig.blocked_user_agents`: the Python reference runs the
149    /// route list through the same matcher per request (`fnmatch`-based,
150    /// no safety validation on the per-request path), and the adapter
151    /// route resolver hands over app-authored lists whose patterns the
152    /// application itself authored. Config-file surfaces (the global
153    /// `blocked_user_agents`) must keep using [`UserAgentFilter::new`],
154    /// which validates every entry with the engine's `ReDoS` validator.
155    ///
156    /// # Errors
157    ///
158    /// [`UserAgentConfigError`] naming the first entry the regex engine
159    /// cannot compile.
160    pub fn from_trusted_patterns<I, S>(patterns: I) -> Result<Self, UserAgentConfigError>
161    where
162        I: IntoIterator<Item = S>,
163        S: AsRef<str>,
164    {
165        let mut compiled = Vec::new();
166        for pattern in patterns {
167            let pattern = pattern.as_ref();
168            let regex = compiler::compile(pattern).map_err(|error| UserAgentConfigError {
169                entry: pattern.to_owned(),
170                reason: format!("expected a compilable regular expression ({error})"),
171            })?;
172            compiled.push(regex);
173        }
174        Ok(Self { patterns: compiled })
175    }
176
177    /// Whether the `User-Agent` value matches any blocked pattern.
178    ///
179    /// The subject is the value truncated to its first
180    /// [`MAX_USER_AGENT_MATCH_LENGTH`] code points (the reference's
181    /// `_MAX_USER_AGENT_MATCH_LENGTH` slice); matching is search semantics
182    /// under the reference's case-insensitive + multiline flags. A missing
183    /// header reads as the empty string.
184    #[must_use]
185    pub fn is_blocked(&self, user_agent: &str) -> bool {
186        let subject: String = user_agent
187            .chars()
188            .take(MAX_USER_AGENT_MATCH_LENGTH)
189            .collect();
190        self.patterns.iter().any(|regex| regex.is_match(&subject))
191    }
192
193    /// How many patterns the filter holds.
194    #[must_use]
195    pub const fn len(&self) -> usize {
196        self.patterns.len()
197    }
198
199    /// Whether the filter holds no patterns (it never blocks).
200    #[must_use]
201    pub const fn is_empty(&self) -> bool {
202        self.patterns.is_empty()
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn default_filter_is_empty_and_never_blocks() {
212        let filter = UserAgentFilter::default();
213        assert!(filter.is_empty());
214        assert_eq!(filter.len(), 0);
215        for agent in ["sqlmap", "", "Mozilla/5.0"] {
216            assert!(
217                !filter.is_blocked(agent),
218                "{agent} must pass an empty filter"
219            );
220        }
221    }
222
223    #[test]
224    fn trusted_patterns_keep_the_compile_semantics_without_the_subprocess() {
225        let filter = UserAgentFilter::from_trusted_patterns(["sqlmap", "havij"])
226            .expect("compilable patterns");
227        assert!(filter.is_blocked("Mozilla/5.0 (SQLMAP/1.8)"));
228        assert!(filter.is_blocked("user agent: HaViJ probe"));
229        assert!(!filter.is_blocked("Mozilla/5.0 (compatible)"));
230
231        // An uncompilable entry still fails closed (compile errors are
232        // independent of the safety subprocess).
233        let error = UserAgentFilter::from_trusted_patterns(["(["]).unwrap_err();
234        assert_eq!(error.entry, "([");
235    }
236
237    #[test]
238    fn matches_are_search_semantics_case_insensitive() {
239        let filter = UserAgentFilter::new(["sqlmap", "havij"]).expect("valid patterns");
240        assert_eq!(filter.len(), 2);
241        assert!(filter.is_blocked("sqlmap"));
242        assert!(filter.is_blocked("Mozilla/5.0 (SQLMAP/1.8)"));
243        assert!(filter.is_blocked("user agent: HaViJ probe"));
244        assert!(
245            filter.is_blocked("innocent-looking sqlmapbot client"),
246            "search semantics: any substring occurrence is enough"
247        );
248        assert!(!filter.is_blocked("Mozilla/5.0 (X11; Linux x86_64)"));
249    }
250
251    #[test]
252    fn patterns_are_multiline_and_anchored_patterns_behave_like_python() {
253        // ^ with MULTILINE anchors per line, like the reference compiler's
254        // re.IGNORECASE | re.MULTILINE.
255        let filter = UserAgentFilter::new(["^bot/"]).expect("valid pattern");
256        assert!(filter.is_blocked("bot/1.0"));
257        assert!(!filter.is_blocked("see bot/1.0"));
258    }
259
260    #[test]
261    fn subject_is_truncated_to_512_code_points() {
262        let filter = UserAgentFilter::new(["tail-marker"]).expect("valid pattern");
263        let mut agent = "a".repeat(600);
264        agent.push_str(" tail-marker");
265        assert!(
266            !filter.is_blocked(&agent),
267            "a match living past the 512-code-point cap must not block"
268        );
269        let mut inside = "x".repeat(500);
270        inside.push_str(" tail-marker");
271        assert!(
272            filter.is_blocked(&inside),
273            "a match inside the cap must block"
274        );
275        // Code points, not bytes: the cap lands past a multi-byte prefix.
276        let mut multibyte = "\u{00e9}".repeat(500);
277        multibyte.push_str("tail-marker");
278        assert!(filter.is_blocked(&multibyte));
279    }
280
281    #[test]
282    fn missing_header_reads_as_the_empty_string() {
283        let filter = UserAgentFilter::new(["^$"]).expect("valid pattern");
284        assert!(filter.is_blocked(""), "an empty value matches ^$");
285        assert!(!filter.is_blocked("Mozilla/5.0"));
286    }
287
288    #[test]
289    fn new_fails_closed_on_an_uncompilable_pattern() {
290        // The full safety chain validates every entry first, so an
291        // uncompilable pattern is rejected there (the validator subsumes
292        // the compile check).
293        let error = UserAgentFilter::new(["(unclosed"]).unwrap_err();
294        assert_eq!(error.entry, "(unclosed");
295        assert!(
296            error.reason.starts_with("rejected by ReDoS validator ("),
297            "unexpected reason: {}",
298            error.reason
299        );
300        assert!(
301            error
302                .to_string()
303                .starts_with("invalid blocked_user_agents pattern '(unclosed':"),
304            "unexpected display: {error}"
305        );
306    }
307
308    #[test]
309    fn new_fails_closed_on_a_redos_unsafe_pattern() {
310        // Nested quantifiers are the canonical catastrophic-backtracking
311        // shape the reference validator rejects.
312        let error = UserAgentFilter::new(["(a+)+b"]).unwrap_err();
313        assert_eq!(error.entry, "(a+)+b");
314        assert!(
315            error.reason.contains("rejected by ReDoS validator"),
316            "unexpected reason: {}",
317            error.reason
318        );
319        assert!(
320            error
321                .to_string()
322                .starts_with("invalid blocked_user_agents pattern '(a+)+b':")
323        );
324    }
325
326    #[test]
327    fn first_rejected_entry_wins_and_valid_prefixes_are_not_retained() {
328        let error = UserAgentFilter::new(["sqlmap", "(bad", "worse]"]).unwrap_err();
329        assert_eq!(error.entry, "(bad");
330    }
331
332    #[test]
333    fn clone_shares_nothing_observable() {
334        let filter = UserAgentFilter::new(["sqlmap"]).expect("valid pattern");
335        let clone = filter.clone();
336        assert!(clone.is_blocked("SQLMAP"));
337        assert_eq!(clone.len(), filter.len());
338    }
339
340    #[test]
341    fn a_chain_safe_pattern_the_engine_cannot_compile_is_rejected() {
342        // Lookarounds pass the Python-syntax safety chain but the concrete
343        // engine compiler rejects them, so the config constructor fails.
344        let error = UserAgentFilter::new(["(?=x)a"]).expect_err("rejected");
345        assert_eq!(error.entry, "(?=x)a");
346        assert!(
347            error
348                .reason
349                .starts_with("expected a compilable regular expression"),
350            "{}",
351            error.reason
352        );
353    }
354}