Skip to content

Commit fba25b7

Browse files
feat: Upstream sync v1.11.5 - Enforce auth ACL on tunnel channels (#51)
Cherry-picked from upstream commit 44310b6. Previously, authfile ACL restrictions were only checked during the initial config handshake. This adds ACL enforcement at the tunnel layer when processing SSH channel requests, ensuring that each outbound connection is validated against the user's allowed addresses. This is a security enhancement that closes a gap where ACL restrictions could potentially be bypassed after initial authentication. Note: Dependency update commits (57d2249, 4df5fcf) were not included as the fork already has newer dependency versions than upstream v1.11.5. Co-authored-by: Jaime Pillora <dev@jpillora.com>
1 parent adec94b commit fba25b7

4 files changed

Lines changed: 339 additions & 2 deletions

File tree

server/server_handler.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,13 +135,18 @@ func (s *Server) handleWebsocket(w http.ResponseWriter, req *http.Request) {
135135
//successfuly validated config!
136136
r.Reply(true, nil)
137137
//tunnel per ssh connection
138-
tunnel := tunnel.New(tunnel.Config{
138+
tunnelConfig := tunnel.Config{
139139
Logger: l,
140140
Inbound: s.config.Reverse,
141141
Outbound: true, //server always accepts outbound
142142
Socks: s.config.Socks5,
143143
KeepAlive: s.config.KeepAlive,
144-
})
144+
}
145+
//enforce ACL on every channel, not just the initial config
146+
if user != nil {
147+
tunnelConfig.ACL = user.HasAccess
148+
}
149+
tunnel := tunnel.New(tunnelConfig)
145150
//bind
146151
eg, ctx := errgroup.WithContext(req.Context())
147152
eg.Go(func() error {

share/tunnel/tunnel.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ type Config struct {
2525
Outbound bool
2626
Socks bool
2727
KeepAlive time.Duration
28+
//ACL optionally checks if a given address (host:port) is allowed.
29+
//When set, outbound connections are denied if this returns false.
30+
ACL func(addr string) bool
2831
}
2932

3033
// Tunnel represents an SSH tunnel with proxy capabilities.

share/tunnel/tunnel_out_ssh.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ func (t *Tunnel) handleSSHChannel(ch ssh.NewChannel) {
4646
ch.Reject(ssh.Prohibited, "SOCKS5 is not enabled")
4747
return
4848
}
49+
//check ACL against the actual requested destination
50+
if t.Config.ACL != nil && !socks && !t.Config.ACL(hostPort) {
51+
t.Debugf("Denied connection to %s (ACL)", hostPort)
52+
ch.Reject(ssh.Prohibited, "access denied")
53+
return
54+
}
4955
sshChan, reqs, err := ch.Accept()
5056
if err != nil {
5157
t.Debugf("Failed to accept stream: %s", err)

test/e2e/acl_channel_test.go

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
package e2e_test
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net"
8+
"net/http"
9+
"testing"
10+
"time"
11+
12+
chserver "github.com/jpillora/chisel/server"
13+
"github.com/jpillora/chisel/share/cnet"
14+
"github.com/jpillora/chisel/share/settings"
15+
16+
"github.com/gorilla/websocket"
17+
"golang.org/x/crypto/ssh"
18+
)
19+
20+
// dialChiselSSH connects to the chisel server via websocket and
21+
// performs an SSH handshake as the given user.
22+
func dialChiselSSH(t *testing.T, serverAddr, user, pass string) (ssh.Conn, <-chan ssh.NewChannel, <-chan *ssh.Request) {
23+
t.Helper()
24+
ws, _, err := (&websocket.Dialer{
25+
HandshakeTimeout: 5 * time.Second,
26+
Subprotocols: []string{"chisel-v3"},
27+
}).Dial("ws://"+serverAddr, http.Header{})
28+
if err != nil {
29+
t.Fatalf("websocket dial: %v", err)
30+
}
31+
conn := cnet.NewWebSocketConn(ws)
32+
sc, chans, reqs, err := ssh.NewClientConn(conn, "", &ssh.ClientConfig{
33+
User: user,
34+
Auth: []ssh.AuthMethod{ssh.Password(pass)},
35+
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
36+
})
37+
if err != nil {
38+
t.Fatalf("ssh handshake: %v", err)
39+
}
40+
go ssh.DiscardRequests(reqs)
41+
go func() { for c := range chans { c.Reject(ssh.Prohibited, "") } }()
42+
return sc, chans, reqs
43+
}
44+
45+
// sendConfig sends the chisel config request with the given remotes.
46+
func sendConfig(t *testing.T, sc ssh.Conn, remotes []*settings.Remote) {
47+
t.Helper()
48+
cfg, err := json.Marshal(settings.Config{Version: "0", Remotes: remotes})
49+
if err != nil {
50+
t.Fatalf("marshal config: %v", err)
51+
}
52+
ok, reply, err := sc.SendRequest("config", true, cfg)
53+
if err != nil {
54+
t.Fatalf("config request: %v", err)
55+
}
56+
if !ok {
57+
t.Fatalf("config rejected: %s", reply)
58+
}
59+
}
60+
61+
// TestAuthChannelDenied verifies that a channel to an unauthorized
62+
// destination is rejected.
63+
func TestAuthChannelDenied(t *testing.T) {
64+
allowedPort := availablePort()
65+
blockedPort := availablePort()
66+
67+
blockedListener, err := net.Listen("tcp", "127.0.0.1:"+blockedPort)
68+
if err != nil {
69+
t.Fatal(err)
70+
}
71+
defer blockedListener.Close()
72+
go func() {
73+
for {
74+
conn, err := blockedListener.Accept()
75+
if err != nil {
76+
return
77+
}
78+
conn.Write([]byte("FORBIDDEN"))
79+
conn.Close()
80+
}
81+
}()
82+
83+
// Start chisel server with ACL: user can only reach allowedPort
84+
s, err := chserver.NewServer(&chserver.Config{
85+
KeySeed: "acl-test",
86+
})
87+
if err != nil {
88+
t.Fatal(err)
89+
}
90+
s.Debug = debug
91+
if err := s.AddUser("user", "pass", fmt.Sprintf(`^127\.0\.0\.1:%s$`, allowedPort)); err != nil {
92+
t.Fatal(err)
93+
}
94+
serverPort := availablePort()
95+
if err := s.Start("127.0.0.1", serverPort); err != nil {
96+
t.Fatal(err)
97+
}
98+
defer s.Close()
99+
100+
serverAddr := "127.0.0.1:" + serverPort
101+
102+
// Connect and send config with only the allowed remote
103+
sc, _, _ := dialChiselSSH(t, serverAddr, "user", "pass")
104+
defer sc.Close()
105+
106+
r, err := settings.DecodeRemote(fmt.Sprintf("0.0.0.0:%s:127.0.0.1:%s", allowedPort, allowedPort))
107+
if err != nil {
108+
t.Fatal(err)
109+
}
110+
sendConfig(t, sc, []*settings.Remote{r})
111+
112+
// Try to open a channel to the BLOCKED port — must be rejected
113+
target := net.JoinHostPort("127.0.0.1", blockedPort)
114+
ch, _, err := sc.OpenChannel("chisel", []byte(target))
115+
if err == nil {
116+
ch.Close()
117+
t.Fatalf("channel to blocked port %s was accepted", blockedPort)
118+
}
119+
t.Logf("channel to blocked port correctly rejected: %v", err)
120+
}
121+
122+
// TestAuthChannelAllowed verifies that a channel to an authorized
123+
// destination is accepted.
124+
func TestAuthChannelAllowed(t *testing.T) {
125+
allowedPort := availablePort()
126+
127+
// Start a TCP listener on the allowed port
128+
allowedListener, err := net.Listen("tcp", "127.0.0.1:"+allowedPort)
129+
if err != nil {
130+
t.Fatal(err)
131+
}
132+
defer allowedListener.Close()
133+
go func() {
134+
for {
135+
conn, err := allowedListener.Accept()
136+
if err != nil {
137+
return
138+
}
139+
conn.Write([]byte("ALLOWED"))
140+
conn.Close()
141+
}
142+
}()
143+
144+
// Start chisel server with ACL: user can only reach allowedPort
145+
s, err := chserver.NewServer(&chserver.Config{
146+
KeySeed: "acl-test-allowed",
147+
})
148+
if err != nil {
149+
t.Fatal(err)
150+
}
151+
s.Debug = debug
152+
if err := s.AddUser("user", "pass", fmt.Sprintf(`^127\.0\.0\.1:%s$`, allowedPort)); err != nil {
153+
t.Fatal(err)
154+
}
155+
serverPort := availablePort()
156+
if err := s.Start("127.0.0.1", serverPort); err != nil {
157+
t.Fatal(err)
158+
}
159+
defer s.Close()
160+
161+
serverAddr := "127.0.0.1:" + serverPort
162+
163+
// Connect and send config with the allowed remote
164+
sc, _, _ := dialChiselSSH(t, serverAddr, "user", "pass")
165+
defer sc.Close()
166+
167+
r, err := settings.DecodeRemote(fmt.Sprintf("0.0.0.0:%s:127.0.0.1:%s", allowedPort, allowedPort))
168+
if err != nil {
169+
t.Fatal(err)
170+
}
171+
sendConfig(t, sc, []*settings.Remote{r})
172+
173+
// Open channel to the allowed port — must succeed
174+
target := net.JoinHostPort("127.0.0.1", allowedPort)
175+
ch, reqs, err := sc.OpenChannel("chisel", []byte(target))
176+
if err != nil {
177+
t.Fatalf("channel to allowed port %s was rejected: %v", allowedPort, err)
178+
}
179+
go ssh.DiscardRequests(reqs)
180+
defer ch.Close()
181+
182+
// Read data from the allowed target
183+
buf := make([]byte, 64)
184+
n, err := ch.Read(buf)
185+
if err != nil && err != io.EOF {
186+
t.Fatalf("read from allowed channel: %v", err)
187+
}
188+
if string(buf[:n]) != "ALLOWED" {
189+
t.Fatalf("expected 'ALLOWED', got %q", buf[:n])
190+
}
191+
t.Logf("channel to allowed port works correctly, received: %s", buf[:n])
192+
}
193+
194+
// TestNoAuthChannel verifies that when no auth is configured,
195+
// all destinations are reachable.
196+
func TestNoAuthChannel(t *testing.T) {
197+
targetPort := availablePort()
198+
199+
// Start a TCP listener
200+
listener, err := net.Listen("tcp", "127.0.0.1:"+targetPort)
201+
if err != nil {
202+
t.Fatal(err)
203+
}
204+
defer listener.Close()
205+
go func() {
206+
for {
207+
conn, err := listener.Accept()
208+
if err != nil {
209+
return
210+
}
211+
conn.Write([]byte("OPEN"))
212+
conn.Close()
213+
}
214+
}()
215+
216+
// Start chisel server with NO auth
217+
s, err := chserver.NewServer(&chserver.Config{
218+
KeySeed: "no-acl-test",
219+
})
220+
if err != nil {
221+
t.Fatal(err)
222+
}
223+
s.Debug = debug
224+
serverPort := availablePort()
225+
if err := s.Start("127.0.0.1", serverPort); err != nil {
226+
t.Fatal(err)
227+
}
228+
defer s.Close()
229+
230+
serverAddr := "127.0.0.1:" + serverPort
231+
232+
// Connect with any credentials (server accepts all when no auth configured)
233+
sc, _, _ := dialChiselSSH(t, serverAddr, "anyone", "anything")
234+
defer sc.Close()
235+
236+
r, err := settings.DecodeRemote(fmt.Sprintf("0.0.0.0:%s:127.0.0.1:%s", targetPort, targetPort))
237+
if err != nil {
238+
t.Fatal(err)
239+
}
240+
sendConfig(t, sc, []*settings.Remote{r})
241+
242+
// Open channel — should be accepted since no ACL
243+
target := net.JoinHostPort("127.0.0.1", targetPort)
244+
ch, creqs, err := sc.OpenChannel("chisel", []byte(target))
245+
if err != nil {
246+
t.Fatalf("channel rejected when no ACL is configured: %v", err)
247+
}
248+
go ssh.DiscardRequests(creqs)
249+
defer ch.Close()
250+
251+
buf := make([]byte, 64)
252+
n, err := ch.Read(buf)
253+
if err != nil && err != io.EOF {
254+
t.Fatalf("read: %v", err)
255+
}
256+
if string(buf[:n]) != "OPEN" {
257+
t.Fatalf("expected 'OPEN', got %q", buf[:n])
258+
}
259+
t.Logf("no-ACL mode works correctly")
260+
}
261+
262+
// TestAuthWildcardChannel verifies that a user with wildcard access
263+
// can reach any destination.
264+
func TestAuthWildcardChannel(t *testing.T) {
265+
targetPort := availablePort()
266+
267+
listener, err := net.Listen("tcp", "127.0.0.1:"+targetPort)
268+
if err != nil {
269+
t.Fatal(err)
270+
}
271+
defer listener.Close()
272+
go func() {
273+
for {
274+
conn, err := listener.Accept()
275+
if err != nil {
276+
return
277+
}
278+
conn.Write([]byte("WILDCARD"))
279+
conn.Close()
280+
}
281+
}()
282+
283+
s, err := chserver.NewServer(&chserver.Config{
284+
KeySeed: "acl-wildcard-test",
285+
Auth: "admin:secret",
286+
})
287+
if err != nil {
288+
t.Fatal(err)
289+
}
290+
s.Debug = debug
291+
serverPort := availablePort()
292+
if err := s.Start("127.0.0.1", serverPort); err != nil {
293+
t.Fatal(err)
294+
}
295+
defer s.Close()
296+
297+
sc, _, _ := dialChiselSSH(t, "127.0.0.1:"+serverPort, "admin", "secret")
298+
defer sc.Close()
299+
300+
r, err := settings.DecodeRemote(fmt.Sprintf("0.0.0.0:%s:127.0.0.1:%s", targetPort, targetPort))
301+
if err != nil {
302+
t.Fatal(err)
303+
}
304+
sendConfig(t, sc, []*settings.Remote{r})
305+
306+
target := net.JoinHostPort("127.0.0.1", targetPort)
307+
ch, reqs, err := sc.OpenChannel("chisel", []byte(target))
308+
if err != nil {
309+
t.Fatalf("wildcard user channel rejected: %v", err)
310+
}
311+
go ssh.DiscardRequests(reqs)
312+
defer ch.Close()
313+
314+
buf := make([]byte, 64)
315+
n, err := ch.Read(buf)
316+
if err != nil && err != io.EOF {
317+
t.Fatalf("read: %v", err)
318+
}
319+
if string(buf[:n]) != "WILDCARD" {
320+
t.Fatalf("expected 'WILDCARD', got %q", buf[:n])
321+
}
322+
t.Logf("wildcard user correctly allowed")
323+
}

0 commit comments

Comments
 (0)