diff --git a/.env.example b/.env.example index 903ffaa65..999f76094 100644 --- a/.env.example +++ b/.env.example @@ -106,6 +106,9 @@ ADMIN_USERNAME=admin123456 # AETHER_INTERNAL_GATEWAY_AUTH_SECRET= # 远程 relay 地址必须使用 HTTPS;HTTP 仅允许 localhost 或回环 IP。 # AETHER_TUNNEL_RELAY_BASE_URL=https://gateway-a.example.com +# 跨网关 relay 解析到受控私有地址时才显式开启;默认关闭以防止被篡改的 attachment +# 记录诱导网关向内网转发 relay 凭据。该开关不放宽普通 provider 的目标地址策略。 +# AETHER_TUNNEL_RELAY_ALLOW_PRIVATE_TARGETS=false # Bark 自建服务默认仅允许公网 HTTPS。确需明文 HTTP 或内网目标时分别显式开启: # AETHER_BARK_ALLOW_HTTP=false # AETHER_BARK_ALLOW_PRIVATE_TARGETS=false diff --git a/README.md b/README.md index 7f529b8e2..38c5be444 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,7 @@ Aether Tunnel 是配套的正向代理节点,部署在海外 VPS 上,为墙 - `AETHER_MAX_REQUEST_BODY_MB`:单请求解压后请求体上限,默认 `256MB`;显式设为 `0` 表示不再收紧默认值,但仍受 `256MB` 安全硬上限约束 - `AETHER_MAX_INTERNAL_BUFFERED_BODY_MB`:heartbeat、管理探测等内部整包响应体上限,默认 `64MB`;显式设为 `0` 表示不再收紧默认值,但仍受 `256MB` 安全硬上限约束 - `AETHER_TUNNEL_NODE_STATUS_QUEUE_CAPACITY`:隧道节点状态上报队列容量,默认 `1024`;满载时拒绝新事件,避免控制面故障导致无界内存增长 +- `AETHER_TUNNEL_RELAY_ALLOW_PRIVATE_TARGETS`:跨网关 owner relay 解析到私有/保留地址时的显式运维开关,默认关闭;仅当多网关 relay URL 是受控的内网 HTTPS 地址时设置为 `true`。它不改变普通 provider 请求的 DNS/代理策略,也不允许明文 HTTP 非 loopback relay - `AETHER_INTERNAL_GATEWAY_AUTH_SECRET`:旧版 `/api/internal/gateway/*` 高权限控制面的独立 HMAC 密钥,至少 `32` 字节;未配置时该控制面返回 `404`。不要复用 JWT、数据加密或 tunnel relay 密钥,多节点必须使用同一值及共享 Redis 防重放 - `AETHER_GATEWAY_SECURITY_CACHE_TTL_MS`:IP 黑白名单本地缓存时间,默认 `1000ms`,写操作会主动失效相关缓存 - `AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB`:PII 恢复同步响应缓冲上限,默认 `64MB`;显式设为 `0` 表示不再收紧默认值,但仍受 `256MB` 安全硬上限约束 diff --git a/apps/aether-gateway/src/tunnel/mod.rs b/apps/aether-gateway/src/tunnel/mod.rs index 955681fb1..82bdbee1a 100644 --- a/apps/aether-gateway/src/tunnel/mod.rs +++ b/apps/aether-gateway/src/tunnel/mod.rs @@ -64,6 +64,12 @@ const TUNNEL_ATTACHMENT_KEY_PREFIX: &str = "tunnel.attachments."; const TUNNEL_ATTACHMENT_REDIS_KEY_PREFIX: &str = "tunnel:attachments:"; const TUNNEL_INSTANCE_ID_ENV: &str = "AETHER_GATEWAY_INSTANCE_ID"; const TUNNEL_RELAY_BASE_URL_ENV: &str = "AETHER_TUNNEL_RELAY_BASE_URL"; +// Owner-forward relay URLs are deployment metadata, but a corrupted or +// stale attachment record must not turn the gateway into a generic internal +// network client. Private HTTPS relay targets therefore require an explicit +// operator opt-in, just like the tunnel's private upstream target escape +// hatch. Loopback HTTP remains supported for the local single-process path. +const TUNNEL_RELAY_ALLOW_PRIVATE_TARGETS_ENV: &str = "AETHER_TUNNEL_RELAY_ALLOW_PRIVATE_TARGETS"; const TUNNEL_ATTACHMENT_TTL_ENV: &str = "AETHER_TUNNEL_ATTACHMENT_TTL_SECS"; const TUNNEL_RELAY_AUTH_SECRET_ENV: &str = "AETHER_TUNNEL_RELAY_AUTH_SECRET"; const TUNNEL_HEARTBEAT_STATE_KEY_PREFIX: &str = "tunnel:heartbeat:session:"; @@ -233,10 +239,26 @@ async fn resolve_owner_forward_target( } fn build_owner_forward_target( + scheme: &str, + host: &str, + port: u16, + addresses: Vec, +) -> Result { + build_owner_forward_target_with_policy( + scheme, + host, + port, + addresses, + tunnel_relay_allows_private_targets(), + ) +} + +fn build_owner_forward_target_with_policy( scheme: &str, host: &str, port: u16, mut addresses: Vec, + allow_private_targets: bool, ) -> Result { let host = host.trim(); if host.is_empty() { @@ -266,6 +288,19 @@ fn build_owner_forward_target( { return Err("loopback HTTP owner gateway resolved to a non-loopback address".to_string()); } + if !allow_private_targets + && addresses.iter().any(|address| { + aether_http::is_private_or_reserved_ip(address.ip()) + // The existing local-development exception is deliberately + // narrow: HTTPS never gets an implicit loopback/private + // exemption, while literal/localhost HTTP is checked above. + && !(scheme.eq_ignore_ascii_case("http") && address.ip().is_loopback()) + }) + { + return Err(format!( + "owner gateway DNS resolution returned a private or reserved address; set {TUNNEL_RELAY_ALLOW_PRIVATE_TARGETS_ENV}=true only for a trusted internal relay" + )); + } Ok(ResolvedOwnerForwardTarget { scheme: scheme.to_ascii_lowercase(), @@ -276,6 +311,17 @@ fn build_owner_forward_target( }) } +fn tunnel_relay_allows_private_targets() -> bool { + std::env::var(TUNNEL_RELAY_ALLOW_PRIVATE_TARGETS_ENV) + .ok() + .is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + fn build_owner_forward_pinned_client( target: &ResolvedOwnerForwardTarget, ) -> Result { @@ -2695,9 +2741,9 @@ fn parse_embedded_tunnel_heartbeat_request( mod tests { use super::{ apply_embedded_tunnel_heartbeat, apply_embedded_tunnel_node_status, - build_owner_forward_target, build_tunnel_affinity_auth_metadata, - build_tunnel_owner_relay_url, build_tunnel_probe_meta, current_unix_secs, - encode_tunnel_relay_envelope, owner_forward_client_for_url, + build_owner_forward_target, build_owner_forward_target_with_policy, + build_tunnel_affinity_auth_metadata, build_tunnel_owner_relay_url, build_tunnel_probe_meta, + current_unix_secs, encode_tunnel_relay_envelope, owner_forward_client_for_url, prepare_owner_relay_request_body, prepare_owner_relay_request_body_with_limits, resolve_owner_forward_target, resolve_tunnel_relay_auth_secret_value, tunnel_attachment_key, validate_tunnel_relay_transport_url, AppState, GatewayDataState, @@ -2797,9 +2843,9 @@ mod tests { "gateway.example.com", 8443, vec![ - "[2001:db8::2]:8443".parse().unwrap(), - "192.0.2.10:8443".parse().unwrap(), - "192.0.2.10:8443".parse().unwrap(), + "[2001:4860:4860::8888]:8443".parse().unwrap(), + "8.8.8.8:8443".parse().unwrap(), + "8.8.8.8:8443".parse().unwrap(), ], ) .expect("valid DNS answers should produce a target"); @@ -2851,13 +2897,22 @@ mod tests { ) .is_ok()); - // Private HTTPS owner deployments remain supported. + // Private HTTPS owner deployments require an explicit deployment + // opt-in; the default path must not be an SSRF primitive. assert!(build_owner_forward_target( "https", "gateway.internal", 8443, vec!["10.0.0.8:8443".parse().unwrap()], ) + .is_err()); + assert!(build_owner_forward_target_with_policy( + "https", + "gateway.internal", + 8443, + vec!["10.0.0.8:8443".parse().unwrap()], + true, + ) .is_ok()); }