Skip to main content

systemprompt_api/services/middleware/
bot_detector.rs

1//! Early bot-classification middleware.
2//!
3//! [`detect_bots_early`] inspects the user agent, client IP, and request path
4//! before routing, attaching a [`BotMarker`] (with its [`BotType`]) to the
5//! request extensions so downstream layers can branch on known bots, scanners,
6//! and suspicious traffic without re-parsing headers.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use 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}