mirror of
https://github.com/jpillora/chisel.git
synced 2026-10-12 02:16:57 +00:00
Enforce auth ACL on tunnel channels
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. Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4df5fcf525
commit
44310b6566
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user