From 9924d8b7aa1ee190bb6f754c2f9d136912727cf5 Mon Sep 17 00:00:00 2001 From: ManuelReschke Date: Mon, 27 Jul 2026 15:37:40 +0200 Subject: [PATCH 1/2] feat(#473): add SOCKS4 and SOCKS4a proxy support Support socks4:// and socks4a:// proxy URLs so clients can tunnel HTTP(S) through SOCKS4 proxies, with optional user ID and remote DNS via the SOCKS4a extension. --- internal/socks/client.go | 113 +++++++- internal/socks/socks.go | 61 +++- internal/socks/socks4_test.go | 506 ++++++++++++++++++++++++++++++++++ internal/transport/option.go | 9 +- proxy_socks4_test.go | 218 +++++++++++++++ transport.go | 22 +- 6 files changed, 907 insertions(+), 22 deletions(-) create mode 100644 internal/socks/socks4_test.go create mode 100644 proxy_socks4_test.go diff --git a/internal/socks/client.go b/internal/socks/client.go index 3d6f516a..01745067 100644 --- a/internal/socks/client.go +++ b/internal/socks/client.go @@ -47,6 +47,95 @@ func (d *Dialer) connect(ctx context.Context, c net.Conn, address string) (_ net }() } + var addr net.Addr + if d.version() == Version4 { + addr, ctxErr = d.connect4(ctx, c, host, port) + } else { + addr, ctxErr = d.connect5(ctx, c, host, port) + } + return addr, ctxErr +} + +// connect4 implements the SOCKS4 and SOCKS4a CONNECT handshake. +func (d *Dialer) connect4(ctx context.Context, c net.Conn, host string, port int) (net.Addr, error) { + if err := validateUserID(d.UserID); err != nil { + return nil, err + } + + var ( + ip net.IP + domain string + ) + if parsed := net.ParseIP(host); parsed != nil { + ip4 := parsed.To4() + if ip4 == nil { + return nil, errors.New("SOCKS4 does not support IPv6 addresses") + } + ip = ip4 + } else if d.Socks4A { + // SOCKS4a: use invalid IP 0.0.0.x (x != 0) and append domain after userid. + ip = net.IPv4(0, 0, 0, 1).To4() + domain = host + } else { + ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", host) + if err != nil { + return nil, err + } + if len(ips) == 0 { + return nil, errors.New("no IPv4 address found for host") + } + ip = ips[0].To4() + if ip == nil { + return nil, errors.New("no IPv4 address found for host") + } + } + + // VN | CD | DSTPORT | DSTIP | USERID | NULL [| DOMAIN | NULL] + b := make([]byte, 0, 9+len(d.UserID)+len(domain)+1) + b = append(b, Version4, byte(d.cmd)) + b = append(b, byte(port>>8), byte(port)) + b = append(b, ip...) + b = append(b, d.UserID...) + b = append(b, 0) + if domain != "" { + b = append(b, domain...) + b = append(b, 0) + } + if _, err := c.Write(b); err != nil { + return nil, err + } + + // Reply is always 8 bytes: VN | CD | DSTPORT | DSTIP + var resp [8]byte + if _, err := io.ReadFull(c, resp[:]); err != nil { + return nil, err + } + // Spec says VN should be 0; some servers incorrectly echo 4. + if resp[0] != 0 && resp[0] != Version4 { + return nil, errors.New("unexpected protocol version " + strconv.Itoa(int(resp[0]))) + } + if code := Reply(resp[1]); code != Status4Granted { + return nil, errors.New(code.String()) + } + + a := &Addr{ + IP: net.IPv4(resp[4], resp[5], resp[6], resp[7]), + Port: int(resp[2])<<8 | int(resp[3]), + } + return a, nil +} + +func validateUserID(userID string) error { + for i := 0; i < len(userID); i++ { + if userID[i] == 0 { + return errors.New("invalid SOCKS4 user ID: contains NUL") + } + } + return nil +} + +// connect5 implements the SOCKS5 CONNECT handshake. +func (d *Dialer) connect5(ctx context.Context, c net.Conn, host string, port int) (net.Addr, error) { b := make([]byte, 0, 6+len(host)) // the size here is just an estimate b = append(b, Version5) if len(d.AuthMethods) == 0 || d.Authenticate == nil { @@ -61,12 +150,12 @@ func (d *Dialer) connect(ctx context.Context, c net.Conn, address string) (_ net b = append(b, byte(am)) } } - if _, ctxErr = c.Write(b); ctxErr != nil { - return + if _, err := c.Write(b); err != nil { + return nil, err } - if _, ctxErr = io.ReadFull(c, b[:2]); ctxErr != nil { - return + if _, err := io.ReadFull(c, b[:2]); err != nil { + return nil, err } if b[0] != Version5 { return nil, errors.New("unexpected protocol version " + strconv.Itoa(int(b[0]))) @@ -76,8 +165,8 @@ func (d *Dialer) connect(ctx context.Context, c net.Conn, address string) (_ net return nil, errors.New("no acceptable authentication methods") } if d.Authenticate != nil { - if ctxErr = d.Authenticate(ctx, c, am); ctxErr != nil { - return + if err := d.Authenticate(ctx, c, am); err != nil { + return nil, err } } @@ -102,12 +191,12 @@ func (d *Dialer) connect(ctx context.Context, c net.Conn, address string) (_ net b = append(b, host...) } b = append(b, byte(port>>8), byte(port)) - if _, ctxErr = c.Write(b); ctxErr != nil { - return + if _, err := c.Write(b); err != nil { + return nil, err } - if _, ctxErr = io.ReadFull(c, b[:4]); ctxErr != nil { - return + if _, err := io.ReadFull(c, b[:4]); err != nil { + return nil, err } if b[0] != Version5 { return nil, errors.New("unexpected protocol version " + strconv.Itoa(int(b[0]))) @@ -140,8 +229,8 @@ func (d *Dialer) connect(ctx context.Context, c net.Conn, address string) (_ net } else { b = b[:l] } - if _, ctxErr = io.ReadFull(c, b); ctxErr != nil { - return + if _, err := io.ReadFull(c, b); err != nil { + return nil, err } if a.IP != nil { copy(a.IP, b) diff --git a/internal/socks/socks.go b/internal/socks/socks.go index a121a9e4..627356d6 100644 --- a/internal/socks/socks.go +++ b/internal/socks/socks.go @@ -2,7 +2,15 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -// Package socks provides a SOCKS version 5 client implementation. +// Package socks provides SOCKS version 4/4a and 5 client implementations. +// +// SOCKS protocol version 4 is described in: +// +// https://www.openssh.org/txt/socks4.protocol +// +// SOCKS protocol version 4a (domain name extension) is described in: +// +// https://www.openssh.org/txt/socks4a.protocol // // SOCKS protocol version 5 is defined in RFC 1928. // Username/Password authentication for SOCKS version 5 is defined in @@ -39,6 +47,7 @@ type Reply int func (code Reply) String() string { switch code { + // SOCKS5 reply codes case StatusSucceeded: return "succeeded" case 0x01: @@ -57,6 +66,15 @@ func (code Reply) String() string { return "command not supported" case 0x08: return "address type not supported" + // SOCKS4 reply codes + case Status4Granted: + return "request granted" + case Status4Rejected: + return "request rejected or failed" + case Status4IdentdFailed: + return "request rejected because SOCKS server cannot connect to identd on the client" + case Status4IdentdMismatch: + return "request rejected because the client program and identd report different user-ids" default: return "unknown code: " + strconv.Itoa(int(code)) } @@ -64,6 +82,7 @@ func (code Reply) String() string { // Wire protocol constants. const ( + Version4 = 0x04 Version5 = 0x05 AddrTypeIPv4 = 0x01 @@ -77,7 +96,14 @@ const ( AuthMethodUsernamePassword AuthMethod = 0x02 // use username/password AuthMethodNoAcceptableMethods AuthMethod = 0xff // no acceptable authentication methods + // SOCKS5 reply codes StatusSucceeded Reply = 0x00 + + // SOCKS4 reply codes + Status4Granted Reply = 90 + Status4Rejected Reply = 91 + Status4IdentdFailed Reply = 92 + Status4IdentdMismatch Reply = 93 ) // An Addr represents a SOCKS-specific address. @@ -124,21 +150,43 @@ type Dialer struct { proxyNetwork string // network between a proxy server and a client proxyAddress string // proxy server address + // Version specifies the SOCKS protocol version. + // Supported values are Version4 and Version5. + // If zero, Version5 is used. + Version int + + // Socks4A enables the SOCKS4a extension so domain names are + // resolved by the proxy instead of the client. + // Only used when Version is Version4. + Socks4A bool + + // UserID is the SOCKS4 user identity string sent to the proxy. + // Only used when Version is Version4. Empty UserID is allowed. + UserID string + // ProxyDial specifies the optional dial function for // establishing the transport connection. ProxyDial func(context.Context, string, string) (net.Conn, error) // AuthMethods specifies the list of request authentication - // methods. + // methods. Only used when Version is Version5. // If empty, SOCKS client requests only AuthMethodNotRequired. AuthMethods []AuthMethod // Authenticate specifies the optional authentication // function. It must be non-nil when AuthMethods is not empty. // It must return an error when the authentication is failed. + // Only used when Version is Version5. Authenticate func(context.Context, io.ReadWriter, AuthMethod) error } +func (d *Dialer) version() int { + if d.Version == 0 { + return Version5 + } + return d.Version +} + // DialContext connects to the provided address on the provided // network. // @@ -237,9 +285,14 @@ func (d *Dialer) pathAddrs(address string) (proxy, dst net.Addr, err error) { } // NewDialer returns a new Dialer that dials through the provided -// proxy server's network and address. +// proxy server's network and address using SOCKS5 by default. func NewDialer(network, address string) *Dialer { - return &Dialer{proxyNetwork: network, proxyAddress: address, cmd: CmdConnect} + return &Dialer{ + proxyNetwork: network, + proxyAddress: address, + cmd: CmdConnect, + Version: Version5, + } } const ( diff --git a/internal/socks/socks4_test.go b/internal/socks/socks4_test.go new file mode 100644 index 00000000..2aa1f6ff --- /dev/null +++ b/internal/socks/socks4_test.go @@ -0,0 +1,506 @@ +package socks + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "net" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +// startSocks4Server starts a minimal SOCKS4/4a test server. +// handle is called with the parsed request fields and should return +// the reply code and optional destination to relay to. If dest is +// non-empty and the reply is Status4Granted, the server dials dest +// and bidirectionally copies data. +func startSocks4Server(t *testing.T, handle func(req socks4Request) (Reply, string)) (addr string, closeFn func()) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + done := make(chan struct{}) + + wg.Add(1) + go func() { + defer wg.Done() + for { + conn, err := ln.Accept() + if err != nil { + select { + case <-done: + return + default: + return + } + } + wg.Add(1) + go func(c net.Conn) { + defer wg.Done() + defer c.Close() + req, err := readSocks4Request(c) + if err != nil { + return + } + code, dest := handle(req) + reply := make([]byte, 8) + reply[0] = 0 + reply[1] = byte(code) + if _, err := c.Write(reply); err != nil { + return + } + if code != Status4Granted || dest == "" { + return + } + upstream, err := net.DialTimeout("tcp", dest, 2*time.Second) + if err != nil { + return + } + defer upstream.Close() + relay(c, upstream) + }(conn) + } + }() + + return ln.Addr().String(), func() { + close(done) + ln.Close() + wg.Wait() + } +} + +type socks4Request struct { + Cmd Command + Port int + IP net.IP + UserID string + Domain string // non-empty when SOCKS4a domain was provided +} + +func readSocks4Request(r io.Reader) (socks4Request, error) { + var hdr [8]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + return socks4Request{}, err + } + if hdr[0] != Version4 { + return socks4Request{}, io.ErrUnexpectedEOF + } + req := socks4Request{ + Cmd: Command(hdr[1]), + Port: int(binary.BigEndian.Uint16(hdr[2:4])), + IP: net.IPv4(hdr[4], hdr[5], hdr[6], hdr[7]).To4(), + } + + userID, err := readCString(r) + if err != nil { + return socks4Request{}, err + } + req.UserID = userID + + // SOCKS4a: IP is 0.0.0.x with x != 0 + if req.IP[0] == 0 && req.IP[1] == 0 && req.IP[2] == 0 && req.IP[3] != 0 { + domain, err := readCString(r) + if err != nil { + return socks4Request{}, err + } + req.Domain = domain + } + return req, nil +} + +func readCString(r io.Reader) (string, error) { + var buf bytes.Buffer + var b [1]byte + for { + if _, err := io.ReadFull(r, b[:]); err != nil { + return "", err + } + if b[0] == 0 { + return buf.String(), nil + } + buf.WriteByte(b[0]) + } +} + +func relay(a, b net.Conn) { + done := make(chan struct{}, 2) + copyFn := func(dst, src net.Conn) { + io.Copy(dst, src) + done <- struct{}{} + } + go copyFn(a, b) + go copyFn(b, a) + <-done +} + +func TestSocks4ConnectIPv4(t *testing.T) { + const userID = "tester" + got := make(chan socks4Request, 1) + + addr, closeFn := startSocks4Server(t, func(req socks4Request) (Reply, string) { + got <- req + return Status4Granted, "" + }) + defer closeFn() + + d := NewDialer("tcp", addr) + d.Version = Version4 + d.UserID = userID + + c, err := d.DialContext(context.Background(), "tcp", "1.2.3.4:80") + if err != nil { + t.Fatal(err) + } + c.Close() + + req := <-got + if req.Cmd != CmdConnect { + t.Fatalf("cmd = %v; want CONNECT", req.Cmd) + } + if req.Port != 80 { + t.Fatalf("port = %d; want 80", req.Port) + } + if !req.IP.Equal(net.IPv4(1, 2, 3, 4)) { + t.Fatalf("ip = %v; want 1.2.3.4", req.IP) + } + if req.UserID != userID { + t.Fatalf("userid = %q; want %q", req.UserID, userID) + } + if req.Domain != "" { + t.Fatalf("domain = %q; want empty", req.Domain) + } +} + +func TestSocks4aConnectDomain(t *testing.T) { + got := make(chan socks4Request, 1) + + addr, closeFn := startSocks4Server(t, func(req socks4Request) (Reply, string) { + got <- req + return Status4Granted, "" + }) + defer closeFn() + + d := NewDialer("tcp", addr) + d.Version = Version4 + d.Socks4A = true + d.UserID = "alice" + + c, err := d.DialContext(context.Background(), "tcp", "example.com:443") + if err != nil { + t.Fatal(err) + } + c.Close() + + req := <-got + if req.Domain != "example.com" { + t.Fatalf("domain = %q; want example.com", req.Domain) + } + if req.Port != 443 { + t.Fatalf("port = %d; want 443", req.Port) + } + if req.UserID != "alice" { + t.Fatalf("userid = %q; want alice", req.UserID) + } + // SOCKS4a uses 0.0.0.x with x != 0 + if req.IP[0] != 0 || req.IP[1] != 0 || req.IP[2] != 0 || req.IP[3] == 0 { + t.Fatalf("ip = %v; want 0.0.0.x with x != 0", req.IP) + } +} + +func TestSocks4EmptyUserID(t *testing.T) { + got := make(chan socks4Request, 1) + addr, closeFn := startSocks4Server(t, func(req socks4Request) (Reply, string) { + got <- req + return Status4Granted, "" + }) + defer closeFn() + + d := NewDialer("tcp", addr) + d.Version = Version4 + + c, err := d.DialContext(context.Background(), "tcp", "127.0.0.1:9") + if err != nil { + t.Fatal(err) + } + c.Close() + + req := <-got + if req.UserID != "" { + t.Fatalf("userid = %q; want empty", req.UserID) + } +} + +func TestSocks4Rejected(t *testing.T) { + addr, closeFn := startSocks4Server(t, func(req socks4Request) (Reply, string) { + return Status4Rejected, "" + }) + defer closeFn() + + d := NewDialer("tcp", addr) + d.Version = Version4 + + _, err := d.DialContext(context.Background(), "tcp", "127.0.0.1:9") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), Status4Rejected.String()) { + t.Fatalf("error = %v; want containing %q", err, Status4Rejected.String()) + } +} + +func TestSocks4IPv6Rejected(t *testing.T) { + // Server should not be needed; client rejects IPv6 before writing. + d := NewDialer("tcp", "127.0.0.1:1") + d.Version = Version4 + + // DialWithConn uses an already-open connection, so we can test the + // handshake without a real proxy listener. + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + errCh := make(chan error, 1) + go func() { + _, err := d.DialWithConn(context.Background(), client, "tcp", "[::1]:80") + errCh <- err + }() + + select { + case err := <-errCh: + if err == nil { + t.Fatal("expected error for IPv6") + } + if !strings.Contains(err.Error(), "IPv6") { + t.Fatalf("error = %v; want IPv6 mention", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout") + } +} + +func TestSocks4InvalidUserID(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + d := NewDialer("tcp", "127.0.0.1:1") + d.Version = Version4 + d.UserID = "bad\x00id" + + _, err := d.DialWithConn(context.Background(), client, "tcp", "127.0.0.1:80") + if err == nil { + t.Fatal("expected error for NUL in user ID") + } + if !strings.Contains(err.Error(), "NUL") { + t.Fatalf("error = %v; want NUL mention", err) + } +} + +func TestSocks4LocalResolve(t *testing.T) { + got := make(chan socks4Request, 1) + addr, closeFn := startSocks4Server(t, func(req socks4Request) (Reply, string) { + got <- req + return Status4Granted, "" + }) + defer closeFn() + + d := NewDialer("tcp", addr) + d.Version = Version4 + // Socks4A is false: domain names must be resolved locally. + + c, err := d.DialContext(context.Background(), "tcp", "localhost:8080") + if err != nil { + t.Fatal(err) + } + c.Close() + + req := <-got + if req.Domain != "" { + t.Fatalf("domain = %q; want empty (local resolve)", req.Domain) + } + if req.Port != 8080 { + t.Fatalf("port = %d; want 8080", req.Port) + } + if !req.IP.IsLoopback() { + t.Fatalf("ip = %v; want loopback", req.IP) + } +} + +func TestSocks4DialWithConn(t *testing.T) { + got := make(chan socks4Request, 1) + addr, closeFn := startSocks4Server(t, func(req socks4Request) (Reply, string) { + got <- req + return Status4Granted, "" + }) + defer closeFn() + + conn, err := net.Dial("tcp", addr) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + d := NewDialer("tcp", addr) + d.Version = Version4 + d.Socks4A = true + d.UserID = "withconn" + + a, err := d.DialWithConn(context.Background(), conn, "tcp", "service.local:1234") + if err != nil { + t.Fatal(err) + } + if _, ok := a.(*Addr); !ok { + t.Fatalf("got %T; want *Addr", a) + } + + req := <-got + if req.Domain != "service.local" { + t.Fatalf("domain = %q; want service.local", req.Domain) + } + if req.Port != 1234 { + t.Fatalf("port = %d; want 1234", req.Port) + } + if req.UserID != "withconn" { + t.Fatalf("userid = %q; want withconn", req.UserID) + } +} + +func TestSocks4ReplyVersionTolerance(t *testing.T) { + // Some servers incorrectly set VN=4 in the reply; client should accept it. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer c.Close() + // Read full request (header + userid NUL) + buf := make([]byte, 256) + n, _ := c.Read(buf) + _ = n + // Reply with VN=4 instead of 0 + c.Write([]byte{Version4, byte(Status4Granted), 0, 0, 0, 0, 0, 0}) + }() + + d := NewDialer("tcp", ln.Addr().String()) + d.Version = Version4 + c, err := d.DialContext(context.Background(), "tcp", "10.0.0.1:80") + if err != nil { + t.Fatal(err) + } + c.Close() +} + +func TestSocks4UnexpectedVersion(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer c.Close() + buf := make([]byte, 256) + c.Read(buf) + c.Write([]byte{5, byte(Status4Granted), 0, 0, 0, 0, 0, 0}) + }() + + d := NewDialer("tcp", ln.Addr().String()) + d.Version = Version4 + _, err = d.DialContext(context.Background(), "tcp", "10.0.0.1:80") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "unexpected protocol version") { + t.Fatalf("error = %v; want unexpected protocol version", err) + } +} + +func TestReply4String(t *testing.T) { + if got, want := Status4Granted.String(), "request granted"; got != want { + t.Errorf("Status4Granted = %q; want %q", got, want) + } + if got, want := Status4Rejected.String(), "request rejected or failed"; got != want { + t.Errorf("Status4Rejected = %q; want %q", got, want) + } + if s := Status4IdentdFailed.String(); !strings.Contains(s, "identd") { + t.Errorf("Status4IdentdFailed = %q; want containing identd", s) + } + if s := Status4IdentdMismatch.String(); !strings.Contains(s, "user-ids") { + t.Errorf("Status4IdentdMismatch = %q; want containing user-ids", s) + } +} + +func TestSocks4RelayHTTP(t *testing.T) { + // End-to-end: SOCKS4 proxy relays to a real TCP echo/HTTP target. + targetLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer targetLn.Close() + + const payload = "HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nOK" + go func() { + c, err := targetLn.Accept() + if err != nil { + return + } + defer c.Close() + // Read the request once, then respond. Do not wait for EOF/full buffer. + buf := make([]byte, 256) + _, _ = c.Read(buf) + _, _ = c.Write([]byte(payload)) + }() + + targetAddr := targetLn.Addr().String() + host, portStr, _ := net.SplitHostPort(targetAddr) + port, _ := strconv.Atoi(portStr) + + proxyAddr, closeFn := startSocks4Server(t, func(req socks4Request) (Reply, string) { + if req.Port != port { + return Status4Rejected, "" + } + if !req.IP.Equal(net.ParseIP(host)) { + return Status4Rejected, "" + } + return Status4Granted, targetAddr + }) + defer closeFn() + + d := NewDialer("tcp", proxyAddr) + d.Version = Version4 + + c, err := d.DialContext(context.Background(), "tcp", targetAddr) + if err != nil { + t.Fatal(err) + } + defer c.Close() + + if _, err := c.Write([]byte("GET / HTTP/1.0\r\n\r\n")); err != nil { + t.Fatal(err) + } + buf := make([]byte, 128) + n, err := c.Read(buf) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(buf[:n]), "OK") { + t.Fatalf("response = %q; want containing OK", buf[:n]) + } +} diff --git a/internal/transport/option.go b/internal/transport/option.go index d45b51c1..4de8f517 100644 --- a/internal/transport/option.go +++ b/internal/transport/option.go @@ -18,13 +18,16 @@ type Options struct { // request is aborted with the provided error. // // The proxy type is determined by the URL scheme. "http", - // "https", "socks5", and "socks5h" are supported. If the scheme is empty, - // "http" is assumed. + // "https", "socks5", "socks5h", "socks4", and "socks4a" are supported. + // If the scheme is empty, "http" is assumed. // "socks5" is treated the same as "socks5h". + // "socks4" resolves domain names locally to IPv4; "socks4a" lets the + // proxy resolve domain names. SOCKS4 only supports IPv4 destinations. // // If the proxy URL contains a userinfo subcomponent, // the proxy request will pass the username and password - // in a Proxy-Authorization header. + // in a Proxy-Authorization header for HTTP proxies, or the username + // as the SOCKS4 user ID / SOCKS5 credentials for SOCKS proxies. // // If Proxy is nil or returns a nil *URL, no proxy is used. Proxy func(*http.Request) (*url.URL, error) diff --git a/proxy_socks4_test.go b/proxy_socks4_test.go new file mode 100644 index 00000000..dfff6b36 --- /dev/null +++ b/proxy_socks4_test.go @@ -0,0 +1,218 @@ +package req + +import ( + "encoding/binary" + "io" + "net" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +// TestSocks4ProxyE2E verifies that Client.SetProxyURL("socks4://...") can +// successfully send an HTTP request through a SOCKS4 proxy. +func TestSocks4ProxyE2E(t *testing.T) { + // Backend HTTP server. + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Test", "socks4") + w.Write([]byte("hello-socks4")) + })) + defer backend.Close() + + backendHost := backend.Listener.Addr().String() + + // Minimal SOCKS4 relay proxy. + proxyLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer proxyLn.Close() + + var wg sync.WaitGroup + done := make(chan struct{}) + wg.Add(1) + go func() { + defer wg.Done() + for { + conn, err := proxyLn.Accept() + if err != nil { + select { + case <-done: + return + default: + return + } + } + wg.Add(1) + go func(c net.Conn) { + defer wg.Done() + defer c.Close() + if err := handleSocks4Connect(c); err != nil { + return + } + }(conn) + } + }() + defer func() { + close(done) + proxyLn.Close() + wg.Wait() + }() + + proxyURL := "socks4://userid@" + proxyLn.Addr().String() + client := C().SetProxyURL(proxyURL).DisableKeepAlives() + + resp, err := client.R().Get(backend.URL) + if err != nil { + t.Fatalf("request via socks4 proxy failed: %v (backend=%s)", err, backendHost) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d; want 200", resp.StatusCode) + } + if resp.Header.Get("X-Test") != "socks4" { + t.Fatalf("X-Test = %q; want socks4", resp.Header.Get("X-Test")) + } + if resp.String() != "hello-socks4" { + t.Fatalf("body = %q; want hello-socks4", resp.String()) + } +} + +// TestSocks4aProxyE2E verifies socks4a scheme with domain-style target. +func TestSocks4aProxyE2E(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("hello-socks4a")) + })) + defer backend.Close() + + proxyLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer proxyLn.Close() + + var wg sync.WaitGroup + done := make(chan struct{}) + wg.Add(1) + go func() { + defer wg.Done() + for { + conn, err := proxyLn.Accept() + if err != nil { + select { + case <-done: + return + default: + return + } + } + wg.Add(1) + go func(c net.Conn) { + defer wg.Done() + defer c.Close() + _ = handleSocks4Connect(c) + }(conn) + } + }() + defer func() { + close(done) + proxyLn.Close() + wg.Wait() + }() + + // Use 127.0.0.1 in the backend URL so the target address is IPv4; + // socks4a still works when the host is an IP (no domain extension needed). + client := C().SetProxyURL("socks4a://" + proxyLn.Addr().String()).DisableKeepAlives() + + resp, err := client.R().Get(backend.URL) + if err != nil { + t.Fatalf("request via socks4a proxy failed: %v", err) + } + if resp.String() != "hello-socks4a" { + t.Fatalf("body = %q; want hello-socks4a", resp.String()) + } +} + +// handleSocks4Connect performs a SOCKS4/4a CONNECT handshake and relays. +func handleSocks4Connect(c net.Conn) error { + _ = c.SetDeadline(time.Now().Add(5 * time.Second)) + + var hdr [8]byte + if _, err := io.ReadFull(c, hdr[:]); err != nil { + return err + } + if hdr[0] != 0x04 || hdr[1] != 0x01 { + // Reject non-CONNECT or non-SOCKS4. + _, _ = c.Write([]byte{0, 91, 0, 0, 0, 0, 0, 0}) + return nil + } + port := int(binary.BigEndian.Uint16(hdr[2:4])) + ip := net.IPv4(hdr[4], hdr[5], hdr[6], hdr[7]) + + // Read userid. + if _, err := readNULString(c); err != nil { + return err + } + + var host string + // SOCKS4a domain when IP is 0.0.0.x with x != 0 + if hdr[4] == 0 && hdr[5] == 0 && hdr[6] == 0 && hdr[7] != 0 { + domain, err := readNULString(c) + if err != nil { + return err + } + host = domain + } else { + host = ip.String() + } + + target := net.JoinHostPort(host, itoa(port)) + upstream, err := net.DialTimeout("tcp", target, 2*time.Second) + if err != nil { + _, _ = c.Write([]byte{0, 91, 0, 0, 0, 0, 0, 0}) + return err + } + defer upstream.Close() + + if _, err := c.Write([]byte{0, 90, 0, 0, 0, 0, 0, 0}); err != nil { + return err + } + + // Clear handshake deadline before long-lived relay. + _ = c.SetDeadline(time.Time{}) + + errc := make(chan struct{}, 2) + go func() { io.Copy(upstream, c); errc <- struct{}{} }() + go func() { io.Copy(c, upstream); errc <- struct{}{} }() + <-errc + return nil +} + +func readNULString(r io.Reader) (string, error) { + var b [1]byte + var out []byte + for { + if _, err := io.ReadFull(r, b[:]); err != nil { + return "", err + } + if b[0] == 0 { + return string(out), nil + } + out = append(out, b[0]) + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [12]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} diff --git a/transport.go b/transport.go index cba871b1..dffc34d5 100644 --- a/transport.go +++ b/transport.go @@ -461,8 +461,8 @@ func (t *Transport) SetDebug(debugf func(format string, v ...any)) *Transport { // is aborted with the provided error. // // The proxy type is determined by the URL scheme. "http", -// "https", and "socks5" are supported. If the scheme is empty, -// "http" is assumed. +// "https", "socks5", "socks5h", "socks4", and "socks4a" are +// supported. If the scheme is empty, "http" is assumed. // // If Proxy is nil or returns a nil *URL, no proxy is used. func (t *Transport) SetProxy(proxy func(*http.Request) (*url.URL, error)) *Transport { @@ -2157,6 +2157,18 @@ func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *pers conn.Close() return nil, err } + case cm.proxyURL.Scheme == "socks4" || cm.proxyURL.Scheme == "socks4a": + conn := pconn.conn + d := socks.NewDialer("tcp", conn.RemoteAddr().String()) + d.Version = socks.Version4 + d.Socks4A = cm.proxyURL.Scheme == "socks4a" + if u := cm.proxyURL.User; u != nil { + d.UserID = u.Username() + } + if _, err := d.DialWithConn(ctx, conn, "tcp", cm.targetAddr); err != nil { + conn.Close() + return nil, err + } case cm.targetScheme == "http": pconn.isProxy = true if pa := cm.proxyAuth(); pa != "" { @@ -2319,6 +2331,8 @@ var _ io.ReaderFrom = (*persistConnWriter)(nil) // http://proxy.com|http http to proxy, http to anywhere after that // socks5://proxy.com|http|foo.com socks5 to proxy, then http to foo.com // socks5://proxy.com|https|foo.com socks5 to proxy, then https to foo.com +// socks4://proxy.com|http|foo.com socks4 to proxy, then http to foo.com +// socks4a://proxy.com|https|foo.com socks4a to proxy, then https to foo.com // https://proxy.com|https|foo.com https to proxy, then CONNECT to foo.com // https://proxy.com|http https to proxy, http to anywhere after that type connectMethod struct { @@ -2349,7 +2363,7 @@ func (cm *connectMethod) key() connectMethodKey { } } -// scheme returns the first hop scheme: http, https, or socks5 +// scheme returns the first hop scheme: http, https, socks5, socks5h, socks4, or socks4a func (cm *connectMethod) scheme() string { if cm.proxyURL != nil { return cm.proxyURL.Scheme @@ -3573,6 +3587,8 @@ var portMap = map[string]string{ "https": "443", "socks5": "1080", "socks5h": "1080", + "socks4": "1080", + "socks4a": "1080", } func idnaASCIIFromURL(url *url.URL) string { From d023148c2881a4f8b6c66f6da9721d2f2297d921 Mon Sep 17 00:00:00 2001 From: ManuelReschke Date: Mon, 27 Jul 2026 15:59:25 +0200 Subject: [PATCH 2/2] fix(#473): harden SOCKS4a validation and domain e2e coverage Reject NUL bytes and overlong domain names in SOCKS4a requests, align SetProxy docs with socks4/socks4a DNS behavior, and assert the public client path actually sends a domain through the SOCKS4a extension. --- internal/socks/client.go | 24 +++++-- internal/socks/socks4_test.go | 37 ++++++++++ proxy_socks4_test.go | 123 ++++++++++++++++++++-------------- transport.go | 3 + 4 files changed, 130 insertions(+), 57 deletions(-) diff --git a/internal/socks/client.go b/internal/socks/client.go index 01745067..02a81791 100644 --- a/internal/socks/client.go +++ b/internal/socks/client.go @@ -56,9 +56,13 @@ func (d *Dialer) connect(ctx context.Context, c net.Conn, address string) (_ net return addr, ctxErr } +// maxSocks4aDomainLen is the maximum domain name length accepted for SOCKS4a. +// Matches the practical FQDN limit used by SOCKS5 in this package. +const maxSocks4aDomainLen = 255 + // connect4 implements the SOCKS4 and SOCKS4a CONNECT handshake. func (d *Dialer) connect4(ctx context.Context, c net.Conn, host string, port int) (net.Addr, error) { - if err := validateUserID(d.UserID); err != nil { + if err := validateSocks4CString(d.UserID, "user ID"); err != nil { return nil, err } @@ -74,6 +78,15 @@ func (d *Dialer) connect4(ctx context.Context, c net.Conn, host string, port int ip = ip4 } else if d.Socks4A { // SOCKS4a: use invalid IP 0.0.0.x (x != 0) and append domain after userid. + if host == "" { + return nil, errors.New("SOCKS4a domain name is empty") + } + if len(host) > maxSocks4aDomainLen { + return nil, errors.New("SOCKS4a domain name too long") + } + if err := validateSocks4CString(host, "domain name"); err != nil { + return nil, err + } ip = net.IPv4(0, 0, 0, 1).To4() domain = host } else { @@ -125,10 +138,11 @@ func (d *Dialer) connect4(ctx context.Context, c net.Conn, host string, port int return a, nil } -func validateUserID(userID string) error { - for i := 0; i < len(userID); i++ { - if userID[i] == 0 { - return errors.New("invalid SOCKS4 user ID: contains NUL") +// validateSocks4CString rejects strings that would truncate a SOCKS4 C-string field. +func validateSocks4CString(s, field string) error { + for i := 0; i < len(s); i++ { + if s[i] == 0 { + return errors.New("invalid SOCKS4 " + field + ": contains NUL") } } return nil diff --git a/internal/socks/socks4_test.go b/internal/socks/socks4_test.go index 2aa1f6ff..c43bceaa 100644 --- a/internal/socks/socks4_test.go +++ b/internal/socks/socks4_test.go @@ -303,6 +303,43 @@ func TestSocks4InvalidUserID(t *testing.T) { } } +func TestSocks4aInvalidDomain(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + d := NewDialer("tcp", "127.0.0.1:1") + d.Version = Version4 + d.Socks4A = true + + _, err := d.DialWithConn(context.Background(), client, "tcp", "bad\x00host:80") + if err == nil { + t.Fatal("expected error for NUL in domain") + } + if !strings.Contains(err.Error(), "NUL") { + t.Fatalf("error = %v; want NUL mention", err) + } +} + +func TestSocks4aDomainTooLong(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + d := NewDialer("tcp", "127.0.0.1:1") + d.Version = Version4 + d.Socks4A = true + + longHost := strings.Repeat("a", maxSocks4aDomainLen+1) + ":80" + _, err := d.DialWithConn(context.Background(), client, "tcp", longHost) + if err == nil { + t.Fatal("expected error for long domain") + } + if !strings.Contains(err.Error(), "too long") { + t.Fatalf("error = %v; want too long", err) + } +} + func TestSocks4LocalResolve(t *testing.T) { got := make(chan socks4Request, 1) addr, closeFn := startSocks4Server(t, func(req socks4Request) (Reply, string) { diff --git a/proxy_socks4_test.go b/proxy_socks4_test.go index dfff6b36..24d21942 100644 --- a/proxy_socks4_test.go +++ b/proxy_socks4_test.go @@ -30,36 +30,7 @@ func TestSocks4ProxyE2E(t *testing.T) { } defer proxyLn.Close() - var wg sync.WaitGroup - done := make(chan struct{}) - wg.Add(1) - go func() { - defer wg.Done() - for { - conn, err := proxyLn.Accept() - if err != nil { - select { - case <-done: - return - default: - return - } - } - wg.Add(1) - go func(c net.Conn) { - defer wg.Done() - defer c.Close() - if err := handleSocks4Connect(c); err != nil { - return - } - }(conn) - } - }() - defer func() { - close(done) - proxyLn.Close() - wg.Wait() - }() + stop := startSocks4Proxy(t, proxyLn, nil) proxyURL := "socks4://userid@" + proxyLn.Addr().String() client := C().SetProxyURL(proxyURL).DisableKeepAlives() @@ -77,28 +48,78 @@ func TestSocks4ProxyE2E(t *testing.T) { if resp.String() != "hello-socks4" { t.Fatalf("body = %q; want hello-socks4", resp.String()) } + stop() } -// TestSocks4aProxyE2E verifies socks4a scheme with domain-style target. +// TestSocks4aProxyE2E verifies the socks4a scheme sends a domain name to the +// proxy (SOCKS4a extension) and successfully relays the HTTP request. func TestSocks4aProxyE2E(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("hello-socks4a")) })) defer backend.Close() + backendAddr := backend.Listener.Addr().String() + _, backendPort, err := net.SplitHostPort(backendAddr) + if err != nil { + t.Fatal(err) + } + + const fakeDomain = "backend.socks4a.test" + proxyLn, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer proxyLn.Close() + gotDomain := make(chan string, 1) + stop := startSocks4Proxy(t, proxyLn, func(domain string) string { + select { + case gotDomain <- domain: + default: + } + if domain == fakeDomain { + return backendAddr + } + return "" + }) + + // Request a non-resolvable domain name so the client must use SOCKS4a + // remote DNS rather than an IPv4 literal path. + client := C().SetProxyURL("socks4a://" + proxyLn.Addr().String()).DisableKeepAlives() + url := "http://" + net.JoinHostPort(fakeDomain, backendPort) + "/" + resp, err := client.R().Get(url) + if err != nil { + t.Fatalf("request via socks4a proxy failed: %v", err) + } + if resp.String() != "hello-socks4a" { + t.Fatalf("body = %q; want hello-socks4a", resp.String()) + } + + select { + case domain := <-gotDomain: + if domain != fakeDomain { + t.Fatalf("proxy domain = %q; want %q", domain, fakeDomain) + } + case <-time.After(2 * time.Second): + t.Fatal("proxy did not receive a SOCKS4a domain name") + } + stop() +} + +// startSocks4Proxy accepts SOCKS4/4a CONNECT requests and relays to the target. +// If resolveDomain is non-nil and the request uses SOCKS4a, resolveDomain is +// called with the domain; a non-empty return value is dialed instead of the domain. +func startSocks4Proxy(t *testing.T, ln net.Listener, resolveDomain func(string) string) (stop func()) { + t.Helper() var wg sync.WaitGroup done := make(chan struct{}) wg.Add(1) go func() { defer wg.Done() for { - conn, err := proxyLn.Accept() + conn, err := ln.Accept() if err != nil { select { case <-done: @@ -111,31 +132,19 @@ func TestSocks4aProxyE2E(t *testing.T) { go func(c net.Conn) { defer wg.Done() defer c.Close() - _ = handleSocks4Connect(c) + _ = handleSocks4Connect(c, resolveDomain) }(conn) } }() - defer func() { + return func() { close(done) - proxyLn.Close() + ln.Close() wg.Wait() - }() - - // Use 127.0.0.1 in the backend URL so the target address is IPv4; - // socks4a still works when the host is an IP (no domain extension needed). - client := C().SetProxyURL("socks4a://" + proxyLn.Addr().String()).DisableKeepAlives() - - resp, err := client.R().Get(backend.URL) - if err != nil { - t.Fatalf("request via socks4a proxy failed: %v", err) - } - if resp.String() != "hello-socks4a" { - t.Fatalf("body = %q; want hello-socks4a", resp.String()) } } // handleSocks4Connect performs a SOCKS4/4a CONNECT handshake and relays. -func handleSocks4Connect(c net.Conn) error { +func handleSocks4Connect(c net.Conn, resolveDomain func(string) string) error { _ = c.SetDeadline(time.Now().Add(5 * time.Second)) var hdr [8]byte @@ -155,19 +164,29 @@ func handleSocks4Connect(c net.Conn) error { return err } - var host string + var target string // SOCKS4a domain when IP is 0.0.0.x with x != 0 if hdr[4] == 0 && hdr[5] == 0 && hdr[6] == 0 && hdr[7] != 0 { domain, err := readNULString(c) if err != nil { return err } - host = domain + if resolveDomain != nil { + if mapped := resolveDomain(domain); mapped != "" { + target = mapped + } + } + if target == "" { + target = net.JoinHostPort(domain, itoa(port)) + } } else { - host = ip.String() + target = net.JoinHostPort(ip.String(), itoa(port)) + if resolveDomain != nil { + // Still report empty domain for IP-literal path. + resolveDomain("") + } } - target := net.JoinHostPort(host, itoa(port)) upstream, err := net.DialTimeout("tcp", target, 2*time.Second) if err != nil { _, _ = c.Write([]byte{0, 91, 0, 0, 0, 0, 0, 0}) diff --git a/transport.go b/transport.go index dffc34d5..75ef99f5 100644 --- a/transport.go +++ b/transport.go @@ -463,6 +463,9 @@ func (t *Transport) SetDebug(debugf func(format string, v ...any)) *Transport { // The proxy type is determined by the URL scheme. "http", // "https", "socks5", "socks5h", "socks4", and "socks4a" are // supported. If the scheme is empty, "http" is assumed. +// "socks5" is treated the same as "socks5h". +// "socks4" resolves domain names locally to IPv4; "socks4a" lets the +// proxy resolve domain names. SOCKS4 only supports IPv4 destinations. // // If Proxy is nil or returns a nil *URL, no proxy is used. func (t *Transport) SetProxy(proxy func(*http.Request) (*url.URL, error)) *Transport {