feat: detect HTTP library User-Agents and return plain text responses

This commit is contained in:
NetworkCats
2026-03-06 00:18:55 +08:00
parent 6b2f0f745d
commit 03a1a1bc99
5 changed files with 407 additions and 83 deletions
-79
View File
@@ -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"));
}
}
+3 -3
View File
@@ -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(),
+1 -1
View File
@@ -1,4 +1,4 @@
pub mod cli_detect;
pub mod ua_detect;
pub mod config;
pub mod db;
pub mod error;
+290
View File
@@ -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"));
}
}
+113
View File
@@ -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("<!DOCTYPE html>"));
assert!(body.contains("45.77.77.77"));
}
// --- Edge case tests ---
#[tokio::test]