From 8a7a86bdb265606714afc0acfeba22728eff98ab Mon Sep 17 00:00:00 2001 From: May Knott Date: Sun, 24 May 2026 01:26:58 +0330 Subject: [PATCH] fix(proxy): preserve SOCKS5 remote DNS semantics SOCKS5 clients can send domain-name targets with ATYP=0x03, which gives the proxy an unresolved hostname and lets resolution happen on a remote transport path. If that flow later falls through to raw TCP direct passthrough, TcpStream::connect((host, port)) asks the local resolver for the destination address and can expose the target hostname outside the tunnel. The SOCKS5 request handler now marks ATYP=domain flows as requiring remote DNS preservation before handing the stream to the shared tunnel dispatcher. HTTP CONNECT and plain HTTP proxy requests pass the flag disabled, so this guard is tied to SOCKS5 domain-name semantics rather than changing every proxy mode. Raw TCP passthrough now refuses direct hostname fallback when remote DNS is required and no upstream SOCKS5 proxy is available. If an upstream SOCKS5 proxy is configured, the hostname is sent to that proxy unchanged so resolution can remain remote. If the upstream SOCKS5 connection fails for a hostname that requires remote DNS, the proxy returns without falling back to direct local resolution. IP literals remain eligible for direct passthrough because they do not require DNS resolution. Full Tunnel, Apps Script HTTP relay, MITM relay, and SNI-rewrite paths continue to receive the original hostname without introducing local destination lookups. The guide documents the fail-closed behavior for SOCKS5 domain targets, and unit coverage exercises hostname refusal, IPv4 and IPv6 literal allowance, upstream SOCKS5 allowance, and non-SOCKS call-site behavior. --- docs/guide.fa.md | 2 + docs/guide.md | 2 + src/proxy_server.rs | 109 ++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/docs/guide.fa.md b/docs/guide.fa.md index d0247453..0176373d 100644 --- a/docs/guide.fa.md +++ b/docs/guide.fa.md @@ -206,6 +206,8 @@ upstream_socks5 = "127.0.0.1:50529" HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی‌کند)، تونل بازنویسی SNI برای `google.com` / `youtube.com` همچنان از هر دو دور می‌زند — یوتیوب به سرعت قبل می‌ماند و تلگرام هم تونل واقعی پیدا می‌کند. +هدف‌های دامنه‌ای SOCKS5 معنای remote-DNS را حفظ می‌کنند: اگر پروکسی برای TCP خام مجبور شود hostname را محلی resolve کند و نه Full Tunnel و نه `upstream_socks5` در دسترس باشد، به‌جای نشت DNS plaintext، اتصال را fail-closed می‌کند. + ## حالت تونل کامل `"mode": "full"` **تمام** ترافیک را end-to-end از Apps Script و یک [tunnel-node](../tunnel-node/) راه دور رد می‌کند — بدون نیاز به نصب گواهی MITM. TCP به‌صورت سشن‌های پایدار تونل، و UDP از کلاینت‌های اندروید / TUN از طریق SOCKS5 `UDP ASSOCIATE` به tunnel-node که UDP واقعی را از سمت سرور منتشر می‌کند. مبادله: تأخیر بیشتر هر درخواست (هر بایت Apps Script → tunnel-node → مقصد می‌رود)، اما برای هر پروتکل و هر برنامه‌ای بدون نصب CA کار می‌کند. diff --git a/docs/guide.md b/docs/guide.md index 679a35d0..6e505d79 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -206,6 +206,8 @@ upstream_socks5 = "127.0.0.1:50529" HTTP / HTTPS keeps going through Apps Script (no change), and the SNI-rewrite tunnel for `google.com` / `youtube.com` keeps bypassing both — YouTube stays as fast as before while Telegram gets a real tunnel. +SOCKS5 domain targets preserve remote-DNS semantics: if the proxy would have to resolve a hostname locally for raw TCP and neither Full Tunnel nor `upstream_socks5` is available, it fails closed instead of leaking a plaintext DNS lookup. + ## Full Tunnel mode `"mode": "full"` routes **all** traffic end-to-end through Apps Script and a remote [tunnel-node](../tunnel-node/) — no MITM certificate needed. TCP carried as persistent tunnel sessions, UDP from Android / TUN clients via SOCKS5 `UDP ASSOCIATE` to the tunnel-node which emits real UDP server-side. Trade-off: higher per-request latency (every byte goes Apps Script → tunnel-node → destination), but works for any protocol and any app, no CA install required. diff --git a/src/proxy_server.rs b/src/proxy_server.rs index 209bbc58..6d1e8ab6 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -832,7 +832,17 @@ async fn handle_http_client( sock.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") .await?; sock.flush().await?; - dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx, tunnel_mux).await + dispatch_tunnel( + sock, + host, + port, + fronter, + mitm, + rewrite_ctx, + tunnel_mux, + false, + ) + .await } else { // Plain HTTP proxy request (e.g. `GET http://…`). // @@ -960,7 +970,18 @@ async fn handle_socks5_client( .await?; sock.flush().await?; - dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx, tunnel_mux).await + let require_remote_dns = atyp == 0x03; + dispatch_tunnel( + sock, + host, + port, + fronter, + mitm, + rewrite_ctx, + tunnel_mux, + require_remote_dns, + ) + .await } #[derive(Clone, Debug, Eq, Hash, PartialEq)] @@ -1626,6 +1647,7 @@ async fn dispatch_tunnel( mitm: Arc>, rewrite_ctx: Arc, tunnel_mux: Option>, + require_remote_dns: bool, ) -> std::io::Result<()> { // 0. User-configured passthrough list wins over every other path. // If the host matches `passthrough_hosts`, we raw-TCP it (through @@ -1641,7 +1663,7 @@ async fn dispatch_tunnel( port, via.unwrap_or("direct") ); - plain_tcp_passthrough(sock, &host, port, via).await; + plain_tcp_passthrough(sock, &host, port, via, require_remote_dns).await; return Ok(()); } @@ -1675,7 +1697,7 @@ async fn dispatch_tunnel( port, via.unwrap_or("direct") ); - plain_tcp_passthrough(sock, &host, port, via).await; + plain_tcp_passthrough(sock, &host, port, via, require_remote_dns).await; return Ok(()); } @@ -1761,7 +1783,7 @@ async fn dispatch_tunnel( port, via.unwrap_or("direct") ); - plain_tcp_passthrough(sock, &host, port, via).await; + plain_tcp_passthrough(sock, &host, port, via, require_remote_dns).await; return Ok(()); } @@ -1776,7 +1798,14 @@ async fn dispatch_tunnel( host, port ); - plain_tcp_passthrough(sock, &host, port, rewrite_ctx.upstream_socks5.as_deref()).await; + plain_tcp_passthrough( + sock, + &host, + port, + rewrite_ctx.upstream_socks5.as_deref(), + require_remote_dns, + ) + .await; return Ok(()); } }; @@ -1802,7 +1831,7 @@ async fn dispatch_tunnel( port, via.unwrap_or("direct") ); - plain_tcp_passthrough(sock, &host, port, via).await; + plain_tcp_passthrough(sock, &host, port, via, require_remote_dns).await; return Ok(()); } }; @@ -1843,7 +1872,7 @@ async fn dispatch_tunnel( port, via.unwrap_or("direct") ); - plain_tcp_passthrough(sock, &host, port, via).await; + plain_tcp_passthrough(sock, &host, port, via, require_remote_dns).await; Ok(()) } @@ -1854,8 +1883,18 @@ async fn plain_tcp_passthrough( host: &str, port: u16, upstream_socks5: Option<&str>, + require_remote_dns: bool, ) { let target_host = host.trim_start_matches('[').trim_end_matches(']'); + let target_is_ip = looks_like_ip(target_host); + if should_refuse_local_dns_fallback(require_remote_dns, target_host, upstream_socks5) { + tracing::warn!( + "refusing raw-tcp direct fallback for SOCKS5 domain target {}:{} to avoid local DNS resolution", + host, + port + ); + return; + } // Shorter connect timeout for IP literals (4s vs 10s for hostnames). // Ported from upstream Python 7b1812c: when the target is an IP (i.e. // a raw Telegram DC, or an IP someone hardcoded), and that route is @@ -1867,7 +1906,7 @@ async fn plain_tcp_passthrough( // Hostnames still get 10s because DNS + first-hop TCP genuinely can // take that long on flaky links, and the resolver fallbacks already // trim the worst case. - let connect_timeout = if looks_like_ip(target_host) { + let connect_timeout = if target_is_ip { std::time::Duration::from_secs(4) } else { std::time::Duration::from_secs(10) @@ -1879,6 +1918,16 @@ async fn plain_tcp_passthrough( s } Err(e) => { + if require_remote_dns && !target_is_ip { + tracing::warn!( + "upstream-socks5 {} -> {}:{} failed: {}; refusing local DNS fallback", + proxy, + host, + port, + e + ); + return; + } tracing::warn!( "upstream-socks5 {} -> {}:{} failed: {} (falling back to direct)", proxy, @@ -1928,6 +1977,14 @@ async fn plain_tcp_passthrough( } } +fn should_refuse_local_dns_fallback( + require_remote_dns: bool, + target_host: &str, + upstream_socks5: Option<&str>, +) -> bool { + require_remote_dns && upstream_socks5.is_none() && !looks_like_ip(target_host) +} + /// Open a TCP stream to `(host, port)` through an upstream SOCKS5 proxy /// (no-auth only). Returns the connected stream after SOCKS5 negotiation. async fn socks5_connect_via(proxy: &str, host: &str, port: u16) -> std::io::Result { @@ -3228,6 +3285,40 @@ mod tests { assert_eq!(build_socks5_udp_packet(&target, payload), raw); } + #[test] + fn socks5_remote_dns_refuses_direct_hostname_fallback() { + assert!(should_refuse_local_dns_fallback( + true, + "example.com", + None + )); + assert!(should_refuse_local_dns_fallback( + true, + "sub.example.com", + None + )); + } + + #[test] + fn socks5_remote_dns_allows_paths_that_do_not_need_local_dns() { + assert!(!should_refuse_local_dns_fallback(true, "1.2.3.4", None)); + assert!(!should_refuse_local_dns_fallback( + true, + "2001:db8::1", + None + )); + assert!(!should_refuse_local_dns_fallback( + true, + "example.com", + Some("127.0.0.1:9050") + )); + assert!(!should_refuse_local_dns_fallback( + false, + "example.com", + None + )); + } + #[tokio::test(flavor = "current_thread")] async fn read_body_decodes_chunked_request() { let (mut client, mut server) = duplex(1024);