Skip to content

fix(proxy): SOCKS5 upstream auth reliability#455

Open
liasica wants to merge 1 commit into
zhom:mainfrom
liasica:fix/socks5-upstream-auth-reliability
Open

fix(proxy): SOCKS5 upstream auth reliability#455
liasica wants to merge 1 commit into
zhom:mainfrom
liasica:fix/socks5-upstream-auth-reliability

Conversation

@liasica

@liasica liasica commented Jun 19, 2026

Copy link
Copy Markdown

Summary

Two independent root causes were producing intermittent / per-credential SOCKS5 upstream authentication failures on the worker dial path. Both addressed in this PR.

  • Credentials were sent percent-encoded. url::Url::username() / Url::password() return percent-encoded strings per WHATWG, but proxy_manager::build_proxy_url and bin/proxy_server::build_proxy_url already percent-encode the user/pass when assembling the upstream URL. The upstream therefore received %40 instead of @ for any credential containing @ : / + = % ! space or non-ASCII — RFC1929 (and HTTP Basic-Auth) silently failed. Centralized the decode in a new upstream_userpass helper and routed all four upstream-dial sites through it. Shadowsocks already decoded manually and is unchanged.
  • Fragmented async_socks5 handshake triggered silent FIN on some upstreams. async_socks5 0.6 calls write_u8 for every single-byte field of method-selection and RFC1929. On a raw TcpStream each call becomes its own TCP segment, and some upstream SOCKS5 implementations treat that as a misbehaving client and silently FIN instead of returning a status — curl with the same credentials works because it buffers each sub-message into a single send(). Wrapped the upstream socket in tokio::io::BufStream (the pattern the async_socks5 README shows) and enabled TCP_NODELAY so flushes leave unsegmented.

Also adds a trace-level SocksHandshakeLogger (off by default, enable with RUST_LOG=donutbrowser_lib::proxy_server=trace) that dumps every read/write byte of the SOCKS5 handshake — used to root-cause #2 and kept around for future debugging.

Test plan

  • pnpm format && pnpm lint && pnpm test — all green (lib 373 + 14 + 15 + 15)
  • Added unit tests for upstream_userpass: plain ASCII / @ : / + = % ! space / non-ASCII / no-credentials / username-only — all pass
  • End-to-end repro: SOCKS5 upstream with username/password that previously failed with ERR_SOCKS_CONNECTION_FAILED (CONNECT tunnel ended with error: unexpected end of file) now succeeds through Wayfern; verified browser fetches api.ipify.org over the upstream
  • (Optional reviewer check) RUST_LOG=donutbrowser_lib::proxy_server=trace confirms the handshake now sends method-selection and RFC1929 each in a single coalesced syscall, and that the server returns [01, 00] before the CONNECT command is issued

…iable

Two independent root causes were producing auth failures on the upstream
SOCKS5 dial path:

1. `url::Url::username()` / `Url::password()` return percent-encoded
   strings per the WHATWG URL spec, but the producer side already
   percent-encodes the credentials when assembling the upstream URL —
   so the upstream was receiving `%40` instead of `@` and authentication
   silently failed for any credential containing `@ : / + = % ! space`
   or non-ASCII characters. Centralize the decode in a new
   `upstream_userpass` helper and route all four upstream-dial sites
   through it (HTTP CONNECT → SOCKS5, HTTP CONNECT → HTTP Basic-Auth,
   local SOCKS5 → HTTP Basic-Auth, local SOCKS5 → SOCKS5). The
   Shadowsocks path already decoded manually and is unchanged.

2. async_socks5 0.6 issues a `write_u8` for every single-byte field of
   the SOCKS5 method-selection and RFC1929 sub-negotiation. On a raw
   `TcpStream` each call becomes its own TCP segment, and some upstream
   SOCKS5 implementations treat this fragmented submission as a
   misbehaving client and silently FIN instead of returning a status —
   curl with the same credentials succeeds because it buffers each
   sub-message into a single send(). Wrap the upstream socket in
   `tokio::io::BufStream` (the usage pattern the async_socks5 README
   shows) and enable TCP_NODELAY so flushes leave unsegmented.

