diff options
| -rw-r--r-- | PLAN.md | 18 | ||||
| -rw-r--r-- | README.md | 6 | ||||
| -rw-r--r-- | cmd/mitmuxd/main.go | 2 | ||||
| -rw-r--r-- | internal/proxy/dialer_test.go | 248 | ||||
| -rw-r--r-- | internal/proxy/proxy.go | 92 |
5 files changed, 349 insertions, 17 deletions
@@ -190,7 +190,17 @@ Upstream proxy chaining: `-upstream-proxy host:port` (optional `http://` prefix, stripped) routes every outbound connection through another HTTP CONNECT proxy instead of dialing origins directly - chaining mitmux into Burp, a corporate proxy, or any other -CONNECT-speaking proxy. SOCKS5 upstreams aren't implemented. For the +CONNECT-speaking proxy. A `socks5://[user:pass@]host:port` prefix +routes through a SOCKS5 proxy instead (Tor, `ssh -D`, any other SOCKS5 +relay), via golang.org/x/net/proxy - already an indirect dependency +through http2, so no new module. 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-proxy +behavior otherwise. SOCKS5 is transport-level and protocol-agnostic - +the tunnel it hands back behaves exactly like a direct connection to +the target, so unlike HTTP-proxy chaining it needs no absolute-form +request adjustment on the plain-HTTP path either. For the CONNECT/HTTPS path, chaining is transparent below the tunnel: once the CONNECT handshake to the upstream proxy succeeds, TLS and the request/response on top of it are identical to a direct connection, so @@ -210,7 +220,11 @@ genuine passthrough CONNECT proxy (tunnels raw bytes, no MITM) works correctly for both plain HTTP and HTTPS; chaining through a second mitmuxd instance correctly fails with a clear "certificate signed by unknown authority" error recorded in history, -rather than hanging or crashing. +rather than hanging or crashing. SOCKS5 chaining verified the same +way, live, against a real standalone SOCKS5 relay process (not an +in-test mock): both a plain HTTP request and an HTTPS request through +mitmux were confirmed - via the relay's own log, not just mitmux's +success response - to have actually traversed it end to end. This closes every item from the original "worth considering" list. @@ -204,7 +204,9 @@ another *intercepting* proxy needs that proxy's own CA trusted too - it terminates and re-signs the connection with its own CA, which mitmux's outbound TLS client has no reason to trust otherwise; you'll see a clear certificate-verification error in history rather than a -silent failure. SOCKS5 upstreams aren't implemented. +silent failure. `-upstream-proxy socks5://[user:pass@]host:port` +chains through a SOCKS5 proxy instead - Tor, `ssh -D`, or any other +SOCKS5 relay - with optional username/password auth. ## Usage @@ -557,8 +559,6 @@ reasoning behind each: - `mitmuxd -install-ca` prints per-OS trust-store install steps; it never runs them for you (see Quick start above for why) - No WebSocket interception -- Upstream proxy chaining (`-upstream-proxy`) is HTTP CONNECT only, no - SOCKS5 - No active or passive vulnerability scanning, no plugin system - this is a manual-testing tool, not a scanner diff --git a/cmd/mitmuxd/main.go b/cmd/mitmuxd/main.go index 0114871..5fc937a 100644 --- a/cmd/mitmuxd/main.go +++ b/cmd/mitmuxd/main.go @@ -31,7 +31,7 @@ func main() { dbPath := flag.String("db", "", "path to history database (default: <ca-dir>/history.db)") socketPath := flag.String("socket", "", "control socket path (default: $XDG_RUNTIME_DIR/mitmux.sock, else <ca-dir>/mitmux.sock)") installCA := flag.Bool("install-ca", false, "generate the CA if needed, print OS-specific trust-store install steps, and exit (doesn't start the proxy)") - upstreamProxy := flag.String("upstream-proxy", "", "chain all outbound connections through this HTTP CONNECT proxy (host:port, optional http:// prefix) instead of dialing origins directly") + upstreamProxy := flag.String("upstream-proxy", "", "chain all outbound connections through this proxy instead of dialing origins directly - an HTTP CONNECT proxy (host:port, optional http:// prefix) or a SOCKS5 proxy (socks5://[user:pass@]host:port)") showVersion := flag.Bool("version", false, "print version and exit") flag.Parse() 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) |