srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--PLAN.md18
-rw-r--r--README.md6
-rw-r--r--cmd/mitmuxd/main.go2
-rw-r--r--internal/proxy/dialer_test.go248
-rw-r--r--internal/proxy/proxy.go92
5 files changed, 349 insertions, 17 deletions
diff --git a/PLAN.md b/PLAN.md
index 16a8599..073c52e 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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.
diff --git a/README.md b/README.md
index b1237f6..16a1880 100644
--- a/README.md
+++ b/README.md
@@ -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)