Includes unit tests covering percent-decode for ASCII / special-char /
non-ASCII / no-credentials / username-only inputs, plus a trace-level
SOCKS5 handshake byte logger that can be enabled with
RUST_LOG=donutbrowser_lib::proxy_server=trace for future debugging.
@zhom

zhom commented Jun 19, 2026

Copy link
Copy Markdown
Owner

Hi there and thanks for the PR!

  1. In which ways have you used AI?
  2. What is your system setup? OS, CPU architecture, etc?
  3. How exactly are you performing tests and how to reproduce the issue you fix on main?

@zhom zhom self-requested a review June 19, 2026 13:32
@liasica

liasica commented Jun 20, 2026

Copy link
Copy Markdown
Author

Hi there and thanks for the PR!

  1. In which ways have you used AI?
  2. What is your system setup? OS, CPU architecture, etc?
  3. How exactly are you performing tests and how to reproduce the issue you fix on main?

@zhom Hi zhom, thanks for reviewing this PR.

  1. In which ways have you used AI?

I used Claude and ChatGPT — mainly to help me read through proxy_server.rs, narrow down the two independent root causes (double percent-encoding of credentials, and the per-byte write_u8 fragmentation in async_socks5 0.6), and to draft the unit tests and trace-level handshake logger. All code was reviewed and tested by me before pushing.

  1. What is your system setup? OS, CPU architecture, etc?
  • OS: macOS 26.5.1 (Build 25F80)
  • CPU: Apple M1 Ultra (arm64 / aarch64-apple-darwin)
  • RAM: 64 GB
  • Toolchain: rustc 1.96.0, Node v25.3.0, pnpm 11.2.2
  1. How exactly are you performing tests and how to reproduce the issue you fix on main?

Reproducing on main:

  1. Set up a SOCKS5 (or HTTP) upstream proxy that requires username/password authentication, where the password contains at least one of: @, :, /, +, =, %, !, a space, or any non-ASCII character (e.g. p@ssw0rd, 100%off!, 测试密码). A real-world commercial proxy with such a password is enough; you can also stand one up locally with 3proxy / danted.
  2. In Donut Browser, add this upstream as a stored proxy and attach it to a profile.
  3. Launch the profile and browse any HTTPS site.
  4. On main, every request fails with an upstream auth error. The donut-proxy worker log at $TMPDIR/donut-proxy-<config_id>.log shows the SOCKS5 sub-negotiation getting a non-success status (or the connection being closed by the upstream). Meanwhile curl --socks5 user:pass@host:port https://example.com with the same credentials succeeds — that's the smoking gun.
  5. There is also a second, credential-independent failure mode: some upstream SOCKS5 servers silently FIN the connection during the RFC1929 sub-negotiation because async_socks5 0.6 issues a write_u8 per single-byte field on a raw TcpStream, so each field becomes its own TCP segment. Curl doesn't trigger this because it buffers each sub-message into one send().

Both failure modes are gone on this branch.

Tests I ran:

  • Unit tests for the new upstream_userpass helper covering plain ASCII / special chars (@ : space + = %) / non-ASCII (测试密码) / no-credentials / username-only:
    cd src-tauri && cargo test --lib proxy_server::tests::upstream_userpass
    
  • Full Rust test suite to make sure nothing else broke:
    cd src-tauri && cargo test --lib
    
  • End-to-end manual test with a real SOCKS5 upstream whose password contains @ and non-ASCII characters: launched a Chromium profile through Donut Browser on main (auth fails) and on this branch (auth succeeds, pages load).
  • Trace-level handshake inspection (added in this PR, off by default — zero overhead once the handshake completes):
    RUST_LOG=donutbrowser_lib::proxy_server=trace pnpm tauri dev
    
    On main you can see the per-byte fragmentation in the trace output; with the BufStream + TCP_NODELAY change in this PR the sub-negotiation goes out as a single segment.
  • Repo CI checks at the project root:
    pnpm format && pnpm lint && pnpm test
    

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants