mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-13 18:29:07 +02:00
[client] Support arbitrary UIDs in rootless image (#7440)
* [client] Support arbitrary UIDs in rootless image * [client] Keep rootless executables root-owned * [client] Harden arbitrary UID image validation * [client] Preserve executable access in rootless image Keep the binary and entrypoint executable when deployments override the runtime group. Retain root ownership so non-root users cannot modify either file. * [client] Verify rootless state reuse with a stable UID Persisted profiles remain scoped to the creating UID. Verify same-UID container recreation without broadening application permissions, and document the Kubernetes volume permission behavior observed on OpenShift. Remove unused synthetic-user home metadata. * [client] Separate image changes from invoking user fix Keep this PR limited to resolving unmapped non-root invoking users. Move container permissions and their smoke test to a dependent image branch so they can be reviewed separately. * [client] Restore invoking process user test Retain coverage for successful current-user lookup without sudo. Numeric-identity fallback tests do not cover this existing behavior.
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -13,17 +14,21 @@ import (
|
||||
const envSudoUser = "SUDO_USER"
|
||||
|
||||
var (
|
||||
geteuid = os.Geteuid
|
||||
lookupUser = user.Lookup
|
||||
currentUser = user.Current
|
||||
getegid = os.Getegid
|
||||
geteuid = os.Geteuid
|
||||
lookupUser = user.Lookup
|
||||
)
|
||||
|
||||
// InvokingUser returns the user a CLI invocation acts for. Under sudo that is
|
||||
// the user who ran sudo, not root: privileged flags force commands through
|
||||
// sudo, and resolving profiles as root would silently switch the daemon to
|
||||
// root's (default) profile instead of the invoking user's. Privilege decisions
|
||||
// are not made here — those stay on the kernel credentials of the daemon
|
||||
// connection, which SUDO_USER (a plain environment variable) can never
|
||||
// influence; a forged value only selects a profile root could select anyway.
|
||||
// root's (default) profile instead of the invoking user's. An unmapped positive
|
||||
// process UID uses its numeric kernel identity; root, sudo lookup failures, and
|
||||
// unavailable platform identities still fail closed. Privilege decisions stay
|
||||
// on the kernel credentials of the daemon connection, which SUDO_USER (a plain
|
||||
// environment variable) can never influence; a forged value only selects a
|
||||
// profile root could select anyway.
|
||||
func InvokingUser() (*user.User, error) {
|
||||
if u, ok := sudoInvokingUser(); ok {
|
||||
return u, nil
|
||||
@@ -35,7 +40,23 @@ func InvokingUser() (*user.User, error) {
|
||||
if sudoActive() {
|
||||
return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser))
|
||||
}
|
||||
return user.Current()
|
||||
u, err := currentUser()
|
||||
if err == nil {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
uid := geteuid()
|
||||
if uid <= 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugf("current user lookup for UID %d: %v; using numeric UID", uid, err)
|
||||
uidString := strconv.Itoa(uid)
|
||||
return &user.User{
|
||||
Username: uidString,
|
||||
Uid: uidString,
|
||||
Gid: strconv.Itoa(getegid()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsPlainRoot reports that the process runs as root with no usable sudo
|
||||
|
||||
@@ -2,6 +2,7 @@ package profilemanager
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
@@ -21,7 +22,51 @@ func TestInvokingUserFallsBackToProcessUser(t *testing.T) {
|
||||
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, current.Username, got.Username)
|
||||
assert.Equal(t, current.Username, got.Username, "invoking user should match the process user without sudo")
|
||||
}
|
||||
|
||||
func TestInvokingUserFailsClosedWithoutPositiveUID(t *testing.T) {
|
||||
for _, uid := range []int{0, -1} {
|
||||
t.Run(fmt.Sprintf("UID%d", uid), func(t *testing.T) {
|
||||
t.Setenv(envSudoUser, "")
|
||||
lookupErr := errors.New("current user unavailable")
|
||||
fakeUnmappedUser(t, uid, 0, lookupErr)
|
||||
|
||||
got, err := InvokingUser()
|
||||
require.ErrorIs(t, err, lookupErr)
|
||||
assert.Nil(t, got, "root or unavailable UID must not become a synthetic identity")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileFilePathUsesNumericIdentityForUnmappedNonRoot(t *testing.T) {
|
||||
t.Setenv(envSudoUser, "")
|
||||
fakeUnmappedUser(t, 1001230000, 0, errors.New("user: unknown userid 1001230000"))
|
||||
|
||||
profilesRoot := t.TempDir()
|
||||
origDir := DefaultConfigPathDir
|
||||
origOverride := ConfigDirOverride
|
||||
DefaultConfigPathDir = profilesRoot
|
||||
ConfigDirOverride = ""
|
||||
t.Cleanup(func() {
|
||||
DefaultConfigPathDir = origDir
|
||||
ConfigDirOverride = origOverride
|
||||
})
|
||||
|
||||
profileID := ID("0123456789abcdef0123456789abcdef")
|
||||
got, err := (&Profile{ID: profileID}).FilePath()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
filepath.Join(profilesRoot, "1001230000", profileID.String()+".json"),
|
||||
got,
|
||||
"profile path should use the numeric UID namespace",
|
||||
)
|
||||
|
||||
entries, err := os.ReadDir(profilesRoot)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1, "only the numeric UID directory should be created")
|
||||
assert.Equal(t, "1001230000", entries[0].Name(), "profile namespace should be numeric")
|
||||
assert.True(t, entries[0].IsDir(), "profile namespace should be a directory")
|
||||
}
|
||||
|
||||
func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) {
|
||||
@@ -60,6 +105,13 @@ func TestInvokingUserFailsClosedWhenSudoLookupFails(t *testing.T) {
|
||||
fakeSudo(t, filepath.Join("/home", "misha"))
|
||||
lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
|
||||
|
||||
origCurrentUser := currentUser
|
||||
currentUser = func() (*user.User, error) {
|
||||
t.Fatal("currentUser must not be called after a sudo lookup failure")
|
||||
return nil, errors.New("currentUser called unexpectedly")
|
||||
}
|
||||
t.Cleanup(func() { currentUser = origCurrentUser })
|
||||
|
||||
got, err := InvokingUser()
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, got, "must not resolve to the root process user")
|
||||
@@ -215,6 +267,22 @@ func fakeSudo(t *testing.T, home string) {
|
||||
})
|
||||
}
|
||||
|
||||
func fakeUnmappedUser(t *testing.T, uid, gid int, lookupErr error) {
|
||||
t.Helper()
|
||||
|
||||
origCurrentUser := currentUser
|
||||
origEuid := geteuid
|
||||
origEgid := getegid
|
||||
currentUser = func() (*user.User, error) { return nil, lookupErr }
|
||||
geteuid = func() int { return uid }
|
||||
getegid = func() int { return gid }
|
||||
t.Cleanup(func() {
|
||||
currentUser = origCurrentUser
|
||||
geteuid = origEuid
|
||||
getegid = origEgid
|
||||
})
|
||||
}
|
||||
|
||||
func assertNoEntries(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error {
|
||||
|
||||
Reference in New Issue
Block a user