From 8935e5f7c04724aaa8d4ed875a3d36c20544c559 Mon Sep 17 00:00:00 2001 From: NetworkCats Date: Thu, 5 Mar 2026 01:37:30 +0800 Subject: [PATCH] feat: reject non-public IP queries on both backend and frontend --- locales/ar.po | 3 + locales/de.po | 3 + locales/en.po | 3 + locales/es.po | 3 + locales/fr.po | 3 + locales/id.po | 3 + locales/it.po | 3 + locales/ja.po | 3 + locales/ko.po | 3 + locales/nl.po | 3 + locales/pt.po | 3 + locales/ru.po | 3 + locales/zh-Hans.po | 3 + locales/zh-Hant.po | 3 + src/error.rs | 14 +- src/handlers.rs | 23 +- src/ip_validate.rs | 513 ++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/routes.rs | 2 +- static/style.css | 14 + templates/_search_form.html | 145 +++++++++- tests/integration.rs | 162 +++++++++++- 22 files changed, 896 insertions(+), 20 deletions(-) create mode 100644 src/ip_validate.rs diff --git a/locales/ar.po b/locales/ar.po index 2fac994..bf67c75 100644 --- a/locales/ar.po +++ b/locales/ar.po @@ -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 للعميل مفقود" diff --git a/locales/de.po b/locales/de.po index 6560706..263dacb 100644 --- a/locales/de.po +++ b/locales/de.po @@ -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" diff --git a/locales/en.po b/locales/en.po index 56a6a4c..5f2cab9 100644 --- a/locales/en.po +++ b/locales/en.po @@ -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" diff --git a/locales/es.po b/locales/es.po index 7dc2819..e6663ab 100644 --- a/locales/es.po +++ b/locales/es.po @@ -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" diff --git a/locales/fr.po b/locales/fr.po index 40ebdbb..992e4e5 100644 --- a/locales/fr.po +++ b/locales/fr.po @@ -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" diff --git a/locales/id.po b/locales/id.po index 78a4ed4..31e5afa 100644 --- a/locales/id.po +++ b/locales/id.po @@ -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" diff --git a/locales/it.po b/locales/it.po index e3f3cc5..f513ca4 100644 --- a/locales/it.po +++ b/locales/it.po @@ -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" diff --git a/locales/ja.po b/locales/ja.po index add5ac1..1383980 100644 --- a/locales/ja.po +++ b/locales/ja.po @@ -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アドレスがありません" diff --git a/locales/ko.po b/locales/ko.po index 8f6c8b8..fd237a6 100644 --- a/locales/ko.po +++ b/locales/ko.po @@ -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 주소가 없습니다" diff --git a/locales/nl.po b/locales/nl.po index 61459b3..6a1c9be 100644 --- a/locales/nl.po +++ b/locales/nl.po @@ -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" diff --git a/locales/pt.po b/locales/pt.po index 120fd62..3392b42 100644 --- a/locales/pt.po +++ b/locales/pt.po @@ -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" diff --git a/locales/ru.po b/locales/ru.po index ca53455..9d637dc 100644 --- a/locales/ru.po +++ b/locales/ru.po @@ -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-адрес клиента" diff --git a/locales/zh-Hans.po b/locales/zh-Hans.po index d4452e8..7d76660 100644 --- a/locales/zh-Hans.po +++ b/locales/zh-Hans.po @@ -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 地址" diff --git a/locales/zh-Hant.po b/locales/zh-Hant.po index 4c76256..95b1bfa 100644 --- a/locales/zh-Hant.po +++ b/locales/zh-Hant.po @@ -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 位址" diff --git a/src/error.rs b/src/error.rs index 4f33757..2b8c5d2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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( diff --git a/src/handlers.rs b/src/handlers.rs index 61b790b..1d82c05 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -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 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) -> Response { for locale in Locale::ALL { let tag = locale.tag(); - urls.push_str(&format!( - " \n https://{domain}/{tag}\n" - )); + urls.push_str(&format!(" \n https://{domain}/{tag}\n")); for alt in Locale::ALL { let alt_tag = alt.tag(); urls.push_str(&format!( diff --git a/src/ip_validate.rs b/src/ip_validate.rs new file mode 100644 index 0000000..c0b2051 --- /dev/null +++ b/src/ip_validate.rs @@ -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"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7e3fbd8..f59b38d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/routes.rs b/src/routes.rs index 294791a..ac70e54 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -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; diff --git a/static/style.css b/static/style.css index 6d28123..ded02e9 100644 --- a/static/style.css +++ b/static/style.css @@ -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); } } diff --git a/templates/_search_form.html b/templates/_search_form.html index f4b1394..064df90 100644 --- a/templates/_search_form.html +++ b/templates/_search_form.html @@ -1,4 +1,6 @@ - + diff --git a/tests/integration.rs b/tests/integration.rs index 960cb1f..af38e42 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -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("")); 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("")); + 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(")); +}