systemprompt_api/services/middleware/
bot_detector.rs1use axum::extract::{ConnectInfo, Request};
12use axum::middleware::Next;
13use axum::response::Response;
14use ipnet::IpNet;
15use std::net::SocketAddr;
16use std::sync::Arc;
17use systemprompt_analytics::matches_bot_pattern;
18
19use super::client_addr::resolve_client_ip;
20
21const DATACENTER_IP_PREFIXES: &[&str] = &[
22 "47.79.", "47.82.", "47.88.", "47.89.", "47.90.", "47.91.", "47.92.", "47.93.", "47.94.",
23 "47.95.", "47.96.", "47.97.", "47.98.", "47.99.", "47.100.", "47.101.", "47.102.", "47.103.",
24 "47.104.", "47.105.", "47.106.", "47.107.", "47.108.", "47.109.", "47.110.", "47.111.",
25 "47.112.", "47.113.", "47.114.", "47.115.", "47.116.", "47.117.", "47.118.", "47.119.",
26 "119.29.", "129.28.", "162.14.", "119.3.", "122.112.",
27];
28
29pub(super) const CHROME_MIN_VERSION: i32 = 120;
30
31#[derive(Clone, Debug)]
32pub struct BotMarker {
33 pub is_bot: bool,
34 pub bot_type: BotType,
35 pub user_agent: String,
36 pub ip_address: Option<String>,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum BotType {
41 KnownBot,
42 Scanner,
43 Suspicious,
44 Human,
45}
46
47pub async fn detect_bots_early(
48 mut req: Request,
49 next: Next,
50 trusted_proxies: Arc<Vec<IpNet>>,
51) -> Response {
52 let user_agent = req
53 .headers()
54 .get("user-agent")
55 .and_then(|h| {
56 h.to_str()
57 .map_err(|e| {
58 tracing::trace!(error = %e, "Invalid UTF-8 in user-agent header");
59 e
60 })
61 .ok()
62 })
63 .unwrap_or("")
64 .to_owned();
65
66 let ip_address = resolve_client_ip(
67 req.headers(),
68 req.extensions().get::<ConnectInfo<SocketAddr>>(),
69 &trusted_proxies,
70 )
71 .map(|a| a.to_string());
72 let uri_path = req.uri().path().to_owned();
73
74 let marker = if is_known_bot(&user_agent) {
75 BotMarker {
76 is_bot: true,
77 bot_type: BotType::KnownBot,
78 user_agent: user_agent.clone(),
79 ip_address: ip_address.clone(),
80 }
81 } else if is_datacenter_ip(ip_address.as_deref()) || is_outdated_browser(&user_agent) {
82 BotMarker {
83 is_bot: true,
84 bot_type: BotType::Suspicious,
85 user_agent: user_agent.clone(),
86 ip_address: ip_address.clone(),
87 }
88 } else if is_scanner_request(&uri_path, &user_agent) {
89 BotMarker {
90 is_bot: false,
91 bot_type: BotType::Scanner,
92 user_agent: user_agent.clone(),
93 ip_address: ip_address.clone(),
94 }
95 } else {
96 BotMarker {
97 is_bot: false,
98 bot_type: BotType::Human,
99 user_agent: user_agent.clone(),
100 ip_address,
101 }
102 };
103
104 req.extensions_mut().insert(Arc::new(marker));
105 next.run(req).await
106}
107
108pub fn is_datacenter_ip(ip: Option<&str>) -> bool {
109 ip.is_some_and(|ip_addr| {
110 DATACENTER_IP_PREFIXES
111 .iter()
112 .any(|prefix| ip_addr.starts_with(prefix))
113 })
114}
115
116pub fn is_known_bot(user_agent: &str) -> bool {
117 matches_bot_pattern(user_agent)
118}
119
120pub fn is_outdated_browser(user_agent: &str) -> bool {
121 let ua_lower = user_agent.to_lowercase();
122
123 if let Some(pos) = ua_lower.find("chrome/") {
124 let version_str = &ua_lower[pos + 7..];
125 if let Some(dot_pos) = version_str.find('.')
126 && let Ok(major) = version_str[..dot_pos].parse::<i32>()
127 {
128 return major < CHROME_MIN_VERSION;
129 }
130 }
131
132 false
133}
134
135pub fn is_scanner_request(path: &str, user_agent: &str) -> bool {
136 let scanner_paths = [
137 ".env",
138 ".git",
139 ".php",
140 "admin",
141 "wp-admin",
142 "wp-login",
143 "administrator",
144 ".sql",
145 ".backup",
146 "config.php",
147 "web.config",
148 ".well-known",
149 ];
150
151 let scanner_agents = [
152 "masscan",
153 "nmap",
154 "nikto",
155 "sqlmap",
156 "metasploit",
157 "nessus",
158 "openvas",
159 "zap",
160 "burp",
161 "qualys",
162 ];
163
164 let path_lower = path.to_lowercase();
165 let ua_lower = user_agent.to_lowercase();
166
167 scanner_paths.iter().any(|p| path_lower.contains(p))
168 || scanner_agents.iter().any(|a| ua_lower.contains(a))
169}