use std::fmt;
use regex::Regex;
use crate::compiler;
pub const MAX_USER_AGENT_MATCH_LENGTH: usize = 512;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserAgentConfigError {
pub entry: String,
pub reason: String,
}
impl fmt::Display for UserAgentConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid blocked_user_agents pattern '{}': {}",
self.entry, self.reason
)
}
}
impl std::error::Error for UserAgentConfigError {}
#[derive(Debug, Clone, Default)]
pub struct UserAgentFilter {
patterns: Vec<Regex>,
}
impl UserAgentFilter {
pub fn new<I, S>(patterns: I) -> Result<Self, UserAgentConfigError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut compiled = Vec::new();
for pattern in patterns {
let pattern = pattern.as_ref();
let (is_safe, reason) = compiler::validate_pattern_safety(pattern);
if !is_safe {
return Err(UserAgentConfigError {
entry: pattern.to_owned(),
reason: format!("rejected by ReDoS validator ({reason})"),
});
}
let regex = compiler::compile(pattern).map_err(|error| UserAgentConfigError {
entry: pattern.to_owned(),
reason: format!("expected a compilable regular expression ({error})"),
})?;
compiled.push(regex);
}
Ok(Self { patterns: compiled })
}
#[must_use]
pub fn is_blocked(&self, user_agent: &str) -> bool {
let subject: String = user_agent
.chars()
.take(MAX_USER_AGENT_MATCH_LENGTH)
.collect();
self.patterns.iter().any(|regex| regex.is_match(&subject))
}
#[must_use]
pub const fn len(&self) -> usize {
self.patterns.len()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.patterns.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_filter_is_empty_and_never_blocks() {
let filter = UserAgentFilter::default();
assert!(filter.is_empty());
assert_eq!(filter.len(), 0);
for agent in ["sqlmap", "", "Mozilla/5.0"] {
assert!(
!filter.is_blocked(agent),
"{agent} must pass an empty filter"
);
}
}
#[test]
fn matches_are_search_semantics_case_insensitive() {
let filter = UserAgentFilter::new(["sqlmap", "havij"]).expect("valid patterns");
assert_eq!(filter.len(), 2);
assert!(filter.is_blocked("sqlmap"));
assert!(filter.is_blocked("Mozilla/5.0 (SQLMAP/1.8)"));
assert!(filter.is_blocked("user agent: HaViJ probe"));
assert!(
filter.is_blocked("innocent-looking sqlmapbot client"),
"search semantics: any substring occurrence is enough"
);
assert!(!filter.is_blocked("Mozilla/5.0 (X11; Linux x86_64)"));
}
#[test]
fn patterns_are_multiline_and_anchored_patterns_behave_like_python() {
let filter = UserAgentFilter::new(["^bot/"]).expect("valid pattern");
assert!(filter.is_blocked("bot/1.0"));
assert!(!filter.is_blocked("see bot/1.0"));
}
#[test]
fn subject_is_truncated_to_512_code_points() {
let filter = UserAgentFilter::new(["tail-marker"]).expect("valid pattern");
let mut agent = "a".repeat(600);
agent.push_str(" tail-marker");
assert!(
!filter.is_blocked(&agent),
"a match living past the 512-code-point cap must not block"
);
let mut inside = "x".repeat(500);
inside.push_str(" tail-marker");
assert!(
filter.is_blocked(&inside),
"a match inside the cap must block"
);
let mut multibyte = "\u{00e9}".repeat(500);
multibyte.push_str("tail-marker");
assert!(filter.is_blocked(&multibyte));
}
#[test]
fn missing_header_reads_as_the_empty_string() {
let filter = UserAgentFilter::new(["^$"]).expect("valid pattern");
assert!(filter.is_blocked(""), "an empty value matches ^$");
assert!(!filter.is_blocked("Mozilla/5.0"));
}
#[test]
fn new_fails_closed_on_an_uncompilable_pattern() {
let error = UserAgentFilter::new(["(unclosed"]).unwrap_err();
assert_eq!(error.entry, "(unclosed");
assert!(
error
.reason
.starts_with("expected a compilable regular expression"),
"unexpected reason: {}",
error.reason
);
assert!(
error
.to_string()
.starts_with("invalid blocked_user_agents pattern '(unclosed':"),
"unexpected display: {error}"
);
}
#[test]
fn new_fails_closed_on_a_redos_unsafe_pattern() {
let error = UserAgentFilter::new(["(a+)+b"]).unwrap_err();
assert_eq!(error.entry, "(a+)+b");
assert!(
error.reason.contains("rejected by ReDoS validator"),
"unexpected reason: {}",
error.reason
);
assert!(
error
.to_string()
.starts_with("invalid blocked_user_agents pattern '(a+)+b':")
);
}
#[test]
fn first_rejected_entry_wins_and_valid_prefixes_are_not_retained() {
let error = UserAgentFilter::new(["sqlmap", "(bad", "worse]"]).unwrap_err();
assert_eq!(error.entry, "(bad");
}
#[test]
fn clone_shares_nothing_observable() {
let filter = UserAgentFilter::new(["sqlmap"]).expect("valid pattern");
let clone = filter.clone();
assert!(clone.is_blocked("SQLMAP"));
assert_eq!(clone.len(), filter.len());
}
}