feat(security): harden gateway boundaries and usage policies

Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change.

Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
elky
2026-09-04 03:45:52 +08:00
parent ddcbeb3ae9
commit 579f2c7cc1
1019 changed files with 190437 additions and 26080 deletions
+131 -17
View File
@@ -2,8 +2,15 @@
//!
//! These are standalone helpers not tied to any specific client or service.
use aether_http::{build_http_client, HttpClientConfig};
use std::net::IpAddr;
use aether_http::{
build_http_client, is_private_or_reserved_ip, read_response_bytes_with_limit, HttpClientConfig,
};
use tracing::{debug, info};
use url::Url;
const MAX_NETWORK_DISCOVERY_RESPONSE_BYTES: usize = 16 * 1024;
/// Auto-detect public IP by querying external services.
pub async fn detect_public_ip() -> anyhow::Result<String> {
@@ -22,10 +29,21 @@ pub async fn detect_public_ip() -> anyhow::Result<String> {
for endpoint in &endpoints {
match client.get(*endpoint).send().await {
Ok(resp) if resp.status().is_success() => {
let ip = resp.text().await?.trim().to_string();
if !ip.is_empty() {
info!(ip = %ip, source = %endpoint, "detected public IP");
return Ok(ip);
match read_response_bytes_with_limit(resp, MAX_NETWORK_DISCOVERY_RESPONSE_BYTES)
.await
{
Ok(body) => {
let text = String::from_utf8_lossy(&body);
if let Some(ip) = parse_ip(text.as_ref()) {
let ip = ip.to_string();
info!(ip = %ip, source = %endpoint, "detected public IP");
return Ok(ip);
}
debug!(endpoint = %endpoint, "IP detection response was not a valid IP");
}
Err(error) => {
debug!(endpoint = %endpoint, error = %error, "IP detection response rejected");
}
}
}
Ok(resp) => {
@@ -47,8 +65,20 @@ pub async fn detect_public_ip() -> anyhow::Result<String> {
/// This is best-effort and non-sensitive -- region detection should never
/// block startup.
pub async fn detect_region(ip: &str) -> Option<String> {
// The value may come directly from the command line/environment. Parse
// it before putting it into a URL and avoid disclosing private or reserved
// addresses to third-party geolocation services. We intentionally do not
// reject such values from registration: controlled internal deployments
// can still advertise their configured address and region explicitly.
let ip = parse_ip(ip)?;
if is_private_or_reserved_ip(ip) {
debug!(ip = %ip, "skipping region detection for private or reserved IP");
return None;
}
let ip = ip.to_string();
// Try HTTPS provider first
let https_url = format!("https://ipinfo.io/{}/country", ip);
let https_url = ipinfo_url(&ip)?;
let client = build_http_client(&HttpClientConfig {
request_timeout_ms: Some(5_000),
@@ -60,27 +90,29 @@ pub async fn detect_region(ip: &str) -> Option<String> {
// Try ipinfo.io (HTTPS, returns plain text country code)
if let Ok(resp) = client.get(&https_url).send().await {
if resp.status().is_success() {
if let Ok(text) = resp.text().await {
let code = text.trim();
if !code.is_empty() && code.len() <= 3 {
if let Ok(body) =
read_response_bytes_with_limit(resp, MAX_NETWORK_DISCOVERY_RESPONSE_BYTES).await
{
let text = String::from_utf8_lossy(&body);
if let Some(code) = normalize_country_code(text.as_ref()) {
info!(region = %code, ip = %ip, source = "ipinfo.io", "detected region");
return Some(code.to_string());
return Some(code);
}
}
}
}
// Fallback: ip-api.com (HTTP only on free tier, non-sensitive data)
let http_url = format!("http://ip-api.com/json/{}?fields=countryCode", ip);
let http_url = ip_api_url(&ip)?;
match client.get(&http_url).send().await {
Ok(resp) if resp.status().is_success() => {
let body: serde_json::Value = resp.json().await.ok()?;
let code = body.get("countryCode")?.as_str()?;
if code.is_empty() {
return None;
}
let body = read_response_bytes_with_limit(resp, MAX_NETWORK_DISCOVERY_RESPONSE_BYTES)
.await
.ok()?;
let body: serde_json::Value = serde_json::from_slice(&body).ok()?;
let code = normalize_country_code(body.get("countryCode")?.as_str()?)?;
info!(region = %code, ip = %ip, source = "ip-api.com", "detected region");
Some(code.to_string())
Some(code)
}
_ => {
debug!(ip = %ip, "region detection failed");
@@ -88,3 +120,85 @@ pub async fn detect_region(ip: &str) -> Option<String> {
}
}
}
fn parse_ip(value: &str) -> Option<IpAddr> {
let value = value.trim();
if value.is_empty() || value.len() > 45 {
return None;
}
value.parse().ok()
}
fn normalize_country_code(value: &str) -> Option<String> {
let value = value.trim();
if !(2..=3).contains(&value.len()) || !value.bytes().all(|byte| byte.is_ascii_alphabetic()) {
return None;
}
Some(value.to_ascii_uppercase())
}
fn ipinfo_url(ip: &str) -> Option<String> {
let mut url = Url::parse("https://ipinfo.io").ok()?;
url.path_segments_mut().ok()?.push(ip).push("country");
Some(url.into())
}
fn ip_api_url(ip: &str) -> Option<String> {
let mut url = Url::parse("http://ip-api.com").ok()?;
url.path_segments_mut().ok()?.push("json").push(ip);
url.query_pairs_mut().append_pair("fields", "countryCode");
Some(url.into())
}
#[cfg(test)]
mod tests {
use super::{ip_api_url, ipinfo_url, normalize_country_code, parse_ip};
#[test]
fn parses_only_bounded_ip_values() {
assert_eq!(parse_ip(" 8.8.8.8\n"), Some("8.8.8.8".parse().unwrap()));
assert_eq!(
parse_ip("2001:4860:4860::8888"),
Some("2001:4860:4860::8888".parse().unwrap())
);
for value in ["", "8.8.8.8?x=1", "8.8.8.8\nX-Injected: yes", "not-an-ip"] {
assert!(parse_ip(value).is_none(), "accepted invalid IP: {value:?}");
}
}
#[test]
fn builds_urls_without_allowing_path_or_query_injection() {
let ip = "2001:4860:4860::8888";
let info = ipinfo_url(ip).expect("fixed URL should parse");
let info = url::Url::parse(&info).unwrap();
assert_eq!(info.host_str(), Some("ipinfo.io"));
assert_eq!(
info.path_segments().unwrap().collect::<Vec<_>>(),
["2001:4860:4860::8888", "country"]
);
assert_eq!(info.query(), None);
assert_eq!(info.fragment(), None);
let api = ip_api_url(ip).expect("fixed URL should parse");
let api = url::Url::parse(&api).unwrap();
assert_eq!(api.host_str(), Some("ip-api.com"));
assert_eq!(api.query(), Some("fields=countryCode"));
assert_eq!(
api.path_segments().unwrap().collect::<Vec<_>>(),
["json", "2001:4860:4860::8888"]
);
assert_eq!(api.fragment(), None);
}
#[test]
fn country_codes_are_ascii_and_normalized() {
assert_eq!(normalize_country_code(" us\n"), Some("US".to_string()));
assert_eq!(normalize_country_code("GBR"), Some("GBR".to_string()));
for value in ["", "U", "US!", "US\nX", "中国"] {
assert!(
normalize_country_code(value).is_none(),
"accepted {value:?}"
);
}
}
}