From e01afcbf0de00af52fe90959ba77288679e3303d Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:40:00 +0200 Subject: SOCKS5 upstream proxy chaining Extends -upstream-proxy to accept a socks5://[user:pass@]host:port prefix, using golang.org/x/net/proxy (already an indirect dependency via http2, so no new module) rather than hand-rolling the client side of RFC 1928/1929. parseSOCKS5 is the single place that decides which kind of upstream a given UpstreamProxy string names; dialViaProxy (CONNECT/TLS path) and dialUpstreamPlain (plain-HTTP path) both check it first and fall through to the existing HTTP CONNECT behavior otherwise. SOCKS5 needs no absolute-form request adjustment on the plain-HTTP path the way HTTP-proxy chaining does, since it tunnels straight to the target rather than expecting a proxy-aware request. Tested against a real, minimal SOCKS5 server built for the test suite (exercises dialSOCKS5's actual wire behavior, not a mock of the client library), plus live against a real standalone SOCKS5 relay process: both a plain HTTP and an HTTPS request through mitmux were confirmed, via the relay's own log, to have actually traversed it. --- internal/proxy/dialer_test.go | 248 ++++++++++++++++++++++++++++++++++++++++++ internal/proxy/proxy.go | 92 ++++++++++++++-- 2 files changed, 329 insertions(+), 11 deletions(-) (limited to 'internal') diff --git a/internal/proxy/dialer_test.go b/internal/proxy/dialer_test.go index 14746c9..5ba5051 100644 --- a/internal/proxy/dialer_test.go +++ b/internal/proxy/dialer_test.go @@ -3,11 +3,14 @@ package proxy import ( "bufio" "context" + "fmt" "io" "net" "net/http" "testing" "time" + + xproxy "golang.org/x/net/proxy" ) // startStubConnectProxy runs a minimal HTTP CONNECT proxy for the @@ -56,6 +59,133 @@ func startStubConnectProxy(t *testing.T, status int) string { return ln.Addr().String() } +// startStubSOCKS5Proxy runs a minimal RFC 1928 SOCKS5 server for the +// duration of the test: negotiates no-auth or username/password (per +// requireAuth), accepts one CONNECT request, and splices the tunnel +// through to a real dial of the requested address. Deliberately minimal +// (IPv4/domain address types only, one connection) - enough to exercise +// dialSOCKS5's actual wire behavior against a real server, not a mock of +// golang.org/x/net/proxy's own client logic. +func startStubSOCKS5Proxy(t *testing.T, requireAuth bool, user, pass string) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer c.Close() + br := bufio.NewReader(c) + + // Greeting: VER NMETHODS METHODS... + hdr := make([]byte, 2) + if _, err := io.ReadFull(br, hdr); err != nil { + return + } + methods := make([]byte, hdr[1]) + if _, err := io.ReadFull(br, methods); err != nil { + return + } + want := byte(0x00) // no auth + if requireAuth { + want = 0x02 // username/password + } + found := false + for _, m := range methods { + if m == want { + found = true + } + } + if !found { + c.Write([]byte{0x05, 0xff}) + return + } + c.Write([]byte{0x05, want}) + + if requireAuth { + // VER ULEN UNAME PLEN PASSWD + authHdr := make([]byte, 2) + if _, err := io.ReadFull(br, authHdr); err != nil { + return + } + uname := make([]byte, authHdr[1]) + if _, err := io.ReadFull(br, uname); err != nil { + return + } + plenBuf := make([]byte, 1) + if _, err := io.ReadFull(br, plenBuf); err != nil { + return + } + passwd := make([]byte, plenBuf[0]) + if _, err := io.ReadFull(br, passwd); err != nil { + return + } + if string(uname) != user || string(passwd) != pass { + c.Write([]byte{0x01, 0x01}) // auth failure + return + } + c.Write([]byte{0x01, 0x00}) // auth success + } + + // Request: VER CMD RSV ATYP DST.ADDR DST.PORT + req := make([]byte, 4) + if _, err := io.ReadFull(br, req); err != nil { + return + } + if req[1] != 0x01 { // CONNECT only + c.Write([]byte{0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) + return + } + var targetHost string + switch req[3] { + case 0x01: // IPv4 + ip := make([]byte, 4) + if _, err := io.ReadFull(br, ip); err != nil { + return + } + targetHost = net.IP(ip).String() + case 0x03: // domain + l := make([]byte, 1) + if _, err := io.ReadFull(br, l); err != nil { + return + } + d := make([]byte, l[0]) + if _, err := io.ReadFull(br, d); err != nil { + return + } + targetHost = string(d) + default: + c.Write([]byte{0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) + return + } + portBuf := make([]byte, 2) + if _, err := io.ReadFull(br, portBuf); err != nil { + return + } + targetPort := int(portBuf[0])<<8 | int(portBuf[1]) + targetAddr := net.JoinHostPort(targetHost, fmt.Sprintf("%d", targetPort)) + + target, err := net.Dial("tcp", targetAddr) + if err != nil { + c.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) + return + } + defer target.Close() + c.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) + + done := make(chan struct{}, 2) + go func() { io.Copy(target, br); done <- struct{}{} }() + go func() { io.Copy(c, target); done <- struct{}{} }() + <-done + }() + return ln.Addr().String() +} + // startEchoServer runs a TCP server that echoes back whatever it reads, // standing in for "the origin" on the far side of a CONNECT tunnel. func startEchoServer(t *testing.T) string { @@ -137,3 +267,121 @@ func TestDialViaProxyRejected(t *testing.T) { t.Fatal("expected an error when the upstream proxy refuses CONNECT, got nil") } } + +func TestParseSOCKS5NotSOCKS5(t *testing.T) { + for _, in := range []string{"", "127.0.0.1:8080", "http://127.0.0.1:8080"} { + addr, auth, err := parseSOCKS5(in) + if err != nil { + t.Errorf("parseSOCKS5(%q): unexpected error: %v", in, err) + } + if addr != "" || auth != nil { + t.Errorf("parseSOCKS5(%q) = %q, %+v, want empty/nil (not a socks5 upstream)", in, addr, auth) + } + } +} + +func TestParseSOCKS5NoAuth(t *testing.T) { + addr, auth, err := parseSOCKS5("socks5://127.0.0.1:1080") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if addr != "127.0.0.1:1080" { + t.Errorf("addr = %q, want %q", addr, "127.0.0.1:1080") + } + if auth != nil { + t.Errorf("auth = %+v, want nil (no credentials in the URL)", auth) + } +} + +func TestParseSOCKS5WithAuth(t *testing.T) { + addr, auth, err := parseSOCKS5("socks5://alice:s3cret@127.0.0.1:1080") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if addr != "127.0.0.1:1080" { + t.Errorf("addr = %q, want %q", addr, "127.0.0.1:1080") + } + if auth == nil || auth.User != "alice" || auth.Password != "s3cret" { + t.Errorf("auth = %+v, want User=alice Password=s3cret", auth) + } +} + +func TestParseSOCKS5InvalidURL(t *testing.T) { + // A raw control character is enough to make url.Parse fail. + if _, _, err := parseSOCKS5("socks5://\x7f"); err == nil { + t.Error("expected an error for a malformed socks5 URL") + } +} + +func TestDialSOCKS5NoAuth(t *testing.T) { + echoAddr := startEchoServer(t) + proxyAddr := startStubSOCKS5Proxy(t, false, "", "") + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + conn, err := dialSOCKS5(ctx, proxyAddr, nil, echoAddr) + if err != nil { + t.Fatalf("dialSOCKS5: %v", err) + } + defer conn.Close() + + if _, err := conn.Write([]byte("via s5")); err != nil { + t.Fatalf("write: %v", err) + } + buf := make([]byte, 6) + if _, err := io.ReadFull(conn, buf); err != nil { + t.Fatalf("read: %v", err) + } + if string(buf) != "via s5" { + t.Errorf("got %q, want %q", buf, "via s5") + } +} + +func TestDialSOCKS5WithAuth(t *testing.T) { + echoAddr := startEchoServer(t) + proxyAddr := startStubSOCKS5Proxy(t, true, "alice", "s3cret") + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + auth := &xproxy.Auth{User: "alice", Password: "s3cret"} + conn, err := dialSOCKS5(ctx, proxyAddr, auth, echoAddr) + if err != nil { + t.Fatalf("dialSOCKS5 with correct credentials: %v", err) + } + conn.Close() +} + +func TestDialSOCKS5WrongAuthRejected(t *testing.T) { + proxyAddr := startStubSOCKS5Proxy(t, true, "alice", "s3cret") + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + auth := &xproxy.Auth{User: "alice", Password: "wrong"} + if _, err := dialSOCKS5(ctx, proxyAddr, auth, "example.invalid:443"); err == nil { + t.Fatal("expected an error for wrong SOCKS5 credentials, got nil") + } +} + +func TestDialViaProxySOCKS5(t *testing.T) { + echoAddr := startEchoServer(t) + proxyAddr := startStubSOCKS5Proxy(t, false, "", "") + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + conn, err := dialViaProxy(ctx, echoAddr, "socks5://"+proxyAddr) + if err != nil { + t.Fatalf("dialViaProxy via socks5: %v", err) + } + defer conn.Close() + + if _, err := conn.Write([]byte("hi")); err != nil { + t.Fatalf("write: %v", err) + } + buf := make([]byte, 2) + if _, err := io.ReadFull(conn, buf); err != nil { + t.Fatalf("read: %v", err) + } + if string(buf) != "hi" { + t.Errorf("got %q, want %q", buf, "hi") + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index bc22bcf..a98a35a 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -37,6 +37,7 @@ import ( "time" "golang.org/x/net/http2" + xproxy "golang.org/x/net/proxy" "mitmux/internal/ca" "mitmux/internal/clientcert" @@ -90,11 +91,13 @@ var hopByHopHeaders = []string{ type Server struct { Addrs []string - // UpstreamProxy, if set (host:port, no scheme), chains every - // outbound connection through another HTTP CONNECT proxy instead of - // dialing origins directly - e.g. routing mitmux's own traffic - // through Burp, a corporate proxy, or a network-access proxy. - // SOCKS5 upstreams aren't implemented (see PLAN.md). + // UpstreamProxy, if set, chains every outbound connection through + // another proxy instead of dialing origins directly - e.g. routing + // mitmux's own traffic through Burp, a corporate proxy, a network- + // access proxy, or Tor. Bare "host:port" (or an "http://" prefix, + // stripped before it gets here) means an HTTP CONNECT proxy; a + // "socks5://[user:pass@]host:port" prefix means SOCKS5 - see + // parseSOCKS5. UpstreamProxy string // OnEntry, if set, is called after each request/response pair is @@ -315,6 +318,15 @@ func dialUpstreamPlain(ctx context.Context, host, upstreamProxy string) (net.Con if _, _, err := net.SplitHostPort(host); err != nil { host = net.JoinHostPort(host, "80") } + // SOCKS5 tunnels straight to host, same as dialViaProxy's TLS path - + // see dialSOCKS5's doc comment for why that needs no absolute-form + // adjustment the way chaining through an HTTP proxy does below. + if addr, auth, err := parseSOCKS5(upstreamProxy); err != nil { + return nil, "", err + } else if addr != "" { + conn, err := dialSOCKS5(ctx, addr, auth, host) + return conn, "http/1.1", err + } target := host if upstreamProxy != "" { target = upstreamProxy @@ -324,10 +336,65 @@ func dialUpstreamPlain(ctx context.Context, host, upstreamProxy string) (net.Con return conn, "http/1.1", err } +// parseSOCKS5 returns the proxy's bare "host:port" and optional +// credentials if upstreamProxy has a "socks5://" prefix - the marker +// this tool uses to distinguish a SOCKS5 upstream from the default HTTP +// CONNECT proxy chaining every other non-empty value means. addr == "" +// means upstreamProxy isn't a SOCKS5 proxy, which includes the "no +// upstream proxy configured at all" empty-string case - callers branch +// on that the same way they'd branch on upstreamProxy == "". +func parseSOCKS5(upstreamProxy string) (addr string, auth *xproxy.Auth, err error) { + if !strings.HasPrefix(upstreamProxy, "socks5://") { + return "", nil, nil + } + u, err := url.Parse(upstreamProxy) + if err != nil { + return "", nil, fmt.Errorf("invalid socks5 upstream proxy %q: %w", upstreamProxy, err) + } + if u.User != nil { + pass, _ := u.User.Password() + auth = &xproxy.Auth{User: u.User.Username(), Password: pass} + } + return u.Host, auth, nil +} + +// dialSOCKS5 tunnels to target through the SOCKS5 proxy at proxyAddr. +// Unlike an HTTP CONNECT proxy, SOCKS5 is transport-level and protocol- +// agnostic: the resulting connection behaves exactly like one dialed +// directly to target, with no "absolute-form request" adjustment needed +// on top (see forward's proxyForm). +func dialSOCKS5(ctx context.Context, proxyAddr string, auth *xproxy.Auth, target string) (net.Conn, error) { + d, err := xproxy.SOCKS5("tcp", proxyAddr, auth, xproxy.Direct) + if err != nil { + return nil, fmt.Errorf("configure SOCKS5 proxy %s: %w", proxyAddr, err) + } + // xproxy.Direct (the forward dialer passed above) always yields a + // ContextDialer-capable SOCKS5 client, per the library's own + // implementation - this fallback exists so a future forward-dialer + // change can't silently drop context cancellation rather than fail + // to compile against a changed interface. + cd, ok := d.(xproxy.ContextDialer) + if !ok { + return d.Dial("tcp", target) + } + conn, err := cd.DialContext(ctx, "tcp", target) + if err != nil { + return nil, fmt.Errorf("dial %s via SOCKS5 proxy %s: %w", target, proxyAddr, err) + } + return conn, nil +} + // dialViaProxy returns a raw TCP connection ready to speak TLS to -// hostPort - dialed directly if upstreamProxy is empty, or tunneled -// through upstreamProxy via an HTTP CONNECT request otherwise. +// hostPort - dialed directly if upstreamProxy is empty, tunneled through +// a SOCKS5 proxy if upstreamProxy has a "socks5://" prefix, or tunneled +// through an HTTP CONNECT proxy otherwise. func dialViaProxy(ctx context.Context, hostPort, upstreamProxy string) (net.Conn, error) { + if addr, auth, err := parseSOCKS5(upstreamProxy); err != nil { + return nil, err + } else if addr != "" { + return dialSOCKS5(ctx, addr, auth, hostPort) + } + nd := &net.Dialer{Timeout: 10 * time.Second} if upstreamProxy == "" { return nd.DialContext(ctx, "tcp", hostPort) @@ -504,10 +571,13 @@ func (s *Server) forward(dial dialer, scheme, hostname string, w http.ResponseWr if negotiated == http2.NextProtoTLS { resp, err = roundTripH2(conn, outReq) } else { - // Only the plain-HTTP path needs absolute-form: a CONNECT tunnel - // (chained through an upstream proxy or not) is transparent from - // here on, so it always uses origin-form like a direct connection. - proxyForm := scheme == "http" && s.UpstreamProxy != "" + // Only the plain-HTTP path needs absolute-form, and only when + // chained through an HTTP proxy specifically - a CONNECT tunnel + // (chained or not) is transparent from here on, so it always uses + // origin-form like a direct connection, and so does a SOCKS5 + // upstream: SOCKS5 tunnels straight to the origin, invisible to + // the HTTP layer, same as dialSOCKS5's doc comment explains. + proxyForm := scheme == "http" && s.UpstreamProxy != "" && !strings.HasPrefix(s.UpstreamProxy, "socks5://") resp, upstreamTee, err = roundTripH1(conn, outReq, proxyForm) } duration := time.Since(started) -- cgit v1.2.3