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:
committed by
Mario Minardi
parent
f3ec43d7dd
commit
e48e7b730a
+17
-7
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user