From 0510fc9f49abcef97cf620bfa05c7addc8fdb8df Mon Sep 17 00:00:00 2001 From: samartha-pm Date: Fri, 3 Apr 2026 11:31:25 +0530 Subject: [PATCH] feat: Upstream sync v1.11.5 - Enforce auth ACL on tunnel channels 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 --- server/server_handler.go | 9 +- share/tunnel/tunnel.go | 3 + share/tunnel/tunnel_out_ssh.go | 6 + test/e2e/acl_channel_test.go | 323 +++++++++++++++++++++++++++++++++ 4 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 test/e2e/acl_channel_test.go diff --git a/server/server_handler.go b/server/server_handler.go index a732e2b6..8b5a68fd 100644 --- a/server/server_handler.go +++ b/server/server_handler.go @@ -135,13 +135,18 @@ func (s *Server) handleWebsocket(w http.ResponseWriter, req *http.Request) { //successfuly validated config! r.Reply(true, nil) //tunnel per ssh connection - tunnel := tunnel.New(tunnel.Config{ + tunnelConfig := tunnel.Config{ Logger: l, Inbound: s.config.Reverse, Outbound: true, //server always accepts outbound Socks: s.config.Socks5, KeepAlive: s.config.KeepAlive, - }) + } + //enforce ACL on every channel, not just the initial config + if user != nil { + tunnelConfig.ACL = user.HasAccess + } + tunnel := tunnel.New(tunnelConfig) //bind eg, ctx := errgroup.WithContext(req.Context()) eg.Go(func() error { diff --git a/share/tunnel/tunnel.go b/share/tunnel/tunnel.go index 7493a5ac..67b0ba9a 100644 --- a/share/tunnel/tunnel.go +++ b/share/tunnel/tunnel.go @@ -25,6 +25,9 @@ type Config struct { Outbound bool Socks bool KeepAlive time.Duration + //ACL optionally checks if a given address (host:port) is allowed. + //When set, outbound connections are denied if this returns false. + ACL func(addr string) bool } // Tunnel represents an SSH tunnel with proxy capabilities. diff --git a/share/tunnel/tunnel_out_ssh.go b/share/tunnel/tunnel_out_ssh.go index b07b98ed..0f2fb5d2 100644 --- a/share/tunnel/tunnel_out_ssh.go +++ b/share/tunnel/tunnel_out_ssh.go @@ -46,6 +46,12 @@ func (t *Tunnel) handleSSHChannel(ch ssh.NewChannel) { ch.Reject(ssh.Prohibited, "SOCKS5 is not enabled") return } + //check ACL against the actual requested destination + if t.Config.ACL != nil && !socks && !t.Config.ACL(hostPort) { + t.Debugf("Denied connection to %s (ACL)", hostPort) + ch.Reject(ssh.Prohibited, "access denied") + return + } sshChan, reqs, err := ch.Accept() if err != nil { t.Debugf("Failed to accept stream: %s", err) diff --git a/test/e2e/acl_channel_test.go b/test/e2e/acl_channel_test.go new file mode 100644 index 00000000..65e18388 --- /dev/null +++ b/test/e2e/acl_channel_test.go @@ -0,0 +1,323 @@ +package e2e_test + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "testing" + "time" + + chserver "github.com/jpillora/chisel/server" + "github.com/jpillora/chisel/share/cnet" + "github.com/jpillora/chisel/share/settings" + + "github.com/gorilla/websocket" + "golang.org/x/crypto/ssh" +) + +// dialChiselSSH connects to the chisel server via websocket and +// performs an SSH handshake as the given user. +func dialChiselSSH(t *testing.T, serverAddr, user, pass string) (ssh.Conn, <-chan ssh.NewChannel, <-chan *ssh.Request) { + t.Helper() + ws, _, err := (&websocket.Dialer{ + HandshakeTimeout: 5 * time.Second, + Subprotocols: []string{"chisel-v3"}, + }).Dial("ws://"+serverAddr, http.Header{}) + if err != nil { + t.Fatalf("websocket dial: %v", err) + } + conn := cnet.NewWebSocketConn(ws) + sc, chans, reqs, err := ssh.NewClientConn(conn, "", &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ssh.Password(pass)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + }) + if err != nil { + t.Fatalf("ssh handshake: %v", err) + } + go ssh.DiscardRequests(reqs) + go func() { for c := range chans { c.Reject(ssh.Prohibited, "") } }() + return sc, chans, reqs +} + +// sendConfig sends the chisel config request with the given remotes. +func sendConfig(t *testing.T, sc ssh.Conn, remotes []*settings.Remote) { + t.Helper() + cfg, err := json.Marshal(settings.Config{Version: "0", Remotes: remotes}) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + ok, reply, err := sc.SendRequest("config", true, cfg) + if err != nil { + t.Fatalf("config request: %v", err) + } + if !ok { + t.Fatalf("config rejected: %s", reply) + } +} + +// TestAuthChannelDenied verifies that a channel to an unauthorized +// destination is rejected. +func TestAuthChannelDenied(t *testing.T) { + allowedPort := availablePort() + blockedPort := availablePort() + + blockedListener, err := net.Listen("tcp", "127.0.0.1:"+blockedPort) + if err != nil { + t.Fatal(err) + } + defer blockedListener.Close() + go func() { + for { + conn, err := blockedListener.Accept() + if err != nil { + return + } + conn.Write([]byte("FORBIDDEN")) + conn.Close() + } + }() + + // Start chisel server with ACL: user can only reach allowedPort + s, err := chserver.NewServer(&chserver.Config{ + KeySeed: "acl-test", + }) + if err != nil { + t.Fatal(err) + } + s.Debug = debug + if err := s.AddUser("user", "pass", fmt.Sprintf(`^127\.0\.0\.1:%s$`, allowedPort)); err != nil { + t.Fatal(err) + } + serverPort := availablePort() + if err := s.Start("127.0.0.1", serverPort); err != nil { + t.Fatal(err) + } + defer s.Close() + + serverAddr := "127.0.0.1:" + serverPort + + // Connect and send config with only the allowed remote + sc, _, _ := dialChiselSSH(t, serverAddr, "user", "pass") + defer sc.Close() + + r, err := settings.DecodeRemote(fmt.Sprintf("0.0.0.0:%s:127.0.0.1:%s", allowedPort, allowedPort)) + if err != nil { + t.Fatal(err) + } + sendConfig(t, sc, []*settings.Remote{r}) + + // Try to open a channel to the BLOCKED port — must be rejected + target := net.JoinHostPort("127.0.0.1", blockedPort) + ch, _, err := sc.OpenChannel("chisel", []byte(target)) + if err == nil { + ch.Close() + t.Fatalf("channel to blocked port %s was accepted", blockedPort) + } + t.Logf("channel to blocked port correctly rejected: %v", err) +} + +// TestAuthChannelAllowed verifies that a channel to an authorized +// destination is accepted. +func TestAuthChannelAllowed(t *testing.T) { + allowedPort := availablePort() + + // Start a TCP listener on the allowed port + allowedListener, err := net.Listen("tcp", "127.0.0.1:"+allowedPort) + if err != nil { + t.Fatal(err) + } + defer allowedListener.Close() + go func() { + for { + conn, err := allowedListener.Accept() + if err != nil { + return + } + conn.Write([]byte("ALLOWED")) + conn.Close() + } + }() + + // Start chisel server with ACL: user can only reach allowedPort + s, err := chserver.NewServer(&chserver.Config{ + KeySeed: "acl-test-allowed", + }) + if err != nil { + t.Fatal(err) + } + s.Debug = debug + if err := s.AddUser("user", "pass", fmt.Sprintf(`^127\.0\.0\.1:%s$`, allowedPort)); err != nil { + t.Fatal(err) + } + serverPort := availablePort() + if err := s.Start("127.0.0.1", serverPort); err != nil { + t.Fatal(err) + } + defer s.Close() + + serverAddr := "127.0.0.1:" + serverPort + + // Connect and send config with the allowed remote + sc, _, _ := dialChiselSSH(t, serverAddr, "user", "pass") + defer sc.Close() + + r, err := settings.DecodeRemote(fmt.Sprintf("0.0.0.0:%s:127.0.0.1:%s", allowedPort, allowedPort)) + if err != nil { + t.Fatal(err) + } + sendConfig(t, sc, []*settings.Remote{r}) + + // Open channel to the allowed port — must succeed + target := net.JoinHostPort("127.0.0.1", allowedPort) + ch, reqs, err := sc.OpenChannel("chisel", []byte(target)) + if err != nil { + t.Fatalf("channel to allowed port %s was rejected: %v", allowedPort, err) + } + go ssh.DiscardRequests(reqs) + defer ch.Close() + + // Read data from the allowed target + buf := make([]byte, 64) + n, err := ch.Read(buf) + if err != nil && err != io.EOF { + t.Fatalf("read from allowed channel: %v", err) + } + if string(buf[:n]) != "ALLOWED" { + t.Fatalf("expected 'ALLOWED', got %q", buf[:n]) + } + t.Logf("channel to allowed port works correctly, received: %s", buf[:n]) +} + +// TestNoAuthChannel verifies that when no auth is configured, +// all destinations are reachable. +func TestNoAuthChannel(t *testing.T) { + targetPort := availablePort() + + // Start a TCP listener + listener, err := net.Listen("tcp", "127.0.0.1:"+targetPort) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + conn.Write([]byte("OPEN")) + conn.Close() + } + }() + + // Start chisel server with NO auth + s, err := chserver.NewServer(&chserver.Config{ + KeySeed: "no-acl-test", + }) + if err != nil { + t.Fatal(err) + } + s.Debug = debug + serverPort := availablePort() + if err := s.Start("127.0.0.1", serverPort); err != nil { + t.Fatal(err) + } + defer s.Close() + + serverAddr := "127.0.0.1:" + serverPort + + // Connect with any credentials (server accepts all when no auth configured) + sc, _, _ := dialChiselSSH(t, serverAddr, "anyone", "anything") + defer sc.Close() + + r, err := settings.DecodeRemote(fmt.Sprintf("0.0.0.0:%s:127.0.0.1:%s", targetPort, targetPort)) + if err != nil { + t.Fatal(err) + } + sendConfig(t, sc, []*settings.Remote{r}) + + // Open channel — should be accepted since no ACL + target := net.JoinHostPort("127.0.0.1", targetPort) + ch, creqs, err := sc.OpenChannel("chisel", []byte(target)) + if err != nil { + t.Fatalf("channel rejected when no ACL is configured: %v", err) + } + go ssh.DiscardRequests(creqs) + defer ch.Close() + + buf := make([]byte, 64) + n, err := ch.Read(buf) + if err != nil && err != io.EOF { + t.Fatalf("read: %v", err) + } + if string(buf[:n]) != "OPEN" { + t.Fatalf("expected 'OPEN', got %q", buf[:n]) + } + t.Logf("no-ACL mode works correctly") +} + +// TestAuthWildcardChannel verifies that a user with wildcard access +// can reach any destination. +func TestAuthWildcardChannel(t *testing.T) { + targetPort := availablePort() + + listener, err := net.Listen("tcp", "127.0.0.1:"+targetPort) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + conn.Write([]byte("WILDCARD")) + conn.Close() + } + }() + + s, err := chserver.NewServer(&chserver.Config{ + KeySeed: "acl-wildcard-test", + Auth: "admin:secret", + }) + if err != nil { + t.Fatal(err) + } + s.Debug = debug + serverPort := availablePort() + if err := s.Start("127.0.0.1", serverPort); err != nil { + t.Fatal(err) + } + defer s.Close() + + sc, _, _ := dialChiselSSH(t, "127.0.0.1:"+serverPort, "admin", "secret") + defer sc.Close() + + r, err := settings.DecodeRemote(fmt.Sprintf("0.0.0.0:%s:127.0.0.1:%s", targetPort, targetPort)) + if err != nil { + t.Fatal(err) + } + sendConfig(t, sc, []*settings.Remote{r}) + + target := net.JoinHostPort("127.0.0.1", targetPort) + ch, reqs, err := sc.OpenChannel("chisel", []byte(target)) + if err != nil { + t.Fatalf("wildcard user channel rejected: %v", err) + } + go ssh.DiscardRequests(reqs) + defer ch.Close() + + buf := make([]byte, 64) + n, err := ch.Read(buf) + if err != nil && err != io.EOF { + t.Fatalf("read: %v", err) + } + if string(buf[:n]) != "WILDCARD" { + t.Fatalf("expected 'WILDCARD', got %q", buf[:n]) + } + t.Logf("wildcard user correctly allowed") +}