From 03a1a1bc992295eb523789295aa6ef86ccb88b40 Mon Sep 17 00:00:00 2001 From: NetworkCats Date: Fri, 6 Mar 2026 00:18:55 +0800 Subject: [PATCH] feat: detect HTTP library User-Agents and return plain text responses --- src/cli_detect.rs | 79 ------------ src/handlers.rs | 6 +- src/lib.rs | 2 +- src/ua_detect.rs | 290 +++++++++++++++++++++++++++++++++++++++++++ tests/integration.rs | 113 +++++++++++++++++ 5 files changed, 407 insertions(+), 83 deletions(-) delete mode 100644 src/cli_detect.rs create mode 100644 src/ua_detect.rs diff --git a/src/cli_detect.rs b/src/cli_detect.rs deleted file mode 100644 index 8c81a94..0000000 --- a/src/cli_detect.rs +++ /dev/null @@ -1,79 +0,0 @@ -const CLI_PREFIXES: &[&str] = &[ - "curl/", - "Wget/", - "HTTPie/", - "xh/", - "fetch", - "PowerShell/", - "WindowsPowerShell/", -]; - -pub fn is_cli_user_agent(user_agent: &str) -> bool { - CLI_PREFIXES - .iter() - .any(|prefix| user_agent.starts_with(prefix)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn detects_curl() { - assert!(is_cli_user_agent("curl/8.7.1")); - } - - #[test] - fn detects_wget() { - assert!(is_cli_user_agent("Wget/1.21.4")); - } - - #[test] - fn detects_httpie() { - assert!(is_cli_user_agent("HTTPie/3.2.2")); - } - - #[test] - fn detects_xh() { - assert!(is_cli_user_agent("xh/0.22.0")); - } - - #[test] - fn detects_fetch() { - assert!(is_cli_user_agent("fetch libfetch/2.0")); - } - - #[test] - fn detects_powershell() { - assert!(is_cli_user_agent("PowerShell/7.4.1")); - } - - #[test] - fn detects_windows_powershell() { - assert!(is_cli_user_agent("WindowsPowerShell/5.1")); - } - - #[test] - fn rejects_browser() { - assert!(!is_cli_user_agent( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" - )); - } - - #[test] - fn rejects_empty() { - assert!(!is_cli_user_agent("")); - } - - #[test] - fn matching_is_case_sensitive() { - // Real CLI tools send consistent casing; we intentionally do not - // match case-insensitively to avoid false positives. - assert!(!is_cli_user_agent("CURL/8.7.1")); - assert!(!is_cli_user_agent("Curl/8.7.1")); - assert!(!is_cli_user_agent("WGET/1.21.4")); - assert!(!is_cli_user_agent("httpie/3.2.2")); - assert!(!is_cli_user_agent("XH/0.22.0")); - assert!(!is_cli_user_agent("powershell/7.4.1")); - } -} diff --git a/src/handlers.rs b/src/handlers.rs index 9a83b55..9747b09 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -6,7 +6,7 @@ use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{Html, IntoResponse, Redirect, Response}; -use crate::cli_detect::is_cli_user_agent; +use crate::ua_detect::is_plain_text_agent; use crate::db::{self, SharedDb}; use crate::error::AppError; use crate::i18n::{self, Locale}; @@ -449,7 +449,7 @@ pub async fn root_redirect( .unwrap_or(""); // CLI clients and JSON requests bypass i18n entirely. - if is_cli_user_agent(ua) && query.ip.is_none() { + if is_plain_text_agent(ua) && query.ip.is_none() { return match extract_client_ip(&headers, state.dev_mode) { Ok(ip) => format!("{ip}\n").into_response(), Err(e) => e.into_response(), @@ -515,7 +515,7 @@ pub async fn root( .and_then(|v| v.to_str().ok()) .unwrap_or(""); - if is_cli_user_agent(ua) && query.ip.is_none() { + if is_plain_text_agent(ua) && query.ip.is_none() { return match extract_client_ip(&headers, state.dev_mode) { Ok(ip) => format!("{ip}\n").into_response(), Err(e) => e.into_response(), diff --git a/src/lib.rs b/src/lib.rs index f59b38d..1745419 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -pub mod cli_detect; +pub mod ua_detect; pub mod config; pub mod db; pub mod error; diff --git a/src/ua_detect.rs b/src/ua_detect.rs new file mode 100644 index 0000000..a61446e --- /dev/null +++ b/src/ua_detect.rs @@ -0,0 +1,290 @@ +const CLI_PREFIXES: &[&str] = &[ + "curl/", + "Wget/", + "HTTPie/", + "xh/", + "fetch", + "PowerShell/", + "WindowsPowerShell/", +]; + +const HTTP_LIB_PREFIXES: &[&str] = &[ + // Python + "python-requests/", + "python-httpx/", + "python-urllib3/", + "aiohttp/", + "httplib2/", + // Go + "Go-http-client/", + // Ruby + "rest-client/", + "Ruby", + "Faraday v", + "HTTParty/", + // Java / JVM + "Java/", + "Apache-HttpClient/", + "okhttp/", + // .NET / C# + "HttpClient/", + // Node.js + "node-fetch/", + "axios/", + "undici", + "got (", + // PHP + "GuzzleHttp/", + "Symfony HttpClient/", + // Rust + "reqwest/", + "hyper/", + "ureq/", + // Dart / Flutter + "Dart/", + // Perl + "libwww-perl/", + "LWP::Simple/", + // R + "libcurl/", + "httr/", + // Elixir + "hackney/", + "mint/", +]; + +fn is_cli_user_agent(user_agent: &str) -> bool { + CLI_PREFIXES + .iter() + .any(|prefix| user_agent.starts_with(prefix)) +} + +fn is_http_lib_user_agent(user_agent: &str) -> bool { + HTTP_LIB_PREFIXES + .iter() + .any(|prefix| user_agent.starts_with(prefix)) +} + +pub fn is_plain_text_agent(user_agent: &str) -> bool { + is_cli_user_agent(user_agent) || is_http_lib_user_agent(user_agent) +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- CLI detection --- + + #[test] + fn detects_curl() { + assert!(is_plain_text_agent("curl/8.7.1")); + } + + #[test] + fn detects_wget() { + assert!(is_plain_text_agent("Wget/1.21.4")); + } + + #[test] + fn detects_httpie() { + assert!(is_plain_text_agent("HTTPie/3.2.2")); + } + + #[test] + fn detects_xh() { + assert!(is_plain_text_agent("xh/0.22.0")); + } + + #[test] + fn detects_fetch() { + assert!(is_plain_text_agent("fetch libfetch/2.0")); + } + + #[test] + fn detects_powershell() { + assert!(is_plain_text_agent("PowerShell/7.4.1")); + } + + #[test] + fn detects_windows_powershell() { + assert!(is_plain_text_agent("WindowsPowerShell/5.1")); + } + + #[test] + fn rejects_browser() { + assert!(!is_plain_text_agent( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + )); + } + + #[test] + fn rejects_empty() { + assert!(!is_plain_text_agent("")); + } + + #[test] + fn cli_matching_is_case_sensitive() { + assert!(!is_plain_text_agent("CURL/8.7.1")); + assert!(!is_plain_text_agent("Curl/8.7.1")); + assert!(!is_plain_text_agent("WGET/1.21.4")); + assert!(!is_plain_text_agent("httpie/3.2.2")); + assert!(!is_plain_text_agent("XH/0.22.0")); + assert!(!is_plain_text_agent("powershell/7.4.1")); + } + + // --- HTTP library detection --- + + #[test] + fn detects_python_requests() { + assert!(is_plain_text_agent("python-requests/2.31.0")); + } + + #[test] + fn detects_python_httpx() { + assert!(is_plain_text_agent("python-httpx/0.27.0")); + } + + #[test] + fn detects_python_urllib3() { + assert!(is_plain_text_agent("python-urllib3/2.2.1")); + } + + #[test] + fn detects_aiohttp() { + assert!(is_plain_text_agent("aiohttp/3.9.5")); + } + + #[test] + fn detects_httplib2() { + assert!(is_plain_text_agent("httplib2/0.22.0")); + } + + #[test] + fn detects_go_http_client() { + assert!(is_plain_text_agent("Go-http-client/2.0")); + } + + #[test] + fn detects_ruby_rest_client() { + assert!(is_plain_text_agent("rest-client/2.1.0 (linux x86_64)")); + } + + #[test] + fn detects_ruby_default() { + assert!(is_plain_text_agent("Ruby")); + } + + #[test] + fn detects_faraday() { + assert!(is_plain_text_agent("Faraday v2.9.0")); + } + + #[test] + fn detects_httparty() { + assert!(is_plain_text_agent("HTTParty/0.21.0")); + } + + #[test] + fn detects_java() { + assert!(is_plain_text_agent("Java/17.0.6")); + } + + #[test] + fn detects_apache_http_client() { + assert!(is_plain_text_agent( + "Apache-HttpClient/4.5.14 (Java/17.0.6)" + )); + } + + #[test] + fn detects_okhttp() { + assert!(is_plain_text_agent("okhttp/4.12.0")); + } + + #[test] + fn detects_dotnet_http_client() { + assert!(is_plain_text_agent("HttpClient/8.0")); + } + + #[test] + fn detects_node_fetch() { + assert!(is_plain_text_agent("node-fetch/3.3.2")); + } + + #[test] + fn detects_axios() { + assert!(is_plain_text_agent("axios/1.7.2")); + } + + #[test] + fn detects_undici() { + assert!(is_plain_text_agent("undici")); + } + + #[test] + fn detects_got() { + assert!(is_plain_text_agent( + "got (https://github.com/sindresorhus/got)" + )); + } + + #[test] + fn detects_guzzle() { + assert!(is_plain_text_agent("GuzzleHttp/7.8.1 curl/8.4.0 PHP/8.3.3")); + } + + #[test] + fn detects_symfony_http_client() { + assert!(is_plain_text_agent("Symfony HttpClient/Curl")); + } + + #[test] + fn detects_reqwest() { + assert!(is_plain_text_agent("reqwest/0.12.4")); + } + + #[test] + fn detects_hyper() { + assert!(is_plain_text_agent("hyper/1.3.1")); + } + + #[test] + fn detects_ureq() { + assert!(is_plain_text_agent("ureq/2.9.7")); + } + + #[test] + fn detects_dart() { + assert!(is_plain_text_agent("Dart/3.3 (dart:io)")); + } + + #[test] + fn detects_libwww_perl() { + assert!(is_plain_text_agent("libwww-perl/6.72")); + } + + #[test] + fn detects_lwp_simple() { + assert!(is_plain_text_agent("LWP::Simple/6.72")); + } + + #[test] + fn detects_libcurl() { + assert!(is_plain_text_agent("libcurl/8.4.0")); + } + + #[test] + fn detects_httr() { + assert!(is_plain_text_agent("httr/1.4.7")); + } + + #[test] + fn detects_hackney() { + assert!(is_plain_text_agent("hackney/1.20.1")); + } + + #[test] + fn detects_mint() { + assert!(is_plain_text_agent("mint/1.5.2")); + } +} diff --git a/tests/integration.rs b/tests/integration.rs index af38e42..80c86cc 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -394,6 +394,119 @@ async fn ipv6_via_cf_header() { assert_eq!(body.trim(), "2606:4700:4700::1111"); } +#[tokio::test] +async fn python_requests_returns_plain_ip() { + let app = build_test_app(); + let (status, body) = get_with_headers( + &app, + "/", + vec![ + ("User-Agent", "python-requests/2.31.0"), + ("CF-Connecting-IP", "1.2.3.4"), + ], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body.trim(), "1.2.3.4"); +} + +#[tokio::test] +async fn go_http_client_returns_plain_ip() { + let app = build_test_app(); + let (status, body) = get_with_headers( + &app, + "/", + vec![ + ("User-Agent", "Go-http-client/2.0"), + ("CF-Connecting-IP", "1.2.3.4"), + ], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body.trim(), "1.2.3.4"); +} + +#[tokio::test] +async fn axios_returns_plain_ip() { + let app = build_test_app(); + let (status, body) = get_with_headers( + &app, + "/", + vec![ + ("User-Agent", "axios/1.7.2"), + ("CF-Connecting-IP", "1.2.3.4"), + ], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body.trim(), "1.2.3.4"); +} + +#[tokio::test] +async fn guzzle_returns_plain_ip() { + let app = build_test_app(); + let (status, body) = get_with_headers( + &app, + "/", + vec![ + ("User-Agent", "GuzzleHttp/7.8.1 curl/8.4.0 PHP/8.3.3"), + ("CF-Connecting-IP", "1.2.3.4"), + ], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body.trim(), "1.2.3.4"); +} + +#[tokio::test] +async fn reqwest_returns_plain_ip() { + let app = build_test_app(); + let (status, body) = get_with_headers( + &app, + "/", + vec![ + ("User-Agent", "reqwest/0.12.4"), + ("CF-Connecting-IP", "1.2.3.4"), + ], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body.trim(), "1.2.3.4"); +} + +#[tokio::test] +async fn http_lib_on_lang_path_returns_plain_ip() { + let app = build_test_app(); + let (status, body) = get_with_headers( + &app, + "/en", + vec![ + ("User-Agent", "python-requests/2.31.0"), + ("CF-Connecting-IP", "1.2.3.4"), + ], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body.trim(), "1.2.3.4"); +} + +#[tokio::test] +async fn http_lib_with_query_param_returns_html() { + let app = build_test_app(); + let (status, body) = get_with_headers( + &app, + "/en?ip=45.77.77.77", + vec![ + ("User-Agent", "python-requests/2.31.0"), + ("CF-Connecting-IP", "1.2.3.4"), + ], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(body.contains("")); + assert!(body.contains("45.77.77.77")); +} + // --- Edge case tests --- #[tokio::test]