feat: reject non-public IP queries on both backend and frontend

This commit is contained in:
NetworkCats
2026-03-05 01:37:30 +08:00
parent 2eb11ad33a
commit 8935e5f7c0
22 changed files with 896 additions and 20 deletions
+3
View File
@@ -177,6 +177,9 @@ msgstr "لم يتم العثور على عنوان IP في قاعدة البيا
msgid "Invalid IP address"
msgstr "عنوان IP غير صالح"
msgid "Only public IP addresses can be queried"
msgstr "يمكن الاستعلام عن عناوين IP العامة فقط"
msgid "Missing client IP address"
msgstr "عنوان IP للعميل مفقود"
+3
View File
@@ -177,6 +177,9 @@ msgstr "IP nicht in der Datenbank gefunden"
msgid "Invalid IP address"
msgstr "Ungueltige IP-Adresse"
msgid "Only public IP addresses can be queried"
msgstr "Es koennen nur oeffentliche IP-Adressen abgefragt werden"
msgid "Missing client IP address"
msgstr "Fehlende Client-IP-Adresse"
+3
View File
@@ -177,6 +177,9 @@ msgstr "IP not found in database"
msgid "Invalid IP address"
msgstr "Invalid IP address"
msgid "Only public IP addresses can be queried"
msgstr "Only public IP addresses can be queried"
msgid "Missing client IP address"
msgstr "Missing client IP address"
+3
View File
@@ -177,6 +177,9 @@ msgstr "IP no encontrada en la base de datos"
msgid "Invalid IP address"
msgstr "Direccion IP no valida"
msgid "Only public IP addresses can be queried"
msgstr "Solo se pueden consultar direcciones IP publicas"
msgid "Missing client IP address"
msgstr "Falta la direccion IP del cliente"
+3
View File
@@ -177,6 +177,9 @@ msgstr "IP introuvable dans la base de donnees"
msgid "Invalid IP address"
msgstr "Adresse IP invalide"
msgid "Only public IP addresses can be queried"
msgstr "Seules les adresses IP publiques peuvent etre interrogees"
msgid "Missing client IP address"
msgstr "Adresse IP du client manquante"
+3
View File
@@ -177,6 +177,9 @@ msgstr "IP tidak ditemukan dalam basis data"
msgid "Invalid IP address"
msgstr "Alamat IP tidak valid"
msgid "Only public IP addresses can be queried"
msgstr "Hanya alamat IP publik yang dapat dicari"
msgid "Missing client IP address"
msgstr "Alamat IP klien tidak ditemukan"
+3
View File
@@ -177,6 +177,9 @@ msgstr "IP non trovato nel database"
msgid "Invalid IP address"
msgstr "Indirizzo IP non valido"
msgid "Only public IP addresses can be queried"
msgstr "Possono essere interrogati solo indirizzi IP pubblici"
msgid "Missing client IP address"
msgstr "Indirizzo IP del client mancante"
+3
View File
@@ -177,6 +177,9 @@ msgstr "IPがデータベースに見つかりません"
msgid "Invalid IP address"
msgstr "無効なIPアドレス"
msgid "Only public IP addresses can be queried"
msgstr "パブリックIPアドレスのみ照会できます"
msgid "Missing client IP address"
msgstr "クライアントIPアドレスがありません"
+3
View File
@@ -177,6 +177,9 @@ msgstr "데이터베이스에서 IP를 찾을 수 없습니다"
msgid "Invalid IP address"
msgstr "유효하지 않은 IP 주소"
msgid "Only public IP addresses can be queried"
msgstr "공용 IP 주소만 조회할 수 있습니다"
msgid "Missing client IP address"
msgstr "클라이언트 IP 주소가 없습니다"
+3
View File
@@ -177,6 +177,9 @@ msgstr "IP niet gevonden in database"
msgid "Invalid IP address"
msgstr "Ongeldig IP-adres"
msgid "Only public IP addresses can be queried"
msgstr "Alleen openbare IP-adressen kunnen worden opgezocht"
msgid "Missing client IP address"
msgstr "Ontbrekend client-IP-adres"
+3
View File
@@ -177,6 +177,9 @@ msgstr "IP não encontrado no banco de dados"
msgid "Invalid IP address"
msgstr "Endereço IP inválido"
msgid "Only public IP addresses can be queried"
msgstr "Apenas enderecos IP publicos podem ser consultados"
msgid "Missing client IP address"
msgstr "Endereço IP do cliente ausente"
+3
View File
@@ -177,6 +177,9 @@ msgstr "IP не найден в базе данных"
msgid "Invalid IP address"
msgstr "Недействительный IP-адрес"
msgid "Only public IP addresses can be queried"
msgstr "Можно запрашивать только публичные IP-адреса"
msgid "Missing client IP address"
msgstr "Отсутствует IP-адрес клиента"
+3
View File
@@ -177,6 +177,9 @@ msgstr "在数据库中未找到该 IP"
msgid "Invalid IP address"
msgstr "无效的 IP 地址"
msgid "Only public IP addresses can be queried"
msgstr "仅可查询公网 IP 地址"
msgid "Missing client IP address"
msgstr "缺少客户端 IP 地址"
+3
View File
@@ -177,6 +177,9 @@ msgstr "在資料庫中找不到此 IP"
msgid "Invalid IP address"
msgstr "無效的 IP 位址"
msgid "Only public IP addresses can be queried"
msgstr "僅可查詢公網 IP 位址"
msgid "Missing client IP address"
msgstr "缺少用戶端 IP 位址"
+13 -1
View File
@@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response};
pub enum AppError {
IpNotFound,
InvalidIp,
NonPublicIp,
MissingClientIp,
DbLookupFailed,
TemplateRenderFailed,
@@ -13,7 +14,7 @@ impl AppError {
pub fn status_code(&self) -> StatusCode {
match self {
Self::IpNotFound => StatusCode::NOT_FOUND,
Self::InvalidIp => StatusCode::BAD_REQUEST,
Self::InvalidIp | Self::NonPublicIp => StatusCode::BAD_REQUEST,
Self::MissingClientIp => StatusCode::BAD_REQUEST,
Self::DbLookupFailed => StatusCode::INTERNAL_SERVER_ERROR,
Self::TemplateRenderFailed => StatusCode::INTERNAL_SERVER_ERROR,
@@ -24,6 +25,7 @@ impl AppError {
match self {
Self::IpNotFound => "IP not found in database",
Self::InvalidIp => "Invalid IP address",
Self::NonPublicIp => "Only public IP addresses can be queried",
Self::MissingClientIp => "Missing client IP address",
Self::DbLookupFailed => "Database lookup failed",
Self::TemplateRenderFailed => "Template render failed",
@@ -74,6 +76,16 @@ mod tests {
.await;
}
#[tokio::test]
async fn non_public_ip_returns_400() {
check_error_response(
AppError::NonPublicIp,
StatusCode::BAD_REQUEST,
"Only public IP addresses can be queried",
)
.await;
}
#[tokio::test]
async fn missing_client_ip_returns_400() {
check_error_response(
+19 -4
View File
@@ -10,6 +10,7 @@ use crate::cli_detect::is_cli_user_agent;
use crate::db::{self, SharedDb};
use crate::error::AppError;
use crate::i18n::{self, Locale};
use crate::ip_validate;
use crate::models::{IpInfo, get_localized_name};
use crate::static_assets;
@@ -50,6 +51,8 @@ struct IndexTemplate {
t_description: &'static str,
t_search_label: &'static str,
t_search_placeholder: &'static str,
t_invalid_ip: &'static str,
t_non_public_ip: &'static str,
t_asn: &'static str,
t_org: &'static str,
t_country: &'static str,
@@ -113,6 +116,8 @@ struct ErrorAlertTemplate {
t_error: &'static str,
t_search_label: &'static str,
t_search_placeholder: &'static str,
t_invalid_ip: &'static str,
t_non_public_ip: &'static str,
t_footer_license: &'static str,
t_footer_source: &'static str,
t_footer_db: &'static str,
@@ -139,7 +144,11 @@ fn resolve_ip(headers: &HeaderMap, query: &IpQuery, dev_mode: bool) -> Result<Ip
if let Some(ref ip_str) = query.ip {
let trimmed = ip_str.trim();
if !trimmed.is_empty() {
return trimmed.parse().map_err(|_| AppError::InvalidIp);
let ip: IpAddr = trimmed.parse().map_err(|_| AppError::InvalidIp)?;
if !ip_validate::is_global_ip(ip) {
return Err(AppError::NonPublicIp);
}
return Ok(ip);
}
}
extract_client_ip(headers, dev_mode)
@@ -211,6 +220,8 @@ fn build_translations(locale: Locale) -> Translations {
),
t_search_label: i18n::translate(locale, "Search IP address"),
t_search_placeholder: i18n::translate(locale, "Type to search IP data"),
t_invalid_ip: i18n::translate(locale, "Invalid IP address"),
t_non_public_ip: i18n::translate(locale, "Only public IP addresses can be queried"),
t_asn: i18n::translate(locale, "ASN"),
t_org: i18n::translate(locale, "Org"),
t_country: i18n::translate(locale, "Country"),
@@ -319,6 +330,8 @@ struct Translations {
t_description: &'static str,
t_search_label: &'static str,
t_search_placeholder: &'static str,
t_invalid_ip: &'static str,
t_non_public_ip: &'static str,
t_asn: &'static str,
t_org: &'static str,
t_country: &'static str,
@@ -384,6 +397,8 @@ fn render_error_alert(state: &AppState, locale: Locale, query: &str, error: &App
t_error: t.t_error,
t_search_label: t.t_search_label,
t_search_placeholder: t.t_search_placeholder,
t_invalid_ip: t.t_invalid_ip,
t_non_public_ip: t.t_non_public_ip,
t_footer_license: t.t_footer_license,
t_footer_source: t.t_footer_source,
t_footer_db: t.t_footer_db,
@@ -556,6 +571,8 @@ pub async fn root(
t_description: t.t_description,
t_search_label: t.t_search_label,
t_search_placeholder: t.t_search_placeholder,
t_invalid_ip: t.t_invalid_ip,
t_non_public_ip: t.t_non_public_ip,
t_asn: t.t_asn,
t_org: t.t_org,
t_country: t.t_country,
@@ -780,9 +797,7 @@ pub async fn sitemap_xml(State(state): State<AppState>) -> Response {
for locale in Locale::ALL {
let tag = locale.tag();
urls.push_str(&format!(
" <url>\n <loc>https://{domain}/{tag}</loc>\n"
));
urls.push_str(&format!(" <url>\n <loc>https://{domain}/{tag}</loc>\n"));
for alt in Locale::ALL {
let alt_tag = alt.tag();
urls.push_str(&format!(
+513
View File
@@ -0,0 +1,513 @@
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
/// Returns `true` if the given IPv4 address is a globally routable public address.
///
/// Rejects: private (RFC 1918), loopback, link-local, broadcast, documentation,
/// benchmarking, reserved, shared (CGN), protocol assignment, and all other
/// non-globally-routable ranges.
fn is_global_ipv4(ip: Ipv4Addr) -> bool {
let octets = ip.octets();
let first = octets[0];
let second = octets[1];
// 0.0.0.0/8 - "This" network (RFC 1122)
if first == 0 {
return false;
}
// 10.0.0.0/8 - Private (RFC 1918)
if first == 10 {
return false;
}
// 100.64.0.0/10 - Shared address space / CGN (RFC 6598)
if first == 100 && (second & 0xC0) == 64 {
return false;
}
// 127.0.0.0/8 - Loopback (RFC 1122)
if first == 127 {
return false;
}
// 169.254.0.0/16 - Link-local (RFC 3927)
if first == 169 && second == 254 {
return false;
}
// 172.16.0.0/12 - Private (RFC 1918)
if first == 172 && (second & 0xF0) == 16 {
return false;
}
// 192.0.0.0/24 - IETF protocol assignments (RFC 6890)
if first == 192 && second == 0 && octets[2] == 0 {
return false;
}
// 192.0.2.0/24 - Documentation TEST-NET-1 (RFC 5737)
if first == 192 && second == 0 && octets[2] == 2 {
return false;
}
// 192.88.99.0/24 - 6to4 relay anycast (RFC 7526, deprecated)
if first == 192 && second == 88 && octets[2] == 99 {
return false;
}
// 192.168.0.0/16 - Private (RFC 1918)
if first == 192 && second == 168 {
return false;
}
// 198.18.0.0/15 - Benchmarking (RFC 2544)
if first == 198 && (second == 18 || second == 19) {
return false;
}
// 198.51.100.0/24 - Documentation TEST-NET-2 (RFC 5737)
if first == 198 && second == 51 && octets[2] == 100 {
return false;
}
// 203.0.113.0/24 - Documentation TEST-NET-3 (RFC 5737)
if first == 203 && second == 0 && octets[2] == 113 {
return false;
}
// 224.0.0.0/4 - Multicast (RFC 5771)
// 240.0.0.0/4 - Reserved for future use (RFC 1112) + 255.255.255.255 broadcast
if first >= 224 {
return false;
}
true
}
/// Returns `true` if the given IPv6 address is a globally routable public address.
///
/// Rejects: unspecified (::), loopback (::1), IPv4-mapped, IPv4-compatible,
/// link-local, site-local (deprecated), unique local (ULA), multicast,
/// documentation, discard, Teredo, 6to4, ORCHID, and all other
/// non-globally-routable ranges.
fn is_global_ipv6(ip: Ipv6Addr) -> bool {
// Unspecified address (::)
if ip.is_unspecified() {
return false;
}
// Loopback (::1)
if ip.is_loopback() {
return false;
}
let segments = ip.segments();
let first = segments[0];
// IPv4-mapped (::ffff:0:0/96) - check the embedded IPv4 part
if first == 0
&& segments[1] == 0
&& segments[2] == 0
&& segments[3] == 0
&& segments[4] == 0
&& segments[5] == 0xFFFF
{
let ipv4 = Ipv4Addr::new(
(segments[6] >> 8) as u8,
segments[6] as u8,
(segments[7] >> 8) as u8,
segments[7] as u8,
);
return is_global_ipv4(ipv4);
}
// IPv4-compatible (deprecated, ::/96 excluding :: and ::1)
if first == 0
&& segments[1] == 0
&& segments[2] == 0
&& segments[3] == 0
&& segments[4] == 0
&& segments[5] == 0
{
return false;
}
// fe80::/10 - Link-local
if (first & 0xFFC0) == 0xFE80 {
return false;
}
// fec0::/10 - Site-local (deprecated, RFC 3879)
if (first & 0xFFC0) == 0xFEC0 {
return false;
}
// fc00::/7 - Unique local address (ULA, RFC 4193)
if (first & 0xFE00) == 0xFC00 {
return false;
}
// ff00::/8 - Multicast
if (first & 0xFF00) == 0xFF00 {
return false;
}
// 100::/64 - Discard prefix (RFC 6666)
if first == 0x0100 && segments[1] == 0 && segments[2] == 0 && segments[3] == 0 {
return false;
}
// 2001:db8::/32 - Documentation (RFC 3849)
if first == 0x2001 && segments[1] == 0x0DB8 {
return false;
}
// 2001::/23 - IETF protocol assignments (RFC 2928)
// This includes 2001::/32 (Teredo) and 2001:10::/28 (ORCHID v2)
// but also encompasses globally routable 2001::/32 allocations.
// We specifically block Teredo (2001:0000::/32) which tunnels arbitrary IPv4.
if first == 0x2001 && segments[1] == 0x0000 {
return false;
}
// 2002::/16 - 6to4 (RFC 3056) - check embedded IPv4 for public-ness
if first == 0x2002 {
let ipv4 = Ipv4Addr::new(
(segments[1] >> 8) as u8,
segments[1] as u8,
(segments[2] >> 8) as u8,
segments[2] as u8,
);
return is_global_ipv4(ipv4);
}
true
}
/// Returns `true` if the IP address is a globally routable public address
/// suitable for geolocation lookup. Works for both IPv4 and IPv6.
pub fn is_global_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_global_ipv4(v4),
IpAddr::V6(v6) => is_global_ipv6(v6),
}
}
#[cfg(test)]
mod tests {
use super::*;
// --- IPv4 tests ---
#[test]
fn public_ipv4_accepted() {
let public_ips = [
"1.1.1.1",
"8.8.8.8",
"45.77.77.77",
"104.16.132.229",
"223.255.255.255",
"100.0.0.1",
"100.63.255.255",
"100.128.0.0",
"198.17.255.255",
"198.20.0.0",
];
for s in public_ips {
let ip: IpAddr = s.parse().unwrap();
assert!(is_global_ip(ip), "{s} should be accepted as public");
}
}
#[test]
fn this_network_rejected() {
let ips = ["0.0.0.0", "0.0.0.1", "0.255.255.255"];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (this network)");
}
}
#[test]
fn private_rfc1918_rejected() {
let ips = [
"10.0.0.0",
"10.0.0.1",
"10.255.255.255",
"172.16.0.0",
"172.16.0.1",
"172.31.255.255",
"192.168.0.0",
"192.168.0.1",
"192.168.255.255",
];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (RFC 1918)");
}
}
#[test]
fn private_boundary_not_rejected() {
let ips = ["172.15.255.255", "172.32.0.0"];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(
is_global_ip(ip),
"{s} should be accepted (outside RFC 1918)"
);
}
}
#[test]
fn loopback_rejected() {
let ips = ["127.0.0.0", "127.0.0.1", "127.255.255.255"];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (loopback)");
}
}
#[test]
fn link_local_rejected() {
let ips = ["169.254.0.0", "169.254.0.1", "169.254.255.255"];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (link-local)");
}
}
#[test]
fn cgn_shared_rejected() {
let ips = ["100.64.0.0", "100.64.0.1", "100.127.255.255"];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (CGN/shared)");
}
}
#[test]
fn documentation_nets_rejected() {
let ips = [
"192.0.2.0",
"192.0.2.1",
"192.0.2.255",
"198.51.100.0",
"198.51.100.255",
"203.0.113.0",
"203.0.113.255",
];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (documentation)");
}
}
#[test]
fn benchmarking_rejected() {
let ips = ["198.18.0.0", "198.18.0.1", "198.19.255.255"];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (benchmarking)");
}
}
#[test]
fn ietf_protocol_rejected() {
let ips = ["192.0.0.0", "192.0.0.1", "192.0.0.255"];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (IETF protocol)");
}
}
#[test]
fn relay_6to4_rejected() {
let ips = ["192.88.99.0", "192.88.99.1", "192.88.99.255"];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (6to4 relay)");
}
}
#[test]
fn multicast_rejected() {
let ips = ["224.0.0.0", "224.0.0.1", "239.255.255.255"];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (multicast)");
}
}
#[test]
fn reserved_and_broadcast_rejected() {
let ips = [
"240.0.0.0",
"240.0.0.1",
"255.255.255.254",
"255.255.255.255",
];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(
!is_global_ip(ip),
"{s} should be rejected (reserved/broadcast)"
);
}
}
// --- IPv6 tests ---
#[test]
fn public_ipv6_accepted() {
let public_ips = [
"2606:4700:4700::1111",
"2001:4860:4860::8888",
"2400:cb00:2048:1::c629:d7a2",
];
for s in public_ips {
let ip: IpAddr = s.parse().unwrap();
assert!(is_global_ip(ip), "{s} should be accepted as public");
}
}
#[test]
fn ipv6_unspecified_rejected() {
let ip: IpAddr = "::".parse().unwrap();
assert!(!is_global_ip(ip), ":: should be rejected (unspecified)");
}
#[test]
fn ipv6_loopback_rejected() {
let ip: IpAddr = "::1".parse().unwrap();
assert!(!is_global_ip(ip), "::1 should be rejected (loopback)");
}
#[test]
fn ipv6_link_local_rejected() {
let ips = ["fe80::", "fe80::1", "fe80::ffff:ffff:ffff:ffff", "febf::1"];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (link-local)");
}
}
#[test]
fn ipv6_site_local_rejected() {
let ips = ["fec0::", "fec0::1", "feff::1"];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (site-local)");
}
}
#[test]
fn ipv6_ula_rejected() {
let ips = [
"fc00::",
"fc00::1",
"fd00::1",
"fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (ULA)");
}
}
#[test]
fn ipv6_multicast_rejected() {
let ips = ["ff00::", "ff02::1", "ff0e::1"];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (multicast)");
}
}
#[test]
fn ipv6_documentation_rejected() {
let ips = [
"2001:db8::",
"2001:db8::1",
"2001:db8:ffff:ffff:ffff:ffff:ffff:ffff",
];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (documentation)");
}
}
#[test]
fn ipv6_teredo_rejected() {
let ips = [
"2001::",
"2001::1",
"2001:0000:ffff:ffff:ffff:ffff:ffff:ffff",
];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (Teredo)");
}
}
#[test]
fn ipv6_discard_rejected() {
let ips = ["100::", "100::1", "100::ffff:ffff:ffff:ffff"];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (discard)");
}
}
#[test]
fn ipv6_ipv4_mapped_private_rejected() {
let ips = [
"::ffff:127.0.0.1",
"::ffff:10.0.0.1",
"::ffff:192.168.1.1",
"::ffff:0.0.0.0",
];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip(ip), "{s} should be rejected (mapped private)");
}
}
#[test]
fn ipv6_ipv4_mapped_public_accepted() {
let ips = ["::ffff:1.1.1.1", "::ffff:8.8.8.8"];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(is_global_ip(ip), "{s} should be accepted (mapped public)");
}
}
#[test]
fn ipv6_ipv4_compatible_rejected() {
let ips = ["::1.1.1.1", "::8.8.8.8"];
for s in ips {
let ip: IpAddr = s.parse().unwrap();
assert!(
!is_global_ip(ip),
"{s} should be rejected (IPv4-compatible)"
);
}
}
#[test]
fn ipv6_6to4_with_private_ipv4_rejected() {
// 2002:0a00:0001:: embeds 10.0.0.1
let ip: IpAddr = "2002:0a00:0001::".parse().unwrap();
assert!(
!is_global_ip(ip),
"6to4 with private IPv4 should be rejected"
);
}
#[test]
fn ipv6_6to4_with_public_ipv4_accepted() {
// 2002:0101:0101:: embeds 1.1.1.1
let ip: IpAddr = "2002:0101:0101::".parse().unwrap();
assert!(is_global_ip(ip), "6to4 with public IPv4 should be accepted");
}
}
+1
View File
@@ -4,6 +4,7 @@ pub mod db;
pub mod error;
pub mod handlers;
pub mod i18n;
pub mod ip_validate;
pub mod middleware;
pub mod models;
pub mod routes;
+1 -1
View File
@@ -1,6 +1,6 @@
use axum::Router;
use axum::middleware;
use axum::routing::get;
use axum::Router;
use crate::handlers::{self, AppState};
use crate::middleware::security_headers;
+14
View File
@@ -118,6 +118,20 @@ main {
&::placeholder {
color: var(--color-label-tertiary);
}
&[aria-invalid="true"] {
border-color: var(--color-red);
&:focus-visible {
outline-color: var(--color-red);
}
}
}
.search-error {
margin-block-start: 6px;
font-size: 13px;
color: var(--color-red);
}
}
+144 -1
View File
@@ -1,4 +1,6 @@
<form method="GET" action="/{{ lang_tag }}" class="search-form" role="search">
<form method="GET" action="/{{ lang_tag }}" class="search-form" role="search"
data-msg-invalid="{{ t_invalid_ip }}"
data-msg-non-public="{{ t_non_public_ip }}">
<div class="search-input-wrap">
<label for="ip-search" class="sr-only">{{ t_search_label }}</label>
<i class="icon-magnifier search-icon" aria-hidden="true"></i>
@@ -13,5 +15,146 @@
spellcheck="false"
{% if autofocus %}autofocus{% endif %}
>
<p class="search-error" id="ip-error" role="alert" aria-live="polite" hidden></p>
</div>
</form>
<script>
(function(){
"use strict";
var form=document.querySelector(".search-form");
if(!form)return;
var input=form.querySelector("input[name=ip]");
var error=document.getElementById("ip-error");
var msgInvalid=form.getAttribute("data-msg-invalid");
var msgNonPublic=form.getAttribute("data-msg-non-public");
function parseIPv4(s){
var p=s.split(".");
if(p.length!==4)return null;
var o=[];
for(var i=0;i<4;i++){
if(!/^\d{1,3}$/.test(p[i]))return null;
var n=parseInt(p[i],10);
if(n>255||p[i]!==String(n))return null;
o.push(n);
}
return o;
}
function isGlobalIPv4(o){
var a=o[0],b=o[1],c=o[2];
if(a===0)return false;
if(a===10)return false;
if(a===100&&(b&192)===64)return false;
if(a===127)return false;
if(a===169&&b===254)return false;
if(a===172&&(b&240)===16)return false;
if(a===192&&b===0&&c===0)return false;
if(a===192&&b===0&&c===2)return false;
if(a===192&&b===88&&c===99)return false;
if(a===192&&b===168)return false;
if(a===198&&(b===18||b===19))return false;
if(a===198&&b===51&&c===100)return false;
if(a===203&&b===0&&c===113)return false;
if(a>=224&&a<=239)return false;
if(a>=240)return false;
return true;
}
function expandIPv6(s){
s=s.replace(/^\[|\]$/g,"");
var halves=s.split("::");
if(halves.length>2)return null;
function parseHalf(h){
if(h==="")return[];
var parts=h.split(":");
var last=parts[parts.length-1];
if(last.indexOf(".")!==-1){
var v4=parseIPv4(last);
if(!v4)return null;
parts.pop();
parts.push(((v4[0]<<8)|v4[1]).toString(16));
parts.push(((v4[2]<<8)|v4[3]).toString(16));
}
return parts;
}
var left=parseHalf(halves[0]);
if(left===null)return null;
var right=halves.length===2?parseHalf(halves[1]):[];
if(right===null)return null;
if(halves.length===1&&left.length!==8)return null;
if(halves.length===2){
var fill=8-left.length-right.length;
if(fill<0)return null;
var mid=[];
for(var i=0;i<fill;i++)mid.push("0");
left=left.concat(mid,right);
}
if(left.length!==8)return null;
var seg=[];
for(var j=0;j<8;j++){
if(!/^[0-9a-fA-F]{1,4}$/.test(left[j]))return null;
seg.push(parseInt(left[j],16));
}
return seg;
}
function isGlobalIPv6(seg){
if(seg[0]===0&&seg[1]===0&&seg[2]===0&&seg[3]===0&&seg[4]===0&&seg[5]===0&&seg[6]===0&&seg[7]===0)return false;
if(seg[0]===0&&seg[1]===0&&seg[2]===0&&seg[3]===0&&seg[4]===0&&seg[5]===0&&seg[6]===0&&seg[7]===1)return false;
if(seg[0]===0&&seg[1]===0&&seg[2]===0&&seg[3]===0&&seg[4]===0&&seg[5]===0xFFFF){
return isGlobalIPv4([seg[6]>>8,seg[6]&255,seg[7]>>8,seg[7]&255]);
}
if(seg[0]===0&&seg[1]===0&&seg[2]===0&&seg[3]===0&&seg[4]===0&&seg[5]===0)return false;
var f=seg[0];
if((f&0xFFC0)===0xFE80)return false;
if((f&0xFFC0)===0xFEC0)return false;
if((f&0xFE00)===0xFC00)return false;
if((f&0xFF00)===0xFF00)return false;
if(f===0x0100&&seg[1]===0&&seg[2]===0&&seg[3]===0)return false;
if(f===0x2001&&seg[1]===0x0DB8)return false;
if(f===0x2001&&seg[1]===0x0000)return false;
if(f===0x2002){
return isGlobalIPv4([seg[1]>>8,seg[1]&255,seg[2]>>8,seg[2]&255]);
}
return true;
}
function validate(val){
var s=val.trim();
if(s==="")return null;
var v4=parseIPv4(s);
if(v4)return isGlobalIPv4(v4)?null:msgNonPublic;
var v6=expandIPv6(s);
if(v6)return isGlobalIPv6(v6)?null:msgNonPublic;
return msgInvalid;
}
function showError(msg){
if(msg){
error.textContent=msg;
error.hidden=false;
input.setAttribute("aria-invalid","true");
input.setAttribute("aria-describedby","ip-error");
}else{
error.textContent="";
error.hidden=true;
input.removeAttribute("aria-invalid");
input.removeAttribute("aria-describedby");
}
}
form.addEventListener("submit",function(e){
var msg=validate(input.value);
if(msg){
e.preventDefault();
showError(msg);
input.focus();
}
});
input.addEventListener("input",function(){
if(!error.hidden)showError(validate(input.value));
});
})();
</script>
+149 -13
View File
@@ -417,10 +417,11 @@ async fn whitespace_ip_query_falls_back_to_cf_header() {
}
#[tokio::test]
async fn ip_not_in_database_returns_500() {
async fn non_public_ip_returns_400() {
let app = build_test_app();
let (status, _) = get(&app, "/json?ip=0.0.0.0").await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
let (status, body) = get(&app, "/json?ip=0.0.0.0").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body, "Only public IP addresses can be queried");
}
#[tokio::test]
@@ -780,7 +781,7 @@ async fn browser_invalid_ip_shows_alert_html() {
}
#[tokio::test]
async fn browser_db_lookup_failed_shows_alert_html() {
async fn browser_non_public_ip_shows_alert_html() {
let app = build_test_app();
let (status, body) = get_with_headers(
&app,
@@ -791,7 +792,7 @@ async fn browser_db_lookup_failed_shows_alert_html() {
)],
)
.await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("<!DOCTYPE html>"));
assert!(body.contains("alert("));
}
@@ -875,8 +876,7 @@ async fn trailing_slash_redirects_to_canonical() {
#[tokio::test]
async fn trailing_slash_preserves_query_params() {
let app = build_test_app_with_dev_mode(true);
let response =
get_response(&app, "/de/?ip=1.2.3.4", vec![("User-Agent", "Mozilla/5.0")]).await;
let response = get_response(&app, "/de/?ip=1.2.3.4", vec![("User-Agent", "Mozilla/5.0")]).await;
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
let location = response
.headers()
@@ -950,12 +950,7 @@ async fn error_page_has_localized_go_home_link() {
#[tokio::test]
async fn german_page_contains_translated_escaped_quote_strings() {
let app = build_test_app_with_dev_mode(true);
let (status, body) = get_with_headers(
&app,
"/de",
vec![("User-Agent", "Mozilla/5.0")],
)
.await;
let (status, body) = get_with_headers(&app, "/de", vec![("User-Agent", "Mozilla/5.0")]).await;
assert_eq!(status, StatusCode::OK);
// These strings were previously untranslated due to escaped quotes in PO files.
assert!(
@@ -975,3 +970,144 @@ async fn german_page_contains_translated_escaped_quote_strings() {
"German page should not contain English data source string"
);
}
// --- Non-public IP rejection tests ---
#[tokio::test]
async fn private_ipv4_rejected_on_all_endpoints() {
let app = build_test_app();
let private_ips = [
"10.0.0.1",
"172.16.0.1",
"192.168.1.1",
"127.0.0.1",
"0.0.0.0",
"255.255.255.255",
"169.254.1.1",
"100.64.0.1",
];
let endpoints = [
"/json", "/ip", "/asn", "/org", "/country", "/city", "/proxy", "/vpn", "/hosting", "/tor",
];
for ip in private_ips {
for endpoint in &endpoints {
let uri = format!("{endpoint}?ip={ip}");
let (status, body) = get(&app, &uri).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"expected 400 for {endpoint}?ip={ip}, got {status}"
);
assert_eq!(
body, "Only public IP addresses can be queried",
"wrong error message for {endpoint}?ip={ip}"
);
}
}
}
#[tokio::test]
async fn private_ipv6_rejected() {
let app = build_test_app();
let private_ips = [
"::1",
"::",
"fe80::1",
"fc00::1",
"fd00::1",
"ff02::1",
"2001:db8::1",
"::ffff:127.0.0.1",
"::ffff:192.168.1.1",
];
for ip in private_ips {
let uri = format!("/json?ip={ip}");
let (status, body) = get(&app, &uri).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"expected 400 for /json?ip={ip}, got {status}"
);
assert_eq!(
body, "Only public IP addresses can be queried",
"wrong error message for /json?ip={ip}"
);
}
}
#[tokio::test]
async fn public_ips_still_accepted() {
let app = build_test_app();
let (status, _) = get(&app, "/json?ip=1.1.1.1").await;
assert_eq!(status, StatusCode::OK);
let (status, _) = get(&app, "/json?ip=2606:4700:4700::1111").await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn private_ip_via_cf_header_still_works() {
let app = build_test_app();
let (status, body) = get_with_headers(
&app,
"/",
vec![
("User-Agent", "curl/8.7.1"),
("CF-Connecting-IP", "10.0.0.1"),
],
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.trim(), "10.0.0.1");
}
#[tokio::test]
async fn browser_private_ipv4_shows_error() {
let app = build_test_app();
let (status, body) = get_with_headers(
&app,
"/en?ip=192.168.1.1",
vec![(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
)],
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("<!DOCTYPE html>"));
assert!(body.contains("alert("));
assert!(body.contains("192.168.1.1"));
}
#[tokio::test]
async fn browser_loopback_shows_error() {
let app = build_test_app();
let (status, body) = get_with_headers(
&app,
"/en?ip=127.0.0.1",
vec![(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
)],
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("alert("));
}
#[tokio::test]
async fn browser_broadcast_shows_error() {
let app = build_test_app();
let (status, body) = get_with_headers(
&app,
"/en?ip=255.255.255.255",
vec![(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
)],
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("alert("));
}