fix(cli): refuse --stealth + socks5:// proxy instead of silent breakage (#160)

The previous workaround in page.rs rewrote socks5:// to http:// before
handing the proxy to the stealth client. That works for Clash-style
mixed-mode proxies, but breaks plain SOCKS5 servers like `ssh -ND`,
producing a confusing `TunnelUnexpectedEof` deep in the network layer.

The stealth client is backed by wreq, which does not speak SOCKS5,
so silently substituting schemes can never be correct in general.

Refuse the combination at CLI startup with a clear message that tells
the user what to do (use --proxy http://... or drop --stealth) and
revert the rewrite in Page::new. SOCKS5 without --stealth still works
through the reqwest path.
This commit is contained in:
SGavrl committed 2026-05-15 21:23:53 +00:00
1 parent b2b41529c4
commit 8cb0cb429b
2 files changed
+68 -7

No files matched your search

+7 -5
View File
@@ -109,13 +109,15 @@ impl Page {
let frame_id = id.clone(); let frame_id = id.clone();
#[cfg(feature = "stealth")] #[cfg(feature = "stealth")]
let stealth_client = if context.stealth { let stealth_client = if context.stealth {
// wreq doesn't support SOCKS5; Clash mixed port handles both. // The wreq client backing StealthHttpClient does not speak SOCKS5.
let stealth_proxy = context.proxy_url.as_ref().map(|u| { // Callers must validate the proxy scheme up front and fail loudly
u.replacen("socks5://", "http://", 1) // (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( Some(Arc::new(StealthHttpClient::with_proxy(
context.cookie_jar.clone(), context.cookie_jar.clone(),
stealth_proxy.as_deref(), context.proxy_url.as_deref(),
))) )))
} else { } else {
None None
+61 -2
View File
@@ -188,6 +188,26 @@ fn merge_proxy(global_proxy: Option<String>, command_proxy: Option<String>) -> O
command_proxy.or(global_proxy) 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. /// 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, /// 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. /// or passed only whitespace; in those cases V8 is left untouched.
@@ -224,6 +244,7 @@ async fn main() -> anyhow::Result<()> {
match args.command { match args.command {
Some(Command::Serve { port, host, proxy, user_agent, stealth, workers, allow_file_access }) => { Some(Command::Serve { port, host, proxy, user_agent, stealth, workers, allow_file_access }) => {
let proxy = merge_proxy(global_proxy.clone(), proxy); let proxy = merge_proxy(global_proxy.clone(), proxy);
reject_stealth_with_socks5(proxy.as_deref(), stealth)?;
print_banner(port); print_banner(port);
if let Some(ref proxy) = proxy { if let Some(ref proxy) = proxy {
tracing::info!("Using 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 }) => { 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?; 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 }) => { Some(Command::Scrape { urls, eval, concurrency, format, timeout, quiet }) => {
run_parallel_scrape(urls, eval, concurrency.get(), &format, timeout, quiet, global_proxy).await?; run_parallel_scrape(urls, eval, concurrency.get(), &format, timeout, quiet, global_proxy).await?;
} }
Some(Command::Mcp { http, port, proxy, user_agent, stealth }) => { 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 { if http {
obscura_mcp::http::run(port, proxy, user_agent, stealth).await?; obscura_mcp::http::run(port, proxy, user_agent, stealth).await?;
} else { } else {
@@ -929,8 +953,8 @@ fn dump_links(page: &Page) -> String {
mod tests { mod tests {
use super::{ use super::{
extract_readable_text, fetch_original_bytes, is_quiet_command, merge_proxy, 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, normalize_v8_flags, reject_stealth_with_socks5, select_log_filter, write_or_print,
Command, DumpFormat, write_or_print_bytes, Args, Command, DumpFormat,
}; };
use clap::Parser; use clap::Parser;
use obscura_dom::parse_html; use obscura_dom::parse_html;
@@ -1179,6 +1203,41 @@ mod tests {
assert_eq!(normalize_v8_flags(Some(input)).as_deref(), Some(input)); 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] #[test]
fn parsed_fetch_quiet_resolves_to_off_filter() { fn parsed_fetch_quiet_resolves_to_off_filter() {
let args = Args::try_parse_from([ let args = Args::try_parse_from([