From b08fa3bdb6cbfe31d776495096335fe02c3868ea Mon Sep 17 00:00:00 2001 From: elky Date: Fri, 4 Sep 2026 21:10:55 +0800 Subject: [PATCH] fix(network): preserve remote DNS semantics for SOCKS proxies --- .../src/execution_runtime/transport.rs | 49 +++++++++++++++++-- .../src/handlers/admin/system/proxy_nodes.rs | 20 +++++++- .../src/handlers/proxy/websocket/transport.rs | 27 +++++++--- 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/apps/aether-gateway/src/execution_runtime/transport.rs b/apps/aether-gateway/src/execution_runtime/transport.rs index cd3e945a5..8bc6bd113 100644 --- a/apps/aether-gateway/src/execution_runtime/transport.rs +++ b/apps/aether-gateway/src/execution_runtime/transport.rs @@ -5036,14 +5036,38 @@ fn resolve_proxy_url( .map(|url| url.trim()) .filter(|url| !url.is_empty()) { - validate_execution_proxy_url(proxy_url)?; - return Ok(Some(proxy_url.to_string())); + return normalize_execution_proxy_url(proxy_url).map(Some); } Err(ExecutionRuntimeTransportError::ProxyUnsupported) } fn validate_execution_proxy_url(raw_url: &str) -> Result<(), ExecutionRuntimeTransportError> { + parse_execution_proxy_url(raw_url).map(|_| ()) +} + +/// Normalize a configured proxy URL before handing it to reqwest/wreq. +/// +/// `socks5://` has a particularly dangerous ambiguity in a gateway: reqwest +/// and wreq interpret it as *local* target-name resolution, while +/// `socks5h://` delegates target resolution to the proxy. Local resolution +/// would bypass the execution DNS guard (and could turn a rebinding hostname +/// into a private address). Keep accepting the established `socks5` config +/// syntax for compatibility, but make its runtime semantics the safe remote +/// DNS variant. HTTP/HTTPS and already-remote `socks5h` URLs are unchanged. +pub(crate) fn normalize_execution_proxy_url( + raw_url: &str, +) -> Result { + let mut parsed = parse_execution_proxy_url(raw_url)?; + if parsed.scheme().eq_ignore_ascii_case("socks5") { + parsed + .set_scheme("socks5h") + .map_err(|_| ExecutionRuntimeTransportError::ProxyUnsupported)?; + } + Ok(parsed.to_string()) +} + +fn parse_execution_proxy_url(raw_url: &str) -> Result { let parsed = url::Url::parse(raw_url).map_err(|_| ExecutionRuntimeTransportError::ProxyUnsupported)?; if !matches!(parsed.scheme(), "http" | "https" | "socks5" | "socks5h") @@ -5054,7 +5078,7 @@ fn validate_execution_proxy_url(raw_url: &str) -> Result<(), ExecutionRuntimeTra { return Err(ExecutionRuntimeTransportError::ProxyUnsupported); } - Ok(()) + Ok(parsed) } pub(crate) fn build_request_headers( @@ -5627,6 +5651,25 @@ mod tests { .is_ok()); } + #[test] + fn execution_proxy_url_normalizes_local_socks_dns_to_remote_dns() { + assert_eq!( + super::normalize_execution_proxy_url("socks5://alice:password@proxy.example.test:1080") + .expect("socks5 URL should normalize"), + "socks5h://alice:password@proxy.example.test:1080" + ); + assert_eq!( + super::normalize_execution_proxy_url("socks5h://proxy.example.test:1080") + .expect("socks5h URL should remain valid"), + "socks5h://proxy.example.test:1080" + ); + assert_eq!( + super::normalize_execution_proxy_url("https://proxy.example.test:8443") + .expect("https URL should remain valid"), + "https://proxy.example.test:8443/" + ); + } + #[test] fn relay_error_kind_accepts_only_protocol_categories() { assert_eq!(super::sanitize_relay_error_kind("TIMEOUT"), "timeout"); diff --git a/apps/aether-gateway/src/handlers/admin/system/proxy_nodes.rs b/apps/aether-gateway/src/handlers/admin/system/proxy_nodes.rs index cd31ab10d..134b51952 100644 --- a/apps/aether-gateway/src/handlers/admin/system/proxy_nodes.rs +++ b/apps/aether-gateway/src/handlers/admin/system/proxy_nodes.rs @@ -1272,7 +1272,25 @@ async fn test_manual_proxy_connectivity_with_probe_url( timeout_secs: u64, ) -> Value { let started_at = Instant::now(); - let proxy = match reqwest::Proxy::all(proxy_url) { + // reqwest resolves the destination locally for `socks5://`, which would + // bypass the gateway's private-address/DNS-rebinding guard. Normalize + // legacy SOCKS URLs to the remote-DNS form before probing; HTTP/HTTPS and + // already-normalized `socks5h://` URLs are unchanged. + let proxy_url = + match crate::execution_runtime::transport::normalize_execution_proxy_url(proxy_url) { + Ok(proxy_url) => proxy_url, + Err(_) => { + return build_proxy_connectivity_result( + probe_url, + timeout_secs, + false, + None, + None, + Some("代理 URL 无效".to_string()), + ); + } + }; + let proxy = match reqwest::Proxy::all(&proxy_url) { Ok(proxy) => proxy, Err(error) => { return build_proxy_connectivity_result( diff --git a/apps/aether-gateway/src/handlers/proxy/websocket/transport.rs b/apps/aether-gateway/src/handlers/proxy/websocket/transport.rs index 425e3289a..4da39352d 100644 --- a/apps/aether-gateway/src/handlers/proxy/websocket/transport.rs +++ b/apps/aether-gateway/src/handlers/proxy/websocket/transport.rs @@ -23,7 +23,8 @@ use wreq::ws::message::{CloseFrame as WreqCloseFrame, Message as WreqWsMessage}; use crate::ai_serving::AiExecutionDecision; use crate::execution_runtime::transport::{ - build_browser_wreq_client, build_request_headers, ExecutionTransportControls, + build_browser_wreq_client, build_request_headers, normalize_execution_proxy_url, + ExecutionTransportControls, }; use crate::frontdoor_loop_guard::gateway_frontdoor_self_loop_guard_error; use crate::handlers::proxy::websocket::session::{ @@ -287,10 +288,10 @@ async fn build_websocket_client( builder.build().map_err(|_| errors.client_build_failed) } -fn resolve_websocket_proxy_url<'a>( - proxy: Option<&'a ProxySnapshot>, +fn resolve_websocket_proxy_url( + proxy: Option<&ProxySnapshot>, errors: UpstreamWebSocketErrorCodes, -) -> Result, &'static str> { +) -> Result, &'static str> { let Some(proxy) = proxy else { return Ok(None); }; @@ -323,7 +324,9 @@ fn resolve_websocket_proxy_url<'a>( { return Err(errors.proxy_invalid); } - return Ok(Some(proxy_url)); + let normalized = + normalize_execution_proxy_url(proxy_url).map_err(|_| errors.proxy_invalid)?; + return Ok(Some(normalized)); } if proxy.node_id.is_some() || proxy.mode.as_deref() == Some("tunnel") { return Err(errors.tunnel_proxy_unsupported); @@ -976,7 +979,19 @@ mod tests { }; assert_eq!( resolve_websocket_proxy_url(Some(&authenticated_node), errors), - Ok(Some("http://alice:password@proxy.example:8080")) + Ok(Some( + "http://alice:password@proxy.example:8080/".to_string() + )) + ); + + let socks = ProxySnapshot { + enabled: Some(true), + url: Some("socks5://proxy.example:1080".to_string()), + ..ProxySnapshot::default() + }; + assert_eq!( + resolve_websocket_proxy_url(Some(&socks), errors), + Ok(Some("socks5h://proxy.example:1080".to_string())) ); }