diff options
| author | srdusr <[email protected]> | 2026-06-24 14:40:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-06-24 14:40:00 +0200 |
| commit | e01afcbf0de00af52fe90959ba77288679e3303d (patch) | |
| tree | 073ff1430af629e28556a6f651ee47f6989376da /internal/proxy/dialer_test.go | |
| parent | 23c8ab359c2108654d57176e233d2c099b398f31 (diff) | |
| download | mitmux-e01afcbf0de00af52fe90959ba77288679e3303d.tar.gz mitmux-e01afcbf0de00af52fe90959ba77288679e3303d.zip | |
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.
Diffstat (limited to 'internal/proxy/dialer_test.go')
| -rw-r--r-- | internal/proxy/dialer_test.go | 248 |
1 files changed, 248 insertions, 0 deletions
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:[email protected]: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") + } +} |