srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/internal
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
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')
-rw-r--r--internal/proxy/dialer_test.go248
-rw-r--r--internal/proxy/proxy.go92
2 files changed, 329 insertions, 11 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")
+ }
+}
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)