ssh/tailssh: check if user matching autogroup:nonroot is root

Add a check to ensure that the user being matched to an
autogroup:nonroot rule is in fact a non-root user on the system.

Updates https://github.com/tailscale/corp/issues/43245

Signed-off-by: Mario Minardi <mario@tailscale.com>
This commit is contained in:
Mario Minardi
2026-07-27 17:18:34 -06:00
committed by Mario Minardi
parent f3ec43d7dd
commit e48e7b730a
5 changed files with 166 additions and 13 deletions
+17 -7
View File
@@ -22,7 +22,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
@@ -48,6 +47,7 @@ import (
"tailscale.com/util/clientmetric" "tailscale.com/util/clientmetric"
"tailscale.com/util/httpm" "tailscale.com/util/httpm"
"tailscale.com/util/mak" "tailscale.com/util/mak"
"tailscale.com/version/distro"
) )
var ( var (
@@ -64,8 +64,6 @@ var (
// hookSSHLoginSuccess is called after successful SSH authentication. // hookSSHLoginSuccess is called after successful SSH authentication.
// It is set by platform-specific code (e.g., auditd_linux.go). // It is set by platform-specific code (e.g., auditd_linux.go).
hookSSHLoginSuccess feature.Hook[func(logf logger.Logf, c *conn)] hookSSHLoginSuccess feature.Hook[func(logf logger.Logf, c *conn)]
uidRegex = regexp.MustCompile("^[0-9]+$")
) )
const ( const (
@@ -344,10 +342,6 @@ func (c *conn) clientAuth(cm ssh.ConnMetadata) (perms *ssh.Permissions, retErr e
return &ssh.Permissions{}, nil return &ssh.Permissions{}, nil
} }
if uidRegex.MatchString(cm.User()) {
return nil, c.errBanner(fmt.Sprintf("rejecting username %q. Usernames that consist of only digits are not allowed as they are ambiguous with numerical UIDs", cm.User()), nil)
}
if err := c.setInfo(cm); err != nil { if err := c.setInfo(cm); err != nil {
return nil, c.errBanner("failed to get connection info", err) return nil, c.errBanner("failed to get connection info", err)
} }
@@ -1262,6 +1256,22 @@ func mapLocalUser(ruleSSHUsers map[string]string, reqSSHUser string) (localUser
v = ruleSSHUsers["*"] v = ruleSSHUsers["*"]
} }
if v == "=" { if v == "=" {
// Skip lookup for gokrazy as we intentionally fall back to a synthesized
// root user there when user lookup fails.
if distro.Get() == distro.Gokrazy {
return reqSSHUser
}
// Immediately look up user information for purposes of generating
// hold and delegate URL (if necessary).
lu, err := userLookup(reqSSHUser)
if err != nil {
return ""
}
// Don't match as root for autogroup:nonroot
if lu.Uid == "0" || lu.Username == "root" {
return ""
}
return reqSSHUser return reqSSHUser
} }
return v return v
+103 -4
View File
@@ -6,6 +6,7 @@
package tailssh package tailssh
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"crypto/ecdsa" "crypto/ecdsa"
@@ -58,6 +59,10 @@ import (
func TestMatchRule(t *testing.T) { func TestMatchRule(t *testing.T) {
someAction := new(tailcfg.SSHAction) someAction := new(tailcfg.SSHAction)
// nonRootUser is a real non-root user in the local passwd database,
// used by the "ssh-user-equal" case because mapLocalUser now does a
// userLookup (via getent) when the mapped value is "=" and rejects root.
nonRootUser := aNonRootUser(t)
tests := []struct { tests := []struct {
name string name string
rule *tailcfg.SSHRule rule *tailcfg.SSHRule
@@ -219,8 +224,8 @@ func TestMatchRule(t *testing.T) {
"*": "=", "*": "=",
}, },
}, },
ci: &sshConnInfo{sshUser: "alice"}, ci: &sshConnInfo{sshUser: nonRootUser},
wantUser: "alice", wantUser: nonRootUser,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
@@ -396,6 +401,39 @@ var currentUser = func() string {
return os.Getenv("USER") return os.Getenv("USER")
}() }()
// aNonRootUser returns the username of a non-root user that exists in the
// local passwd database (verified via userLookup, which uses getent on
// Linux). It prefers the current user, falling back to reading /etc/passwd
// for a real non-root account when the tests are run as root (e.g. in CI).
// It skips the test if no such user can be found.
func aNonRootUser(t *testing.T) string {
t.Helper()
if u, err := userLookup(currentUser); err == nil && u.Uid != "0" && u.Username != "root" {
return currentUser
}
f, err := os.Open("/etc/passwd")
if err != nil {
t.Skipf("can't find a non-root user: %v", err)
}
defer f.Close()
scan := bufio.NewScanner(f)
for scan.Scan() {
fields := strings.Split(scan.Text(), ":")
if len(fields) < 3 {
continue
}
name, uid := fields[0], fields[2]
if uid == "0" || name == "root" {
continue
}
if _, err := userLookup(name); err == nil {
return name
}
}
t.Skip("can't find a non-root user in /etc/passwd")
return ""
}
func (ts *localState) Dialer() *tsdial.Dialer { func (ts *localState) Dialer() *tsdial.Dialer {
return &tsdial.Dialer{} return &tsdial.Dialer{}
} }
@@ -468,6 +506,7 @@ func newSSHRule(action *tailcfg.SSHAction) *tailcfg.SSHRule {
return &tailcfg.SSHRule{ return &tailcfg.SSHRule{
SSHUsers: map[string]string{ SSHUsers: map[string]string{
"alice": currentUser, "alice": currentUser,
"*": "=",
}, },
Action: action, Action: action,
Principals: []*tailcfg.SSHPrincipal{ Principals: []*tailcfg.SSHPrincipal{
@@ -803,6 +842,11 @@ func TestSSHAuthFlow(t *testing.T) {
Reject: true, Reject: true,
Message: "Go Away!", Message: "Go Away!",
}) })
autogroupNonrootRule := newSSHRule(&tailcfg.SSHAction{
Accept: true,
Message: "autogroup:nonroot",
})
autogroupNonrootRule.SSHUsers = map[string]string{"*": "="}
tests := []struct { tests := []struct {
name string name string
@@ -832,7 +876,7 @@ func TestSSHAuthFlow(t *testing.T) {
wantBanners: []string{`tailscale: tailnet policy does not permit you to SSH as user "alice"` + "\n"}, wantBanners: []string{`tailscale: tailnet policy does not permit you to SSH as user "alice"` + "\n"},
}, },
{ {
name: "digit-only-username", name: "user-mismatch-numeric-username",
sshUser: "321", sshUser: "321",
state: &localState{ state: &localState{
sshEnabled: true, sshEnabled: true,
@@ -840,7 +884,62 @@ func TestSSHAuthFlow(t *testing.T) {
matchingRule: bobRule, matchingRule: bobRule,
}, },
authErr: true, authErr: true,
wantBanners: []string{`tailscale: rejecting username "321". Usernames that consist of only digits are not allowed as they are ambiguous with numerical UIDs` + "\n"}, wantBanners: []string{`tailscale: tailnet policy does not permit you to SSH as user "321"` + "\n"},
},
{
name: "user-mismatch-root-uid",
sshUser: "0",
state: &localState{
sshEnabled: true,
varRoot: varRoot,
matchingRule: autogroupNonrootRule,
},
authErr: true,
wantBanners: []string{`tailscale: tailnet policy does not permit you to SSH as user "0"` + "\n"},
},
{
name: "user-mismatch-root-uid-leading-space",
sshUser: " 0",
state: &localState{
sshEnabled: true,
varRoot: varRoot,
matchingRule: autogroupNonrootRule,
},
authErr: true,
wantBanners: []string{`tailscale: tailnet policy does not permit you to SSH as user " 0"` + "\n"},
},
{
name: "user-mismatch-root-uid-force-password-auth",
sshUser: "0+password",
state: &localState{
sshEnabled: true,
varRoot: varRoot,
matchingRule: autogroupNonrootRule,
},
authErr: true,
wantBanners: []string{`tailscale: tailnet policy does not permit you to SSH as user "0"` + "\n"},
},
{
name: "user-mismatch-double-zero-force-password-auth",
sshUser: "00+password",
state: &localState{
sshEnabled: true,
varRoot: varRoot,
matchingRule: autogroupNonrootRule,
},
authErr: true,
wantBanners: []string{`tailscale: tailnet policy does not permit you to SSH as user "00"` + "\n"},
},
{
name: "user-mismatch-leading-plus",
sshUser: "+0",
state: &localState{
sshEnabled: true,
varRoot: varRoot,
matchingRule: autogroupNonrootRule,
},
authErr: true,
wantBanners: []string{`tailscale: tailnet policy does not permit you to SSH as user "+0"` + "\n"},
}, },
{ {
name: "accept", name: "accept",
@@ -698,6 +698,15 @@ func (s *Server) SetMasqueradeAddresses(pairs []MasqueradePair) {
s.updateLocked("SetMasqueradeAddresses", s.nodeIDsLocked(0)) s.updateLocked("SetMasqueradeAddresses", s.nodeIDsLocked(0))
} }
// SetSSHPolicy sets the SSH policy sent in MapResponses and notifies all
// connected nodes so they pick up the change.
func (s *Server) SetSSHPolicy(policy *tailcfg.SSHPolicy) {
s.mu.Lock()
defer s.mu.Unlock()
s.SSHPolicy = policy
s.updateLocked("SetSSHPolicy", s.nodeIDsLocked(0))
}
// SetNodeCapMap overrides the capability map the specified client receives. // SetNodeCapMap overrides the capability map the specified client receives.
func (s *Server) SetNodeCapMap(nodeKey key.NodePublic, capMap tailcfg.NodeCapMap) { func (s *Server) SetNodeCapMap(nodeKey key.NodePublic, capMap tailcfg.NodeCapMap) {
s.mu.Lock() s.mu.Lock()
+34
View File
@@ -12,6 +12,8 @@ import (
"time" "time"
"github.com/creachadair/mds/shell" "github.com/creachadair/mds/shell"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/tstest/natlab/vmtest" "tailscale.com/tstest/natlab/vmtest"
"tailscale.com/tstest/natlab/vnet" "tailscale.com/tstest/natlab/vnet"
) )
@@ -37,6 +39,8 @@ func TestTailscaleSSH_Ubuntu(t *testing.T) {
if out, code := testSuite.ssh(t, "nosuchuser", "true"); code == 0 { if out, code := testSuite.ssh(t, "nosuchuser", "true"); code == 0 {
t.Errorf("ubuntu nonexistent user: ssh succeeded, want failure:\n%s", out) t.Errorf("ubuntu nonexistent user: ssh succeeded, want failure:\n%s", out)
} }
testSuite.checkAutogroupNonroot(t, "ubuntu")
} }
// TestTailscaleSSH_FreeBSD exercises the Tailscale SSH server ("tailscale up // TestTailscaleSSH_FreeBSD exercises the Tailscale SSH server ("tailscale up
@@ -60,6 +64,8 @@ func TestTailscaleSSH_FreeBSD(t *testing.T) {
if out, code := testSuite.ssh(t, "nosuchuser", "true"); code == 0 { if out, code := testSuite.ssh(t, "nosuchuser", "true"); code == 0 {
t.Errorf("freebsd nonexistent user: ssh succeeded, want failure:\n%s", out) t.Errorf("freebsd nonexistent user: ssh succeeded, want failure:\n%s", out)
} }
testSuite.checkAutogroupNonroot(t, "freebsd")
} }
// TestTailscaleSSH_Gokrazy exercises the gokrazy-specific cases in the // TestTailscaleSSH_Gokrazy exercises the gokrazy-specific cases in the
@@ -157,6 +163,34 @@ func (st *sshTest) check(t *testing.T, desc, user string, cmd, want string) {
} }
} }
// checkAutogroupNonroot adjusts the server's SSH policy to be equivalent to "autogroup:nonroot"
// and verifies that attempting to SSH as root fails.
func (st *sshTest) checkAutogroupNonroot(t *testing.T, name string) {
t.Helper()
orignalSSHPolicy := st.env.ControlServer().SSHPolicy
defer func() {
st.env.ControlServer().SetSSHPolicy(orignalSSHPolicy)
}()
st.env.ControlServer().SetSSHPolicy(&tailcfg.SSHPolicy{
Rules: []*tailcfg.SSHRule{{
Principals: []*tailcfg.SSHPrincipal{{Any: true}},
SSHUsers: map[string]string{"*": "="},
Action: &tailcfg.SSHAction{Accept: true},
}},
})
if err := tstest.WaitFor(30*time.Second, func() error {
_, code := st.ssh(t, "root", "true")
if code == 0 {
return fmt.Errorf("root SSH still succeeds")
}
return nil
}); err != nil {
t.Fatalf("%s: root SSH still succeeds after policy update to autogroup:nonroot-only; policy may not have propagated", name)
}
}
func newTestSuite(t *testing.T, serverName string, serverOS vmtest.OSImage) *sshTest { func newTestSuite(t *testing.T, serverName string, serverOS vmtest.OSImage) *sshTest {
t.Helper() t.Helper()
env := vmtest.New(t) env := vmtest.New(t)
+3 -2
View File
@@ -1779,8 +1779,9 @@ func (e *Env) initVnet() {
e.server.ControlServer().SSHPolicy = &tailcfg.SSHPolicy{ e.server.ControlServer().SSHPolicy = &tailcfg.SSHPolicy{
Rules: []*tailcfg.SSHRule{{ Rules: []*tailcfg.SSHRule{{
Principals: []*tailcfg.SSHPrincipal{{Any: true}}, Principals: []*tailcfg.SSHPrincipal{{Any: true}},
SSHUsers: map[string]string{"*": "="}, // Allow permissive login + root login by default
Action: &tailcfg.SSHAction{Accept: true}, SSHUsers: map[string]string{"*": "=", "root": "root"},
Action: &tailcfg.SSHAction{Accept: true},
}}, }},
} }
break break