mirror of
https://github.com/tailscale/tailscale.git
synced 2026-10-28 22:19:12 -04:00
Close the specific forwarded listener on connection cancellation and stop its callback when the accept loop exits. Canceling a forward no longer retains a connection-lifetime waiter or looks up a replacement listener by address. Test repeated cancel and rebind operations on one connection, along with callback cleanup when the listener closes independently. Make the existing connection-cancellation test fail if its listener stays open. Updates tailscale/corp#50380 Change-Id: Ife7fb0acde012b0abf1a8d804b9c6a8994ee5703 Signed-off-by: James Tucker <james@tailscale.com>
1313 lines
44 KiB
Go
1313 lines
44 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd
|
|
|
|
// Conn-level tests for the monitor-backed forwarding paths: unix -L,
|
|
// unix -R, TCP -R, and agent forwarding. The tested entry points are
|
|
// mayForwardLocalUnixTo, mayReverseUnixForwardTo, fwdTCPHandler, and
|
|
// handleSSHAgentForwarding. The forwarding-path tests drive a real conn
|
|
// with a real monitor and real one-shot fwd children through the
|
|
// self-exec seams in fwdchild_test.go. TestFwdAllowedSocketPathCaps and
|
|
// TestConnMonitorClosedRefusesRespawn exercise the lexical policy and
|
|
// the closed flag directly. Under root the conn binds to the non-root
|
|
// test user, so the cross-uid closures are pinned: socket ownership,
|
|
// permission-denied dials, and kernel privileged-port refusal. Non-root
|
|
// runs bind to the current user.
|
|
|
|
package tailssh
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/netip"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
gliderssh "github.com/tailscale/gliderssh"
|
|
gossh "golang.org/x/crypto/ssh"
|
|
"tailscale.com/envknob"
|
|
"tailscale.com/net/memnet"
|
|
"tailscale.com/tailcfg"
|
|
testssh "tailscale.com/tempfork/sshtest/ssh"
|
|
)
|
|
|
|
// fwdTestIdentity returns the local user the conn binds its monitor to.
|
|
// Under root it is the non-root test user, so sockets created by the
|
|
// forwarding children are owned by the session user. Otherwise it is the
|
|
// current user, where a non-root tailscaled binds the monitor to itself.
|
|
func fwdTestIdentity(t *testing.T) *userMeta {
|
|
t.Helper()
|
|
if os.Getuid() == 0 {
|
|
uid, gid := ensureFwdTestUser(t)
|
|
return &userMeta{User: user.User{
|
|
Username: fwdTestUser,
|
|
Uid: strconv.Itoa(uid),
|
|
Gid: strconv.Itoa(gid),
|
|
HomeDir: "/nonexistent", // no home needed. Tests use /tmp paths.
|
|
}}
|
|
}
|
|
u, err := user.Current()
|
|
if err != nil {
|
|
t.Fatalf("user.Current: %v", err)
|
|
}
|
|
return &userMeta{User: *u}
|
|
}
|
|
|
|
// newFwdTestConn returns a conn whose forwarding paths go through a real
|
|
// monitor (spawned lazily on first use), bound to lu's identity, with the
|
|
// given final action. It registers the monitor self-exec seams and closes
|
|
// the monitor on cleanup.
|
|
func newFwdTestConn(t *testing.T, lu *userMeta, final *tailcfg.SSHAction) *conn {
|
|
t.Helper()
|
|
exe := makeExecutableReachable(t)
|
|
// The override must reach the monitor process, which spawns the fwd
|
|
// children. It therefore travels through the environment.
|
|
t.Setenv("TS_TEST_SSH_FWD_PREFIX", strings.Join(testFwdPrefix, " "))
|
|
old := monitorSpawnPrefix
|
|
monitorSpawnPrefix = testMonitorPrefix
|
|
t.Cleanup(func() { monitorSpawnPrefix = old })
|
|
|
|
groups := []string{}
|
|
if g, err := os.Getgroups(); err == nil {
|
|
for _, v := range g {
|
|
groups = append(groups, strconv.Itoa(v))
|
|
}
|
|
} else {
|
|
t.Fatalf("os.Getgroups: %v", err)
|
|
}
|
|
// Install a matching policy and authenticated state for forwarding rechecks.
|
|
rule := &tailcfg.SSHRule{
|
|
SSHUsers: map[string]string{lu.Username: lu.Username},
|
|
Action: final,
|
|
Principals: []*tailcfg.SSHPrincipal{{Any: true}},
|
|
}
|
|
c := &conn{
|
|
// t.Logf so bind/spawn failures are visible in test output.
|
|
// tailscaledPath is the reachable copy of the test binary: the
|
|
// monitor exec'd from it spawns the fwd children from the same
|
|
// path, and under a root run the bound user can exec the copy
|
|
// but not the original.
|
|
srv: &server{logf: t.Logf, tailscaledPath: exe, lb: &localState{
|
|
sshEnabled: true,
|
|
matchingRule: rule,
|
|
}},
|
|
localUser: lu,
|
|
mappedLocalUser: lu.Username,
|
|
userGroupIDs: groups,
|
|
action0: final,
|
|
finalAction: final,
|
|
info: &sshConnInfo{
|
|
sshUser: lu.Username,
|
|
src: netip.MustParseAddrPort("100.100.100.101:2231"),
|
|
dst: netip.MustParseAddrPort("100.100.100.102:22"),
|
|
},
|
|
}
|
|
c.authCompleted.Store(true)
|
|
t.Cleanup(c.closeConnMonitor)
|
|
return c
|
|
}
|
|
|
|
// fwdTestCtx returns a gliderssh.Context for driving the conn callbacks and
|
|
// handlers directly, whose ContextKeyConn value is sshConn and which is
|
|
// canceled by the returned cancel func. Every other Value lookup returns
|
|
// true, matching process_unix_test.go's agentFwdContext.
|
|
func fwdTestCtx(sshConn gossh.Conn) (gliderssh.Context, context.CancelFunc) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
return fwdTestContext{Context: ctx, Mutex: &sync.Mutex{}, conn: sshConn}, cancel
|
|
}
|
|
|
|
// fwdTestContext implements gliderssh.Context. Value serves ContextKeyConn
|
|
// and returns true otherwise. Everything else is inert. The embedded mutex
|
|
// satisfies the interface's Locker requirement, like process_unix_test.go's
|
|
// agentFwdContext.
|
|
type fwdTestContext struct {
|
|
context.Context
|
|
*sync.Mutex
|
|
conn gossh.Conn
|
|
}
|
|
|
|
func (c fwdTestContext) Value(key any) any {
|
|
if key == gliderssh.ContextKeyConn {
|
|
return c.conn
|
|
}
|
|
return true
|
|
}
|
|
func (fwdTestContext) User() string { return "test" }
|
|
func (fwdTestContext) SessionID() string { return "test" }
|
|
func (fwdTestContext) ClientVersion() string { return "2.0" }
|
|
func (fwdTestContext) ServerVersion() string { return "2.0" }
|
|
func (fwdTestContext) RemoteAddr() net.Addr { return nil }
|
|
func (fwdTestContext) LocalAddr() net.Addr { return nil }
|
|
func (fwdTestContext) Permissions() *gliderssh.Permissions { return nil }
|
|
func (fwdTestContext) SetValue(key, value any) {}
|
|
|
|
// fwdTestSSHConn is a gossh.ServerConn stub for the TCP -R handler.
|
|
// OpenChannel returns an echo channel. What the client writes through the
|
|
// forwarded TCP connection comes back, so bicopy is exercised end to end.
|
|
type fwdTestSSHConn struct{ gossh.Conn }
|
|
|
|
// OpenChannel returns an echo channel so the accept loop and bicopy are
|
|
// exercised end to end. The handler type-asserts the ContextKeyConn value
|
|
// to gossh.Conn. A real server hands it a *gossh.ServerConn.
|
|
func (fwdTestSSHConn) OpenChannel(name string, data []byte) (gossh.Channel, <-chan *gossh.Request, error) {
|
|
if name != "forwarded-tcpip" {
|
|
return nil, nil, fmt.Errorf("unexpected channel type %q", name)
|
|
}
|
|
return newEchoChannel(), nil, nil
|
|
}
|
|
|
|
// echoChannel is a gossh.Channel that echoes its Write payload back out of
|
|
// Read. bicopy(ch, nc) then makes the TCP peer see its own writes.
|
|
type echoChannel struct {
|
|
buf chan []byte
|
|
closed chan struct{}
|
|
closeMu sync.Mutex
|
|
}
|
|
|
|
func newEchoChannel() *echoChannel {
|
|
return &echoChannel{buf: make(chan []byte, 16), closed: make(chan struct{})}
|
|
}
|
|
|
|
func (e *echoChannel) Read(p []byte) (int, error) {
|
|
select {
|
|
case b := <-e.buf:
|
|
return copy(p, b), nil
|
|
case <-e.closed:
|
|
return 0, io.EOF
|
|
}
|
|
}
|
|
|
|
func (e *echoChannel) Write(p []byte) (int, error) {
|
|
select {
|
|
case e.buf <- bytes.Clone(p):
|
|
return len(p), nil
|
|
case <-e.closed:
|
|
return 0, errors.New("channel closed")
|
|
}
|
|
}
|
|
|
|
func (e *echoChannel) Close() error {
|
|
e.closeMu.Lock()
|
|
defer e.closeMu.Unlock()
|
|
select {
|
|
case <-e.closed:
|
|
default:
|
|
close(e.closed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *echoChannel) CloseWrite() error { return nil }
|
|
func (e *echoChannel) SendRequest(string, bool, []byte) (bool, error) {
|
|
return false, nil
|
|
}
|
|
func (e *echoChannel) Stderr() io.ReadWriter { return e }
|
|
|
|
// fwdTmpDir creates a directory under /tmp, inside the socket-path
|
|
// allowlist, that the monitor's bound identity can use. Root runs create
|
|
// it as root and chmod it 0777 for the non-root bound user.
|
|
func fwdTmpDir(t *testing.T) string {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp("/tmp", "tailssh-fwd-test-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(dir, 0o777); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
|
return dir
|
|
}
|
|
|
|
// statOwnerMode returns the owner uid and permission bits of path.
|
|
func statOwnerMode(t *testing.T, path string) (int, os.FileMode) {
|
|
t.Helper()
|
|
fi, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, ok := fi.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
t.Fatalf("stat %q: unsupported syscall.Stat_t type %T", path, fi.Sys())
|
|
}
|
|
return int(st.Uid), fi.Mode().Perm()
|
|
}
|
|
|
|
// setDeadlineConn gives every blocking read/write in these tests a
|
|
// deadline so nothing can wedge the suite.
|
|
func setDeadlineConn(t *testing.T, c net.Conn) {
|
|
t.Helper()
|
|
dl := time.Now().Add(10 * time.Second)
|
|
if err := c.SetDeadline(dl); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// TestFwdConnLocalUnixDial drives mayForwardLocalUnixTo (unix -L) through
|
|
// a real monitor: a dial to an accessible socket in /tmp returns a live
|
|
// connection dialed as the bound identity, and paths outside the
|
|
// allowlist (or relative/abstract) are rejected.
|
|
func TestFwdConnLocalUnixDial(t *testing.T) {
|
|
lu := fwdTestIdentity(t)
|
|
c := newFwdTestConn(t, lu, &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowLocalPortForwarding: true,
|
|
})
|
|
|
|
socketPath := filepath.Join(fwdTmpDir(t), "service.sock")
|
|
ln, err := net.Listen("unix", socketPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { ln.Close() })
|
|
// The test process owns the socket and may be root while the conn
|
|
// binds to the non-root test user, so make it connectable by the
|
|
// bound identity. TestFwdDialRoundTrip and TestFwdDialCrossUser pin
|
|
// the permission-denied direction; here we assert the conn-level path
|
|
// returns a live conn.
|
|
if err := os.Chmod(socketPath, 0o666); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
go func() {
|
|
for {
|
|
sc, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func() {
|
|
defer sc.Close()
|
|
io.Copy(sc, sc)
|
|
}()
|
|
}
|
|
}()
|
|
|
|
ctx, _ := fwdTestCtx(nil)
|
|
uc, err := c.mayForwardLocalUnixTo(ctx, socketPath)
|
|
if err != nil {
|
|
t.Fatalf("mayForwardLocalUnixTo(%q): %v", socketPath, err)
|
|
}
|
|
defer uc.Close()
|
|
setDeadlineConn(t, uc)
|
|
|
|
want := "GET /_ping HTTP/1.1\r\n\r\n"
|
|
if _, err := uc.Write([]byte(want)); err != nil {
|
|
t.Fatalf("write through forwarded conn: %v", err)
|
|
}
|
|
got := make([]byte, len(want))
|
|
if _, err := io.ReadFull(uc, got); err != nil {
|
|
t.Fatalf("read through forwarded conn: %v", err)
|
|
}
|
|
if string(got) != want {
|
|
t.Errorf("echo = %q; want %q", got, want)
|
|
}
|
|
|
|
// Paths outside the allowed directories are rejected.
|
|
for _, bad := range []string{"/var/run/docker.sock", "/etc/evil.sock"} {
|
|
_, err := c.mayForwardLocalUnixTo(ctx, bad)
|
|
if err == nil {
|
|
t.Errorf("dial %q: unexpectedly allowed", bad)
|
|
continue
|
|
}
|
|
if !errors.Is(err, gliderssh.ErrRejected) || !strings.Contains(err.Error(), "not in an allowed directory") {
|
|
t.Errorf("dial %q error = %v; want an ErrRejected \"not in an allowed directory\"", bad, err)
|
|
}
|
|
}
|
|
// Relative and abstract paths are rejected.
|
|
for _, bad := range []string{"relative.sock", "@/tmp/abstract.sock"} {
|
|
_, err := c.mayForwardLocalUnixTo(ctx, bad)
|
|
if !errors.Is(err, gliderssh.ErrRejected) {
|
|
t.Errorf("dial %q error = %v; want ErrRejected", bad, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFwdConnReverseUnixListen drives mayReverseUnixForwardTo (unix -R):
|
|
// the returned listener's socket is 0600 and owned by the bound identity.
|
|
// Data flows through it, and a foreign-owned live socket in a sticky
|
|
// directory is not unlinked in the root run.
|
|
func TestFwdConnReverseUnixListen(t *testing.T) {
|
|
lu := fwdTestIdentity(t)
|
|
uid, _ := strconv.Atoi(lu.Uid)
|
|
c := newFwdTestConn(t, lu, &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowLocalPortForwarding: true, // for the dial-side half of the round trip
|
|
AllowRemotePortForwarding: true,
|
|
})
|
|
|
|
socketPath := filepath.Join(fwdTmpDir(t), "reverse.sock")
|
|
ctx, _ := fwdTestCtx(nil)
|
|
ln, err := c.mayReverseUnixForwardTo(ctx, socketPath)
|
|
if err != nil {
|
|
t.Fatalf("mayReverseUnixForwardTo(%q): %v", socketPath, err)
|
|
}
|
|
t.Cleanup(func() { ln.Close() })
|
|
|
|
// Socket created by the monitor's child: 0600, owned by the bound
|
|
// identity (the non-root test user under root runs).
|
|
gotUid, mode := statOwnerMode(t, socketPath)
|
|
if mode != 0o600 {
|
|
t.Errorf("socket mode = %o; want 0600", mode)
|
|
}
|
|
if gotUid != uid {
|
|
t.Errorf("socket uid = %d; want %d (the session user)", gotUid, uid)
|
|
}
|
|
|
|
// Round trip: accept on the reverse listener, dial it through the
|
|
// conn's own -L path, which is self-consistent because the same user
|
|
// can reach its own 0600 socket.
|
|
go func() {
|
|
sc, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer sc.Close()
|
|
io.Copy(sc, sc)
|
|
}()
|
|
uc, err := c.mayForwardLocalUnixTo(ctx, socketPath)
|
|
if err != nil {
|
|
t.Fatalf("dial reverse socket through -L path: %v", err)
|
|
}
|
|
defer uc.Close()
|
|
setDeadlineConn(t, uc)
|
|
want := "hello reverse"
|
|
if _, err := uc.Write([]byte(want)); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
got := make([]byte, len(want))
|
|
if _, err := io.ReadFull(uc, got); err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if string(got) != want {
|
|
t.Errorf("echo = %q; want %q", got, want)
|
|
}
|
|
|
|
// Root run: a foreign root-owned live socket in sticky /tmp is not
|
|
// unlinked by a bind from the non-root session user. The bind fails
|
|
// EADDRINUSE and the original socket still accepts. Subtest so the
|
|
// non-root round trip above passes on its own and this guard's skip
|
|
// cannot erase it.
|
|
t.Run("foreign-socket-not-unlinked", func(t *testing.T) {
|
|
if os.Getuid() != 0 {
|
|
t.Skipf("non-root run: bound identity is the current user, so the foreign-socket case is same-uid; TestFwdListenUnlinkIfOwned pins it at monitor level")
|
|
}
|
|
foreignPath := filepath.Join(fwdTmpDir(t), "foreign.sock")
|
|
foreignLn, err := net.Listen("unix", foreignPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { foreignLn.Close() })
|
|
go func() {
|
|
for {
|
|
sc, err := foreignLn.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
sc.Close()
|
|
}
|
|
}()
|
|
if _, err := c.mayReverseUnixForwardTo(ctx, foreignPath); err == nil {
|
|
t.Fatal("bind over foreign-owned socket: unexpectedly allowed")
|
|
} else if !strings.Contains(err.Error(), "address already in use") {
|
|
t.Errorf("bind over foreign socket error = %v; want EADDRINUSE", err)
|
|
}
|
|
// The foreign socket was not unlinked and still accepts.
|
|
if _, _, err := statOwnerModeOK(foreignPath); err != nil {
|
|
t.Fatalf("foreign socket unlinked by refused bind: %v", err)
|
|
}
|
|
if _, err := net.Dial("unix", foreignPath); err != nil {
|
|
t.Errorf("foreign socket no longer accepts: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func statOwnerModeOK(path string) (int, os.FileMode, error) {
|
|
fi, err := os.Stat(path)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
st, ok := fi.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
return 0, 0, fmt.Errorf("unsupported Stat_t %T", fi.Sys())
|
|
}
|
|
return int(st.Uid), fi.Mode().Perm(), nil
|
|
}
|
|
|
|
// TestFwdTCPHandler drives the tailssh-owned tcpip-forward handler: a
|
|
// loopback bind goes through the monitor's user-cred listener child and
|
|
// accepts with data echoing through the SSH channel stub. Non-loopback
|
|
// binds are rejected unless TS_SSH_FORWARD_GATEWAY_PORTS is set, and a
|
|
// privileged port is refused for a non-root bound identity.
|
|
func TestFwdTCPHandler(t *testing.T) {
|
|
lu := fwdTestIdentity(t)
|
|
c := newFwdTestConn(t, lu, &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowRemotePortForwarding: true,
|
|
})
|
|
|
|
ctx, cancel := fwdTestCtx(fwdTestSSHConn{})
|
|
t.Cleanup(cancel)
|
|
h := &fwdTCPHandler{conn: c}
|
|
tcpReq := func(addr string, port uint32) *gossh.Request {
|
|
return &gossh.Request{
|
|
Type: "tcpip-forward",
|
|
WantReply: true,
|
|
Payload: gossh.Marshal(fwdRemoteForwardRequest{BindAddr: addr, BindPort: port}),
|
|
}
|
|
}
|
|
|
|
// 127.0.0.1:0 binds via the monitor's child and accepts. Data written
|
|
// by a local dialer echoes back through the SSH channel stub.
|
|
ok, payload := h.HandleSSHRequest(ctx, nil, tcpReq("127.0.0.1", 0))
|
|
if !ok {
|
|
t.Fatalf("tcpip-forward 127.0.0.1:0 rejected: %s", payload)
|
|
}
|
|
var succ fwdRemoteForwardSuccess
|
|
if err := gossh.Unmarshal(payload, &succ); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if succ.BindPort == 0 {
|
|
t.Fatal("reply carried no concrete port")
|
|
}
|
|
fwdAddr := net.JoinHostPort("127.0.0.1", strconv.Itoa(int(succ.BindPort)))
|
|
tc, err := net.DialTimeout("tcp", fwdAddr, 5*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("dial forwarded listener %s: %v", fwdAddr, err)
|
|
}
|
|
defer tc.Close()
|
|
setDeadlineConn(t, tc)
|
|
want := "ping through reverse tcp forward"
|
|
if _, err := tc.Write([]byte(want)); err != nil {
|
|
t.Fatalf("write to forwarded listener: %v", err)
|
|
}
|
|
got := make([]byte, len(want))
|
|
if _, err := io.ReadFull(tc, got); err != nil {
|
|
t.Fatalf("read from forwarded listener: %v", err)
|
|
}
|
|
if string(got) != want {
|
|
t.Errorf("echo = %q; want %q", got, want)
|
|
}
|
|
|
|
// Connection cancellation must close the forwarded listener.
|
|
cancel()
|
|
eventually(t, 5*time.Second, "forwarded listener remained open after cancellation", func() bool {
|
|
conn, err := net.DialTimeout("tcp", fwdAddr, time.Second)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
conn.Close()
|
|
return false
|
|
})
|
|
|
|
// Non-loopback binds are rejected without the gateway-ports knob…
|
|
ctx2, cancel2 := fwdTestCtx(fwdTestSSHConn{})
|
|
t.Cleanup(cancel2)
|
|
if ok, msg := h.HandleSSHRequest(ctx2, nil, tcpReq("0.0.0.0", 0)); ok {
|
|
t.Fatal("tcpip-forward 0.0.0.0:0 allowed without TS_SSH_FORWARD_GATEWAY_PORTS")
|
|
} else if !bytes.Contains(msg, []byte("non-loopback")) {
|
|
t.Errorf("rejection message = %q; want non-loopback notice", msg)
|
|
}
|
|
// …and allowed with it. The knob widens address policy only, and the
|
|
// bind still runs with the user's credentials.
|
|
envknob.SetenvForTest(t, "TS_SSH_FORWARD_GATEWAY_PORTS", "1")
|
|
if ok, msg := h.HandleSSHRequest(ctx2, nil, tcpReq("0.0.0.0", 0)); !ok {
|
|
t.Fatalf("tcpip-forward 0.0.0.0:0 rejected with TS_SSH_FORWARD_GATEWAY_PORTS: %s", msg)
|
|
}
|
|
cancel2()
|
|
|
|
// Privileged ports fail at the kernel for the non-root bound
|
|
// identity, matching OpenSSH's rule that only root binds privileged
|
|
// ports. The port-80 refusal above pins that the bind runs as the
|
|
// bound user. Only where the kernel enforces the restriction:
|
|
// privileged CI containers often set ip_unprivileged_port_start to
|
|
// 0, which lets any uid bind any port, and darwin does not restrict
|
|
// the range at all.
|
|
if unprivilegedPrivilegedBinds() {
|
|
t.Log("skipping the privileged-port refusal: the kernel allows unprivileged privileged binds")
|
|
} else {
|
|
ctx3, cancel3 := fwdTestCtx(fwdTestSSHConn{})
|
|
t.Cleanup(cancel3)
|
|
if ok, msg := h.HandleSSHRequest(ctx3, nil, tcpReq("127.0.0.1", 80)); ok {
|
|
t.Fatalf("tcpip-forward 127.0.0.1:80 allowed for non-root bound identity %q", lu.Username)
|
|
} else {
|
|
t.Logf("privileged bind rejected: %s", msg)
|
|
}
|
|
cancel3()
|
|
}
|
|
}
|
|
|
|
// TestFwdTCPHandlerPolicyGate pins the handler's policy gates: the final
|
|
// action's AllowRemotePortForwarding and TS_SSH_DISABLE_FORWARDING.
|
|
func TestFwdTCPHandlerPolicyGate(t *testing.T) {
|
|
lu := fwdTestIdentity(t)
|
|
denied := newFwdTestConn(t, lu, &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowRemotePortForwarding: false,
|
|
})
|
|
ctx, cancel := fwdTestCtx(fwdTestSSHConn{})
|
|
t.Cleanup(cancel)
|
|
h := &fwdTCPHandler{conn: denied}
|
|
req := &gossh.Request{
|
|
Type: "tcpip-forward",
|
|
WantReply: true,
|
|
Payload: gossh.Marshal(fwdRemoteForwardRequest{BindAddr: "127.0.0.1", BindPort: 0}),
|
|
}
|
|
if ok, msg := h.HandleSSHRequest(ctx, nil, req); ok {
|
|
t.Fatal("tcpip-forward allowed without AllowRemotePortForwarding")
|
|
} else if !bytes.Contains(msg, []byte("port forwarding is disabled")) {
|
|
t.Errorf("rejection message = %q", msg)
|
|
}
|
|
|
|
envknob.SetenvForTest(t, "TS_SSH_DISABLE_FORWARDING", "1")
|
|
if ok, msg := h.HandleSSHRequest(ctx, nil, req); ok {
|
|
t.Fatal("tcpip-forward allowed with TS_SSH_DISABLE_FORWARDING")
|
|
} else if !bytes.Contains(msg, []byte("port forwarding is disabled")) {
|
|
t.Errorf("rejection message = %q", msg)
|
|
}
|
|
cancel()
|
|
}
|
|
|
|
// TestFwdDisableForwarding pins that TS_SSH_DISABLE_FORWARDING rejects all
|
|
// four forwarding paths.
|
|
func TestFwdDisableForwarding(t *testing.T) {
|
|
envknob.SetenvForTest(t, "TS_SSH_DISABLE_FORWARDING", "1")
|
|
lu := fwdTestIdentity(t)
|
|
c := newFwdTestConn(t, lu, &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowLocalPortForwarding: true,
|
|
AllowRemotePortForwarding: true,
|
|
AllowAgentForwarding: true,
|
|
})
|
|
ctx, cancel := fwdTestCtx(fwdTestSSHConn{})
|
|
t.Cleanup(cancel)
|
|
|
|
if _, err := c.mayForwardLocalUnixTo(ctx, "/tmp/anything.sock"); !errors.Is(err, gliderssh.ErrRejected) {
|
|
t.Errorf("unix -L error = %v; want ErrRejected", err)
|
|
}
|
|
if _, err := c.mayReverseUnixForwardTo(ctx, "/tmp/anything.sock"); !errors.Is(err, gliderssh.ErrRejected) {
|
|
t.Errorf("unix -R error = %v; want ErrRejected", err)
|
|
}
|
|
h := &fwdTCPHandler{conn: c}
|
|
req := &gossh.Request{
|
|
Type: "tcpip-forward",
|
|
WantReply: true,
|
|
Payload: gossh.Marshal(fwdRemoteForwardRequest{BindAddr: "127.0.0.1", BindPort: 0}),
|
|
}
|
|
if ok, msg := h.HandleSSHRequest(ctx, nil, req); ok {
|
|
t.Error("TCP -R allowed with TS_SSH_DISABLE_FORWARDING")
|
|
} else if !bytes.Contains(msg, []byte("port forwarding is disabled")) {
|
|
t.Errorf("TCP -R rejection message = %q", msg)
|
|
}
|
|
// Agent forwarding silently does not start, as before.
|
|
ss, s := newAgentTestSession(t, c)
|
|
if err := ss.handleSSHAgentForwarding(s, lu); err != nil {
|
|
t.Errorf("handleSSHAgentForwarding: %v", err)
|
|
}
|
|
if ss.agentListener != nil {
|
|
t.Error("agent listener started with TS_SSH_DISABLE_FORWARDING")
|
|
}
|
|
cancel()
|
|
}
|
|
|
|
// TestFwdFullStackLocalUnix is the full-stack -L test. A real SSH
|
|
// connection from an sshtest client over memnet reaches a real tailssh
|
|
// server with a real policy, with SSHUsers mapping alice to the bound
|
|
// user. The direct-streamlocal dial goes through the conn's lazily
|
|
// spawned real monitor and its one-shot fwd child. The service reads a
|
|
// fixed-length payload under a deadline, echoes it, and on linux reports
|
|
// the dialer's peer uid, which must be the monitor's bound uid.
|
|
func TestFwdFullStackLocalUnix(t *testing.T) {
|
|
lu := fwdTestIdentity(t)
|
|
boundUID, err := strconv.Atoi(lu.Uid)
|
|
if err != nil {
|
|
t.Fatalf("parse bound uid %q: %v", lu.Uid, err)
|
|
}
|
|
|
|
// The monitor seam overrides must be in place before the conn lazily
|
|
// spawns its monitor on the -L dial.
|
|
exe := makeExecutableReachable(t)
|
|
t.Setenv("TS_TEST_SSH_FWD_PREFIX", strings.Join(testFwdPrefix, " "))
|
|
old := monitorSpawnPrefix
|
|
monitorSpawnPrefix = testMonitorPrefix
|
|
t.Cleanup(func() { monitorSpawnPrefix = old })
|
|
|
|
// The service socket is in the allowlist's /tmp and is created by the
|
|
// test process, which may be root while the conn binds to the
|
|
// non-root test user, so it must be reachable by the bound identity.
|
|
socketPath := filepath.Join(fwdTmpDir(t), "svc.sock")
|
|
ln, err := net.Listen("unix", socketPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { ln.Close() })
|
|
if err := os.Chmod(socketPath, 0o666); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
payload := []byte("GET /_ping HTTP/1.1\r\n\r\n") // fixed, known length
|
|
type serviceResult struct {
|
|
peerUID int
|
|
err error
|
|
}
|
|
svc := make(chan serviceResult, 1)
|
|
go func() {
|
|
sc, err := ln.Accept()
|
|
if err != nil {
|
|
svc <- serviceResult{err: err}
|
|
return
|
|
}
|
|
defer sc.Close()
|
|
if err := sc.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
|
svc <- serviceResult{err: err}
|
|
return
|
|
}
|
|
got := make([]byte, len(payload))
|
|
if _, err := io.ReadFull(sc, got); err != nil {
|
|
svc <- serviceResult{err: fmt.Errorf("service read: %w", err)}
|
|
return
|
|
}
|
|
if !bytes.Equal(got, payload) {
|
|
svc <- serviceResult{err: fmt.Errorf("service got %q, want %q", got, payload)}
|
|
return
|
|
}
|
|
// The dialer is the monitor's fwd child, so its credentials are
|
|
// the conn's bound identity. peerCredUID is linux-only.
|
|
peerUID := -1
|
|
if runtime.GOOS == "linux" {
|
|
peerUID, err = peerCredUID(sc)
|
|
if err != nil {
|
|
svc <- serviceResult{err: fmt.Errorf("service peer cred: %w", err)}
|
|
return
|
|
}
|
|
}
|
|
if _, err := sc.Write(payload); err != nil {
|
|
svc <- serviceResult{err: fmt.Errorf("service echo: %w", err)}
|
|
return
|
|
}
|
|
svc <- serviceResult{peerUID: peerUID}
|
|
}()
|
|
|
|
// The server runs a real auth flow with alice mapped to the bound
|
|
// local user and -L forwarding allowed.
|
|
rule := &tailcfg.SSHRule{
|
|
SSHUsers: map[string]string{"alice": lu.Username},
|
|
Action: &tailcfg.SSHAction{Accept: true, AllowLocalPortForwarding: true},
|
|
Principals: []*tailcfg.SSHPrincipal{
|
|
{Any: true},
|
|
},
|
|
}
|
|
s := &server{
|
|
logf: t.Logf,
|
|
tailscaledPath: exe, // the reachable copy; see makeExecutableReachable
|
|
lb: &localState{
|
|
sshEnabled: true,
|
|
varRoot: t.TempDir(),
|
|
matchingRule: rule,
|
|
},
|
|
}
|
|
|
|
src, dst := netip.MustParseAddrPort("100.100.100.101:2231"), netip.MustParseAddrPort("100.100.100.102:22")
|
|
clientConn, serverConn := memnet.NewTCPConn(src, dst, 1024)
|
|
// Deadline on the client side of the memnet transport, so nothing can
|
|
// wedge the test waiting on a transport that stopped making progress.
|
|
if err := clientConn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Go(func() {
|
|
cl, chans, reqs, err := testssh.NewClientConn(clientConn, clientConn.RemoteAddr().String(), &testssh.ClientConfig{
|
|
User: "alice",
|
|
HostKeyCallback: testssh.InsecureIgnoreHostKey(),
|
|
Timeout: 15 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Errorf("client handshake: %v", err)
|
|
return
|
|
}
|
|
client := testssh.NewClient(cl, chans, reqs)
|
|
defer client.Close()
|
|
|
|
uc, err := client.Dial("unix", socketPath)
|
|
if err != nil {
|
|
t.Errorf("client -L dial %q: %v", socketPath, err)
|
|
return
|
|
}
|
|
defer uc.Close()
|
|
// No deadline on the channel-backed conn because x/crypto channels
|
|
// do not support deadlines. The memnet transport deadline above
|
|
// and the fixed-length reads bound this side instead.
|
|
if _, err := uc.Write(payload); err != nil {
|
|
t.Errorf("write through forwarded conn: %v", err)
|
|
return
|
|
}
|
|
got := make([]byte, len(payload))
|
|
if _, err := io.ReadFull(uc, got); err != nil {
|
|
t.Errorf("read through forwarded conn: %v", err)
|
|
return
|
|
}
|
|
if !bytes.Equal(got, payload) {
|
|
t.Errorf("echo = %q; want %q", got, payload)
|
|
}
|
|
})
|
|
if err := s.HandleSSHConn(serverConn); err != nil {
|
|
t.Errorf("HandleSSHConn: %v", err)
|
|
}
|
|
wg.Wait()
|
|
|
|
select {
|
|
case r := <-svc:
|
|
if r.err != nil {
|
|
t.Fatalf("service: %v", r.err)
|
|
}
|
|
if runtime.GOOS == "linux" && r.peerUID != boundUID {
|
|
t.Errorf("service observed peer uid %d; want %d (the conn's bound identity, not root tailscaled)", r.peerUID, boundUID)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("service never saw the forwarded connection")
|
|
}
|
|
}
|
|
|
|
// fwdTestCtxTimeout is fwdTestCtx with a deadline. The race test's dial
|
|
// attempts each carry a short budget so nothing can wedge the loop.
|
|
func fwdTestCtxTimeout(timeout time.Duration) (gliderssh.Context, context.CancelFunc) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
return fwdTestContext{Context: ctx, Mutex: &sync.Mutex{}, conn: nil}, cancel
|
|
}
|
|
|
|
// canaryListener is a marker-emitting canary unix socket for the
|
|
// root-dial-TOCTOU regression tests. Every accepted connection is served
|
|
// exactly one marker write and closed. markers is buffered so the accept
|
|
// loop never blocks. The test drains it to assert which side ever got
|
|
// served.
|
|
type canaryListener struct {
|
|
ln *net.UnixListener
|
|
markers chan string
|
|
done chan struct{} // closed when the accept loop exits
|
|
}
|
|
|
|
// listenCanary binds the canary socket at path with a umask-at-bind mode
|
|
// and starts its marker-emitting accept loop. See canaryListener for the
|
|
// markers channel semantics. The mode 0o600 comes from umask 0o177 and
|
|
// 0o666 from umask 0o111, so the socket is created directly at the
|
|
// requested mode.
|
|
func listenCanary(t *testing.T, path string, mode int, marker string) *canaryListener {
|
|
t.Helper()
|
|
old := syscall.Umask(0o777 &^ mode)
|
|
ln, err := net.Listen("unix", path)
|
|
syscall.Umask(old)
|
|
if err != nil {
|
|
t.Fatalf("listen canary %q: %v", path, err)
|
|
}
|
|
c := &canaryListener{
|
|
ln: ln.(*net.UnixListener),
|
|
markers: make(chan string, 64),
|
|
done: make(chan struct{}),
|
|
}
|
|
go func() {
|
|
defer close(c.done)
|
|
for {
|
|
sc, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
sc.(*net.UnixConn).SetDeadline(time.Now().Add(5 * time.Second))
|
|
sc.Write([]byte(marker + "\n"))
|
|
sc.Close()
|
|
select {
|
|
case c.markers <- marker:
|
|
default:
|
|
}
|
|
}
|
|
}()
|
|
return c
|
|
}
|
|
|
|
// close stops the canary's accept loop and closes the socket.
|
|
// Closing the listener also unlinks the socket file.
|
|
func (c *canaryListener) close() {
|
|
c.ln.Close()
|
|
<-c.done
|
|
}
|
|
|
|
// TestFwdConnLocalUnixSymlinkToProtectedSocket pins the static-symlink
|
|
// variant of the root-dial race: a symlink inside an allowed directory
|
|
// may resolve anywhere, and the dial runs with the bound user's own
|
|
// permissions.
|
|
//
|
|
// The reachable half runs everywhere: a symlink inside the allowed /tmp
|
|
// dir to a socket the bound identity can open is served. The protected
|
|
// half is root-only: a root-owned 0600 canary outside the allowed
|
|
// directories, behind an in-allowlist symlink, must fail permission
|
|
// denied for the bound user. TestFwdDialCrossUser pins the same check
|
|
// at monitor level.
|
|
func TestFwdConnLocalUnixSymlinkToProtectedSocket(t *testing.T) {
|
|
lu := fwdTestIdentity(t)
|
|
c := newFwdTestConn(t, lu, &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowLocalPortForwarding: true,
|
|
})
|
|
|
|
dir := fwdTmpDir(t)
|
|
ctx, cancel := fwdTestCtx(nil)
|
|
t.Cleanup(cancel)
|
|
|
|
// Reachable half, in every run. The allowlist does not resolve
|
|
// symlinks, so the user's own connect permission is the boundary.
|
|
reachable := filepath.Join(dir, "reachable.sock")
|
|
rl, err := net.Listen("unix", reachable)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { rl.Close() })
|
|
if err := os.Chmod(reachable, 0o666); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
go func() {
|
|
for {
|
|
sc, err := rl.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func() {
|
|
defer sc.Close()
|
|
sc.SetDeadline(time.Now().Add(10 * time.Second))
|
|
io.Copy(sc, sc)
|
|
}()
|
|
}
|
|
}()
|
|
okLink := filepath.Join(dir, "ok-link.sock")
|
|
if err := os.Symlink(reachable, okLink); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
uc, err := c.mayForwardLocalUnixTo(ctx, okLink)
|
|
if err != nil {
|
|
t.Fatalf("dial through in-allowlist symlink to reachable socket: %v", err)
|
|
}
|
|
setDeadlineConn(t, uc)
|
|
want := "symlink-dac"
|
|
if _, err := uc.Write([]byte(want)); err != nil {
|
|
t.Fatalf("write through symlinked forward: %v", err)
|
|
}
|
|
got := make([]byte, len(want))
|
|
if _, err := io.ReadFull(uc, got); err != nil {
|
|
t.Fatalf("read through symlinked forward: %v", err)
|
|
}
|
|
if string(got) != want {
|
|
t.Errorf("echo through symlinked forward = %q, want %q", got, want)
|
|
}
|
|
uc.Close()
|
|
|
|
// Protected half (root runs only): a root-owned 0600 canary outside
|
|
// the allowed directories, behind an in-allowlist symlink. Subtest so
|
|
// the reachable half above passes on its own and this guard's skip
|
|
// cannot erase it.
|
|
t.Run("protected-socket-dac", func(t *testing.T) {
|
|
if os.Getuid() != 0 {
|
|
t.Skipf("protected half is root-only: creating a socket protected against the bound user %q requires another uid; a root run of the suite exercises it (monitor-level DAC pin: TestFwdDialCrossUser)", lu.Username)
|
|
}
|
|
escape := filepath.Join("/var/run", fmt.Sprintf("tailssh-fwd-symlink-%d", os.Getpid()))
|
|
if err := os.MkdirAll(escape, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(escape) })
|
|
protected := filepath.Join(escape, "canary.sock")
|
|
canary := listenCanary(t, protected, 0o600, "PROTECTED")
|
|
t.Cleanup(canary.close)
|
|
evilLink := filepath.Join(dir, "evil-link.sock")
|
|
if err := os.Symlink(protected, evilLink); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Every dial attempt through the symlink must fail with the kernel's
|
|
// EACCES as the bound user. None may reach the canary.
|
|
for i := 0; i < 5; i++ {
|
|
uc, err := c.mayForwardLocalUnixTo(ctx, evilLink)
|
|
if err == nil {
|
|
uc.Close()
|
|
t.Fatalf("root-dial regression: dial through in-allowlist symlink to root-owned 0600 socket SUCCEEDED as bound user %q", lu.Username)
|
|
}
|
|
if errors.Is(err, gliderssh.ErrRejected) {
|
|
t.Errorf("dial %q: allowlist rejected a lexically-allowed symlink path (%v); the boundary must be DAC, not path strings", evilLink, err)
|
|
}
|
|
if !strings.Contains(err.Error(), "permission denied") {
|
|
t.Errorf("dial %q error = %v, want permission denied (the bound user's DAC)", evilLink, err)
|
|
}
|
|
}
|
|
|
|
// Control dial: the canary is served directly, without the forward,
|
|
// proving it is alive.
|
|
ctrl, err := net.Dial("unix", protected)
|
|
if err != nil {
|
|
t.Fatalf("control dial of canary: %v", err)
|
|
}
|
|
if err := ctrl.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
buf := make([]byte, 64)
|
|
n, err := io.ReadFull(ctrl, buf[:len("PROTECTED\n")])
|
|
if err != nil {
|
|
t.Fatalf("control read of canary marker: %v", err)
|
|
}
|
|
if string(buf[:n]) != "PROTECTED\n" {
|
|
t.Fatalf("canary control marker = %q", buf[:n])
|
|
}
|
|
ctrl.Close()
|
|
select {
|
|
case m := <-canary.markers:
|
|
if m != "PROTECTED" {
|
|
t.Fatalf("unexpected canary marker %q", m)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
// Reading the socket marker does not synchronize with the
|
|
// canary goroutine's subsequent channel send.
|
|
t.Fatal("control marker not recorded by canary")
|
|
}
|
|
// The five forwarded attempts through the symlink were all refused.
|
|
if got := len(canary.markers); got != 0 {
|
|
t.Errorf("canary served %d extra connections after the control dial; the forwarded attempts reached the protected socket (root-dial race regression)", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestFwdConnLocalUnixDirSwapRaceNeverServesProtectedSocket is the
|
|
// direct pin of the race variant of the root-dial race: a parent
|
|
// directory is concurrently switched between the real directory and a
|
|
// symlink to an escape directory while forwarded dial attempts run. The
|
|
// dial runs as the connection's bound user, so a root-owned 0600 canary
|
|
// behind the raced path must never be served, and EACCES-classified
|
|
// attempts prove the symlink windows were hit. Root runs only.
|
|
func TestFwdConnLocalUnixDirSwapRaceNeverServesProtectedSocket(t *testing.T) {
|
|
if os.Getuid() != 0 {
|
|
t.Skip("root-only test: the race pin needs a protected (root-owned 0600) canary facing a non-root bound user; run under sudo")
|
|
}
|
|
lu := fwdTestIdentity(t)
|
|
c := newFwdTestConn(t, lu, &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowLocalPortForwarding: true,
|
|
})
|
|
|
|
base := fwdTmpDir(t) // 0777, inside the allowlist
|
|
owned := filepath.Join(base, "owned")
|
|
aside := filepath.Join(base, "aside")
|
|
for _, d := range []string{owned, aside} {
|
|
if err := os.Mkdir(d, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// The escape directory is outside the tested path's own tree, like
|
|
// /var/run/docker.sock. It is traversable by every user, and its
|
|
// canary is protected by socket mode alone.
|
|
escape := filepath.Join("/var/run", fmt.Sprintf("tailssh-fwd-race-%d", os.Getpid()))
|
|
if err := os.MkdirAll(escape, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(escape) })
|
|
canaryPath := filepath.Join(escape, "canary.sock")
|
|
dialPath := filepath.Join(owned, "canary.sock")
|
|
|
|
// The swap loop alternates <owned> between the real empty directory
|
|
// and a symlink to the escape directory. It holds each state about
|
|
// 3ms so both are well represented across the dial attempts. A
|
|
// failed step restores the real-dir state, so the loop self-heals.
|
|
stop := make(chan struct{})
|
|
swapsDone := make(chan struct{})
|
|
var swaps, attempts, dials, eacces, enoent atomic.Int64
|
|
hold := func() {
|
|
select {
|
|
case <-stop:
|
|
case <-time.After(3 * time.Millisecond):
|
|
}
|
|
}
|
|
go func() {
|
|
defer close(swapsDone)
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
os.Remove(aside)
|
|
if err := os.Rename(owned, aside); err != nil {
|
|
continue
|
|
}
|
|
if err := os.Symlink(escape, owned); err != nil {
|
|
os.Rename(aside, owned)
|
|
continue
|
|
}
|
|
swaps.Add(1)
|
|
hold() // symlink state is in place
|
|
os.Remove(owned)
|
|
if err := os.Rename(aside, owned); err != nil {
|
|
os.Mkdir(owned, 0o755)
|
|
}
|
|
hold()
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
<-swapsDone
|
|
})
|
|
|
|
// One forwarded dial attempt for the raced path, classified.
|
|
probe := func() (servedMarker string, class string) {
|
|
gctx, cancel := fwdTestCtxTimeout(8 * time.Second)
|
|
defer cancel()
|
|
attempts.Add(1)
|
|
uc, err := c.mayForwardLocalUnixTo(gctx, dialPath)
|
|
if err != nil {
|
|
switch s := err.Error(); {
|
|
case strings.Contains(s, "permission denied"):
|
|
eacces.Add(1)
|
|
return "", "eacces"
|
|
case strings.Contains(s, "no such file"):
|
|
enoent.Add(1)
|
|
return "", "enoent"
|
|
default:
|
|
return "", "err"
|
|
}
|
|
}
|
|
dials.Add(1)
|
|
defer uc.Close()
|
|
if err := uc.SetDeadline(time.Now().Add(3 * time.Second)); err != nil {
|
|
t.Errorf("SetDeadline on forward conn: %v", err)
|
|
return "", "ok"
|
|
}
|
|
buf := make([]byte, 128)
|
|
n, _ := uc.Read(buf)
|
|
return strings.TrimSpace(string(buf[:n])), "ok"
|
|
}
|
|
|
|
const controlMarker = "CTL-CANARY"
|
|
const protectedMarker = "PRT-CANARY"
|
|
|
|
// In the control phase, an accessible 0666 canary proves the harness
|
|
// delivers markers through raced forwards when the target is
|
|
// user-accessible.
|
|
control := listenCanary(t, canaryPath, 0o666, controlMarker)
|
|
served := 0
|
|
for i := 0; i < 60; i++ {
|
|
if marker, class := probe(); class == "ok" {
|
|
if marker != controlMarker {
|
|
t.Fatalf("control phase served unexpected marker %q", marker)
|
|
}
|
|
served++
|
|
break
|
|
}
|
|
}
|
|
if served == 0 {
|
|
t.Fatalf("control configuration never delivered a marker; cannot attest the race harness (swaps so far: %d, attempts: %d)", swaps.Load(), attempts.Load())
|
|
}
|
|
t.Logf("control configuration: marker delivered (attempts so far: %d, swaps: %d)", attempts.Load(), swaps.Load())
|
|
control.close()
|
|
// The stdlib unix listener's Close already unlinked the socket, so
|
|
// the removal tolerates ENOENT. The path must be gone before the
|
|
// protected canary rebinds it.
|
|
if err := os.Remove(canaryPath); err != nil && !os.IsNotExist(err) {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// In the protected phase, a root-owned 0600 canary behind the very same
|
|
// raced path. It must never serve.
|
|
protected := listenCanary(t, canaryPath, 0o600, protectedMarker)
|
|
t.Cleanup(protected.close)
|
|
const raceAttempts = 200
|
|
for i := 0; i < raceAttempts; i++ {
|
|
marker, class := probe()
|
|
if class == "ok" {
|
|
t.Fatalf("root-dial race regression: attempt %d reached the protected canary through the raced forward (marker %q)", i, marker)
|
|
}
|
|
if marker != "" {
|
|
t.Fatalf("root-dial regression: attempt %d received %q from beyond the forward; the protected canary was served", i, marker)
|
|
}
|
|
}
|
|
if got := len(protected.markers); got != 0 {
|
|
t.Fatalf("REGRESSION: protected canary served %d connections through the raced forward", got)
|
|
}
|
|
if got := eacces.Load(); got == 0 {
|
|
t.Fatalf("no dial attempt hit the protected canary through the swapped symlink (%d attempts, %d swaps); the race harness did not exercise the symlink state", attempts.Load(), swaps.Load())
|
|
}
|
|
if got := enoent.Load(); got == 0 {
|
|
t.Logf("note: no ENOENT-classified attempt observed (%d attempts)", attempts.Load())
|
|
}
|
|
t.Logf("protected canary never served: %d total attempts, %d succeeded, %d EACCES (symlink state hit, DAC-refused), %d ENOENT, %d swaps",
|
|
attempts.Load(), dials.Load(), eacces.Load(), enoent.Load(), swaps.Load())
|
|
}
|
|
|
|
// TestMonExitRegistryPIDReuse checks both exit-before-registration and
|
|
// registration-before-exit when two child generations share a PID.
|
|
func TestMonExitRegistryPIDReuse(t *testing.T) {
|
|
var r monExitRegistry
|
|
old := monitorProcess{PID: 42, ID: 1}
|
|
fresh := monitorProcess{PID: 42, ID: 2}
|
|
ch, ch2 := newMonExitStatus(), newMonExitStatus()
|
|
r.dispatchExit(old, 7)
|
|
r.registerSessionExit(fresh, ch2)
|
|
r.registerSessionExit(old, ch)
|
|
<-ch.done
|
|
if ch.code != 7 {
|
|
t.Fatalf("old exit = %d; want 7", ch.code)
|
|
}
|
|
select {
|
|
case <-ch2.done:
|
|
t.Fatal("old notification completed the new child")
|
|
default:
|
|
}
|
|
r.dispatchExit(fresh, 9)
|
|
<-ch2.done
|
|
if ch2.code != 9 || ch.code != 7 {
|
|
t.Fatalf("exit codes = %d, %d; want 7, 9", ch.code, ch2.code)
|
|
}
|
|
}
|
|
|
|
// TestFwdAllowedSocketPathCaps pins the allowlist's two lexical caps:
|
|
// a home directory that cleans to "/" must not collapse the allowlist
|
|
// to "every absolute path", and over-long paths beyond the sun_path
|
|
// capacity reject lexically.
|
|
func TestFwdAllowedSocketPathCaps(t *testing.T) {
|
|
for _, home := range []string{"/", "//", ""} {
|
|
c := &conn{localUser: &userMeta{User: user.User{Username: "u", Uid: "1000", Gid: "1000", HomeDir: home}}}
|
|
for _, bad := range []string{"/var/run/docker.sock", "/etc/passwd", "/root/x.sock", "/home"} {
|
|
if _, err := c.fwdAllowedSocketPath(bad); err == nil {
|
|
t.Errorf("fwdAllowedSocketPath(%q) with HomeDir %q = nil error; want rejection (a /-home must not match every path)", bad, home)
|
|
}
|
|
}
|
|
// The unconditional entries survive a collapsed home.
|
|
for _, ok := range []string{"/tmp/x.sock", "/run/user/1000/x.sock"} {
|
|
if _, err := c.fwdAllowedSocketPath(ok); err != nil {
|
|
t.Errorf("fwdAllowedSocketPath(%q) with HomeDir %q: %v; want allowed", ok, home, err)
|
|
}
|
|
}
|
|
}
|
|
// The sun_path length cap rejects at and above the cap and passes
|
|
// below, still inside an allowed dir.
|
|
c := &conn{localUser: &userMeta{User: user.User{Username: "u", Uid: "1000", Gid: "1000", HomeDir: "/home/u"}}}
|
|
if _, err := c.fwdAllowedSocketPath("/tmp/" + strings.Repeat("a", fwdMaxSunPathLen)); err == nil {
|
|
t.Errorf("path of length %d accepted; want the sun_path cap to reject", len("/tmp/")+fwdMaxSunPathLen)
|
|
}
|
|
if _, err := c.fwdAllowedSocketPath("/tmp/" + strings.Repeat("a", fwdMaxSunPathLen-6)); err != nil {
|
|
t.Errorf("path below the sun_path cap rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestConnMonitorClosedRefusesRespawn pins the closed flag on connMonitorState.
|
|
// See connMonitorState.closed. Once the conn's teardown has run, connMonitor must
|
|
// refuse to spawn for every late caller, including session launch, unix
|
|
// and TCP forwarding, and agent forwarding.
|
|
func TestConnMonitorClosedRefusesRespawn(t *testing.T) {
|
|
lu := fwdTestIdentity(t)
|
|
c := newFwdTestConn(t, lu, &tailcfg.SSHAction{Accept: true})
|
|
if _, err := c.connMonitor(); err != nil {
|
|
t.Fatalf("spawning monitor: %v", err)
|
|
}
|
|
c.closeConnMonitor()
|
|
|
|
if _, err := c.connMonitor(); err == nil {
|
|
t.Fatal("connMonitor spawned a monitor after closeConnMonitor; want refusal (nobody would close it)")
|
|
}
|
|
if mc := c.connMonitorIfAny(); mc != nil {
|
|
t.Fatal("connMonitor left a monitor in monitor.mon after closeConnMonitor")
|
|
}
|
|
// Closing again stays a no-op.
|
|
c.closeConnMonitor()
|
|
}
|
|
|
|
// TestRemoveAgentForwardingDirRefuses pins the session-end removal guard
|
|
// for the agent-forwarding directory. The directory was created by a
|
|
// child running as the connection's user and the removal runs with the
|
|
// daemon's credentials, so only the shape the child was supposed to
|
|
// produce — a real directory (never a symlink) named auth-agent*,
|
|
// directly inside the root-owned agentDirRoot, owned by the connection's
|
|
// user with no group or other access — is removed. A symlink entry, a
|
|
// directory outside agentDirRoot, and a loosened mode are all refused
|
|
// and left in place.
|
|
func TestRemoveAgentForwardingDirRefuses(t *testing.T) {
|
|
if fi, err := os.Stat(agentDirRoot); err != nil || !fi.Mode().IsDir() {
|
|
t.Skipf("agentDirRoot %s unavailable: %v", agentDirRoot, err)
|
|
}
|
|
if st, ok := fi2stat(t, agentDirRoot); !ok || st.Uid != 0 {
|
|
t.Skipf("agentDirRoot %s is not root-owned; the pin needs the standard temp root", agentDirRoot)
|
|
}
|
|
lu := &userMeta{User: user.User{Uid: strconv.Itoa(os.Getuid()), Username: "self"}}
|
|
|
|
// Positive control: the real shape is removed.
|
|
dir, err := os.MkdirTemp(agentDirRoot, "auth-agent")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
removeAgentForwardingDir(dir, lu, t.Logf)
|
|
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
|
t.Fatalf("agent dir %q still exists after removal (err=%v)", dir, err)
|
|
}
|
|
|
|
// A symlink entry is never followed: the symlink is refused and its
|
|
// target untouched.
|
|
d, err := os.MkdirTemp(agentDirRoot, "auth-agent")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
os.Remove(d)
|
|
target := t.TempDir()
|
|
if err := os.Symlink(target, d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.Remove(d) })
|
|
removeAgentForwardingDir(d, lu, t.Logf)
|
|
if _, err := os.Stat(d); err != nil {
|
|
t.Fatalf("steered symlink entry %q was removed", d)
|
|
}
|
|
if _, err := os.Stat(target); err != nil {
|
|
t.Fatalf("symlink target %q removed through the steered entry", target)
|
|
}
|
|
|
|
// A directory outside agentDirRoot is refused, whatever its name.
|
|
outside := filepath.Join(t.TempDir(), "auth-agent-outside")
|
|
if err := os.Mkdir(outside, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(outside) })
|
|
removeAgentForwardingDir(outside, lu, t.Logf)
|
|
if _, err := os.Stat(outside); err != nil {
|
|
t.Fatalf("outside dir %q was removed", outside)
|
|
}
|
|
|
|
// A loosened mode is refused: the dir stays behind.
|
|
loose, err := os.MkdirTemp(agentDirRoot, "auth-agent")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(loose) })
|
|
if err := os.Chmod(loose, 0o711); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
removeAgentForwardingDir(loose, lu, t.Logf)
|
|
if _, err := os.Stat(loose); err != nil {
|
|
t.Fatalf("loose-mode dir %q was removed", loose)
|
|
}
|
|
}
|
|
|
|
// fi2stat returns the syscall.Stat_t of path, reporting ok=false when
|
|
// the platform does not expose one.
|
|
func fi2stat(t *testing.T, path string) (syscall.Stat_t, bool) {
|
|
t.Helper()
|
|
fi, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, ok := fi.Sys().(*syscall.Stat_t)
|
|
return *st, ok
|
|
}
|