diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 60c1eca..69b52b8 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -109,13 +109,15 @@ impl Page { let frame_id = id.clone(); #[cfg(feature = "stealth")] let stealth_client = if context.stealth { - // wreq doesn't support SOCKS5; Clash mixed port handles both. - let stealth_proxy = context.proxy_url.as_ref().map(|u| { - u.replacen("socks5://", "http://", 1) - }); + // The wreq client backing StealthHttpClient does not speak SOCKS5. + // Callers must validate the proxy scheme up front and fail loudly + // (see obscura-cli) rather than silently rewriting socks5:// to + // http://, which only works when the upstream happens to be a + // Clash-style mixed-mode proxy and breaks plain SOCKS5 servers + // like `ssh -ND` (#160). Some(Arc::new(StealthHttpClient::with_proxy( context.cookie_jar.clone(), - stealth_proxy.as_deref(), + context.proxy_url.as_deref(), ))) } else { None diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs index 92f17c5..0e22f04 100644 --- a/crates/obscura-cli/src/main.rs +++ b/crates/obscura-cli/src/main.rs @@ -188,6 +188,26 @@ fn merge_proxy(global_proxy: Option, command_proxy: Option) -> O command_proxy.or(global_proxy) } +/// `--stealth` routes outbound traffic through `wreq`, which doesn't speak +/// SOCKS5. A previous workaround silently rewrote `socks5://` to `http://`, +/// which broke plain SOCKS5 servers (#160). Refuse the combination at +/// startup so the user gets a clear message instead of `TunnelUnexpectedEof`. +fn reject_stealth_with_socks5(proxy: Option<&str>, stealth: bool) -> anyhow::Result<()> { + if !stealth { + return Ok(()); + } + let Some(p) = proxy else { return Ok(()) }; + let scheme = p.split("://").next().unwrap_or("").to_ascii_lowercase(); + if scheme == "socks5" || scheme == "socks5h" { + anyhow::bail!( + "--stealth does not support SOCKS5 proxies (the stealth HTTP \ + client cannot reach the upstream). Use --proxy http://... \ + or drop --stealth." + ); + } + Ok(()) +} + /// Normalize a raw `--v8-flags` value into the string we'll hand to V8. /// Returns `None` when the user didn't pass the flag, passed an empty string, /// or passed only whitespace; in those cases V8 is left untouched. @@ -224,6 +244,7 @@ async fn main() -> anyhow::Result<()> { match args.command { Some(Command::Serve { port, host, proxy, user_agent, stealth, workers, allow_file_access }) => { let proxy = merge_proxy(global_proxy.clone(), proxy); + reject_stealth_with_socks5(proxy.as_deref(), stealth)?; print_banner(port); if let Some(ref proxy) = proxy { tracing::info!("Using proxy: {}", proxy); @@ -250,12 +271,15 @@ async fn main() -> anyhow::Result<()> { } } Some(Command::Fetch { url, dump, selector, wait, timeout, wait_until, user_agent, stealth, eval, output, quiet }) => { + reject_stealth_with_socks5(global_proxy.as_deref(), stealth)?; run_fetch(&url, dump, selector, wait, timeout, &wait_until, user_agent, stealth, eval, output, quiet, global_proxy).await?; } Some(Command::Scrape { urls, eval, concurrency, format, timeout, quiet }) => { run_parallel_scrape(urls, eval, concurrency.get(), &format, timeout, quiet, global_proxy).await?; } Some(Command::Mcp { http, port, proxy, user_agent, stealth }) => { + let mcp_proxy = merge_proxy(global_proxy.clone(), proxy.clone()); + reject_stealth_with_socks5(mcp_proxy.as_deref(), stealth)?; if http { obscura_mcp::http::run(port, proxy, user_agent, stealth).await?; } else { @@ -929,8 +953,8 @@ fn dump_links(page: &Page) -> String { mod tests { use super::{ extract_readable_text, fetch_original_bytes, is_quiet_command, merge_proxy, - normalize_v8_flags, select_log_filter, write_or_print, write_or_print_bytes, Args, - Command, DumpFormat, + normalize_v8_flags, reject_stealth_with_socks5, select_log_filter, write_or_print, + write_or_print_bytes, Args, Command, DumpFormat, }; use clap::Parser; use obscura_dom::parse_html; @@ -1179,6 +1203,41 @@ mod tests { assert_eq!(normalize_v8_flags(Some(input)).as_deref(), Some(input)); } + #[test] + fn reject_stealth_with_socks5_passes_when_no_stealth() { + assert!(reject_stealth_with_socks5(Some("socks5://127.0.0.1:1080"), false).is_ok()); + } + + #[test] + fn reject_stealth_with_socks5_passes_when_no_proxy() { + assert!(reject_stealth_with_socks5(None, true).is_ok()); + } + + #[test] + fn reject_stealth_with_socks5_passes_for_http_proxy() { + assert!(reject_stealth_with_socks5(Some("http://127.0.0.1:8080"), true).is_ok()); + assert!(reject_stealth_with_socks5(Some("https://proxy.example:443"), true).is_ok()); + } + + #[test] + fn reject_stealth_with_socks5_fails_for_socks5() { + let err = reject_stealth_with_socks5(Some("socks5://127.0.0.1:9999"), true).unwrap_err(); + assert!(err.to_string().contains("SOCKS5")); + assert!(err.to_string().contains("--stealth")); + } + + #[test] + fn reject_stealth_with_socks5_fails_for_socks5h() { + let err = reject_stealth_with_socks5(Some("socks5h://127.0.0.1:9999"), true).unwrap_err(); + assert!(err.to_string().contains("SOCKS5")); + } + + #[test] + fn reject_stealth_with_socks5_is_case_insensitive() { + let err = reject_stealth_with_socks5(Some("SOCKS5://127.0.0.1:9999"), true).unwrap_err(); + assert!(err.to_string().contains("SOCKS5")); + } + #[test] fn parsed_fetch_quiet_resolves_to_off_filter() { let args = Args::try_parse_from([