mirror of
https://github.com/tailscale/tailscale.git
synced 2026-11-09 14:05:36 +09:00
Move lazy monitor startup, credentials, and cleanup into connmonitor.go and rename the connection state and accessors. The monitor owns session processes as well as forwarding children. Move the session exit registry alongside session monitor handling and keep the Plan 9 stub in a matching file. Update callers, tests, and diagnostic prefixes without changing monitor lifecycle behavior. Updates tailscale/corp#50380 Change-Id: I760fa009176491da426639615907d129b68026fc Signed-off-by: James Tucker <james@tailscale.com>
1315 lines
45 KiB
Go
1315 lines
45 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)
|
|
}
|
|
|
|
// Canceling the request's context closes the listener through the
|
|
// conn's ctx-done goroutine.
|
|
cancel()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if conn, err := net.DialTimeout("tcp", fwdAddr, time.Second); err != nil {
|
|
break // listener closed
|
|
} else {
|
|
conn.Close()
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|