srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/internal/proxy/dialer_test.go
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-06-24 14:40:00 +0200
committersrdusr <[email protected]>2026-06-24 14:40:00 +0200
commite01afcbf0de00af52fe90959ba77288679e3303d (patch)
tree073ff1430af629e28556a6f651ee47f6989376da /internal/proxy/dialer_test.go
parent23c8ab359c2108654d57176e233d2c099b398f31 (diff)
downloadmitmux-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.go248
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")
+ }
+}