Skip to content

Commit 927abde

Browse files
jpilloraclaude
andauthored
Improve SOCKS auth: enforce per-user ACL on socks channels (#591)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b9d1219 commit 927abde

4 files changed

Lines changed: 258 additions & 4 deletions

File tree

share/settings/remote.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,13 @@ func (r Remote) UserAddr() string {
221221
if r.Reverse {
222222
return "R:" + r.LocalHost + ":" + r.LocalPort
223223
}
224+
//forward socks is granted via the literal "socks" token, matching the
225+
//per-channel ACL check in tunnel_out_ssh.go (ExtraData == "socks").
226+
//Without this it would be ":" (empty host:port), which is opaque and
227+
//inconsistent with the channel-level check.
228+
if r.Socks {
229+
return "socks"
230+
}
224231
return r.RemoteHost + ":" + r.RemotePort
225232
}
226233

share/tunnel/tunnel_out_ssh.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ func (t *Tunnel) handleSSHChannel(ch ssh.NewChannel) {
4747
return
4848
}
4949
//check ACL against the actual requested destination
50-
if t.Config.ACL != nil && !socks && !t.Config.ACL(hostPort) {
50+
//(hostPort == "socks" for socks channels, so socks is gated too,
51+
//symmetric with the config-time UserAddr() check in server_handler.go)
52+
if t.Config.ACL != nil && !t.Config.ACL(hostPort) {
5153
t.Debugf("Denied connection to %s (ACL)", hostPort)
5254
ch.Reject(ssh.Prohibited, "access denied")
5355
return

test/e2e/acl_channel_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"io"
77
"net"
88
"net/http"
9+
"strconv"
10+
"strings"
911
"testing"
1012
"time"
1113

@@ -321,3 +323,138 @@ func TestAuthWildcardChannel(t *testing.T) {
321323
}
322324
t.Logf("wildcard user correctly allowed")
323325
}
326+
327+
// TestAuthSocksChannelDenied verifies that a user who is NOT granted socks
328+
// cannot open a socks channel, even when the server runs with --socks5.
329+
// socks channels are authorized against the per-user ACL like any other
330+
// destination (the channel token is the literal "socks").
331+
func TestAuthSocksChannelDenied(t *testing.T) {
332+
allowedPort := availablePort()
333+
334+
// SOCKS5 is enabled, so any rejection can only come from the ACL,
335+
// not from the "SOCKS5 is not enabled" guard.
336+
s, err := chserver.NewServer(&chserver.Config{
337+
KeySeed: "acl-socks-denied",
338+
Socks5: true,
339+
})
340+
if err != nil {
341+
t.Fatal(err)
342+
}
343+
s.Debug = debug
344+
// user is pinned to a single TCP destination; socks is NOT granted
345+
if err := s.AddUser("user", "pass", fmt.Sprintf(`^127\.0\.0\.1:%s$`, allowedPort)); err != nil {
346+
t.Fatal(err)
347+
}
348+
serverPort := availablePort()
349+
if err := s.Start("127.0.0.1", serverPort); err != nil {
350+
t.Fatal(err)
351+
}
352+
defer s.Close()
353+
354+
// declare ONLY the allowed remote so the config handshake passes
355+
sc, _, _ := dialChiselSSH(t, "127.0.0.1:"+serverPort, "user", "pass")
356+
defer sc.Close()
357+
r, err := settings.DecodeRemote(fmt.Sprintf("0.0.0.0:%s:127.0.0.1:%s", allowedPort, allowedPort))
358+
if err != nil {
359+
t.Fatal(err)
360+
}
361+
sendConfig(t, sc, []*settings.Remote{r})
362+
363+
// open a raw channel whose ExtraData is "socks" — must be denied by the ACL
364+
ch, _, err := sc.OpenChannel("chisel", []byte("socks"))
365+
if err == nil {
366+
ch.Close()
367+
t.Fatal("socks channel was accepted for a user without socks access")
368+
}
369+
// it must be the ACL talking, not "SOCKS5 is not enabled"
370+
if !strings.Contains(err.Error(), "access denied") {
371+
t.Fatalf("socks channel rejected for the wrong reason: %v", err)
372+
}
373+
t.Logf("socks channel correctly rejected by ACL: %v", err)
374+
}
375+
376+
// TestAuthSocksChannelAllowed verifies the ACL does not over-block: a user
377+
// explicitly granted socks (a regex matching the "socks" channel token) can
378+
// open a socks channel and reach a destination through the SOCKS5 proxy.
379+
func TestAuthSocksChannelAllowed(t *testing.T) {
380+
targetPort := availablePort()
381+
382+
// a destination to CONNECT to through socks
383+
ln, err := net.Listen("tcp", "127.0.0.1:"+targetPort)
384+
if err != nil {
385+
t.Fatal(err)
386+
}
387+
defer ln.Close()
388+
go func() {
389+
for {
390+
conn, err := ln.Accept()
391+
if err != nil {
392+
return
393+
}
394+
conn.Write([]byte("VIA-SOCKS"))
395+
conn.Close()
396+
}
397+
}()
398+
399+
s, err := chserver.NewServer(&chserver.Config{
400+
KeySeed: "acl-socks-allowed",
401+
Socks5: true,
402+
})
403+
if err != nil {
404+
t.Fatal(err)
405+
}
406+
s.Debug = debug
407+
// grant socks at the channel level (the channel token is the literal "socks")
408+
if err := s.AddUser("user", "pass", `^socks$`); err != nil {
409+
t.Fatal(err)
410+
}
411+
serverPort := availablePort()
412+
if err := s.Start("127.0.0.1", serverPort); err != nil {
413+
t.Fatal(err)
414+
}
415+
defer s.Close()
416+
417+
sc, _, _ := dialChiselSSH(t, "127.0.0.1:"+serverPort, "user", "pass")
418+
defer sc.Close()
419+
// no remotes to declare; the socks channel is opened directly
420+
sendConfig(t, sc, []*settings.Remote{})
421+
422+
ch, reqs, err := sc.OpenChannel("chisel", []byte("socks"))
423+
if err != nil {
424+
t.Fatalf("socks channel rejected for a user granted socks: %v", err)
425+
}
426+
go ssh.DiscardRequests(reqs)
427+
defer ch.Close()
428+
429+
// drive a minimal SOCKS5 CONNECT to the allowed local listener
430+
if _, err := ch.Write([]byte{0x05, 0x01, 0x00}); err != nil { // greeting: no-auth
431+
t.Fatal(err)
432+
}
433+
if _, err := io.ReadFull(ch, make([]byte, 2)); err != nil { // method selection
434+
t.Fatal(err)
435+
}
436+
port, err := strconv.Atoi(targetPort)
437+
if err != nil {
438+
t.Fatal(err)
439+
}
440+
connect := []byte{0x05, 0x01, 0x00, 0x01, 127, 0, 0, 1, byte(port >> 8), byte(port)}
441+
if _, err := ch.Write(connect); err != nil {
442+
t.Fatal(err)
443+
}
444+
reply := make([]byte, 10)
445+
if _, err := io.ReadFull(ch, reply); err != nil {
446+
t.Fatal(err)
447+
}
448+
if reply[1] != 0x00 {
449+
t.Fatalf("socks CONNECT failed, rep=%d", reply[1])
450+
}
451+
buf := make([]byte, 16)
452+
n, err := ch.Read(buf)
453+
if err != nil && err != io.EOF {
454+
t.Fatalf("read via socks: %v", err)
455+
}
456+
if string(buf[:n]) != "VIA-SOCKS" {
457+
t.Fatalf("expected 'VIA-SOCKS' via socks, got %q", buf[:n])
458+
}
459+
t.Logf("socks channel allowed and functional for a granted user")
460+
}

test/e2e/socks_test.go

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,113 @@
11
package e2e_test
22

3-
//TODO tests for:
4-
// - SOCKS-client -> [client -> server SOCKS] -> endpoint
5-
// - SOCKS-client -> [server -> client SOCKS] -> endpoint
3+
import (
4+
"context"
5+
"io"
6+
"net"
7+
"net/http"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"golang.org/x/net/proxy"
13+
14+
chclient "github.com/jpillora/chisel/client"
15+
chserver "github.com/jpillora/chisel/server"
16+
)
17+
18+
//TODO test: SOCKS-client -> [server -> client SOCKS] -> endpoint (reverse socks)
19+
20+
// TestSocksEndToEnd exercises the full forward-SOCKS5 path for a user granted
21+
// socks via "^socks$":
22+
//
23+
// http client -> chisel client's local SOCKS5 -> tunnel ->
24+
// chisel server's SOCKS5 proxy -> endpoint
25+
//
26+
// It covers both ACL checks: the config-time check (UserAddr() == "socks")
27+
// lets the client register its socks remote, and the per-channel check lets
28+
// each tunnelled connection through. A "^socks$"-only user only succeeds when
29+
// both checks agree on the "socks" token.
30+
func TestSocksEndToEnd(t *testing.T) {
31+
// endpoint reached via the socks proxy: echoes the request body + '!'
32+
endpoint := &http.Server{
33+
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34+
b, _ := io.ReadAll(r.Body)
35+
w.Write(append(b, '!'))
36+
}),
37+
}
38+
endpointPort := availablePort()
39+
el, err := net.Listen("tcp", "127.0.0.1:"+endpointPort)
40+
if err != nil {
41+
t.Fatal(err)
42+
}
43+
go endpoint.Serve(el)
44+
defer endpoint.Close()
45+
46+
// chisel server: SOCKS5 enabled, user granted ONLY socks
47+
s, err := chserver.NewServer(&chserver.Config{
48+
KeySeed: "socks-e2e",
49+
Socks5: true,
50+
})
51+
if err != nil {
52+
t.Fatal(err)
53+
}
54+
s.Debug = debug
55+
if err := s.AddUser("user", "pass", `^socks$`); err != nil {
56+
t.Fatal(err)
57+
}
58+
serverPort := availablePort()
59+
if err := s.Start("127.0.0.1", serverPort); err != nil {
60+
t.Fatal(err)
61+
}
62+
defer s.Close()
63+
64+
// chisel client: local SOCKS5 listener, authed as the socks-only user
65+
socksPort := availablePort()
66+
c, err := chclient.NewClient(&chclient.Config{
67+
Server: "http://127.0.0.1:" + serverPort,
68+
Auth: "user:pass",
69+
Fingerprint: s.GetFingerprint(),
70+
Remotes: []string{"127.0.0.1:" + socksPort + ":socks"},
71+
})
72+
if err != nil {
73+
t.Fatal(err)
74+
}
75+
c.Debug = debug
76+
ctx, cancel := context.WithCancel(context.Background())
77+
defer cancel()
78+
if err := c.Start(ctx); err != nil {
79+
t.Fatal(err)
80+
}
81+
defer c.Close()
82+
83+
// HTTP request through the local SOCKS5 proxy to the endpoint, retrying
84+
// until the tunnel is established (bounded ~5s).
85+
dialer, err := proxy.SOCKS5("tcp", "127.0.0.1:"+socksPort, nil, proxy.Direct)
86+
if err != nil {
87+
t.Fatal(err)
88+
}
89+
httpClient := &http.Client{
90+
Timeout: 2 * time.Second,
91+
Transport: &http.Transport{
92+
DialContext: func(_ context.Context, network, addr string) (net.Conn, error) {
93+
return dialer.Dial(network, addr)
94+
},
95+
},
96+
}
97+
endpointURL := "http://" + net.JoinHostPort("127.0.0.1", endpointPort) + "/"
98+
var body string
99+
for i := 0; i < 50; i++ {
100+
resp, err := httpClient.Post(endpointURL, "text/plain", strings.NewReader("foo"))
101+
if err == nil {
102+
b, _ := io.ReadAll(resp.Body)
103+
resp.Body.Close()
104+
body = string(b)
105+
break
106+
}
107+
time.Sleep(100 * time.Millisecond)
108+
}
109+
if body != "foo!" {
110+
t.Fatalf("expected \"foo!\" through socks, got %q", body)
111+
}
112+
t.Logf("forward socks end-to-end works for a ^socks$ user")
113+
}

0 commit comments

Comments
 (0)