diff --git a/client/Dockerfile-rootless b/client/Dockerfile-rootless index 8141af6ed..541f398c0 100644 --- a/client/Dockerfile-rootless +++ b/client/Dockerfile-rootless @@ -1,20 +1,23 @@ # build & run locally with: # cd "$(git rev-parse --show-toplevel)" # CGO_ENABLED=0 go build -o netbird ./client -# podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client . -# podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest +# podman build -t localhost/netbird:latest -f client/Dockerfile-rootless --ignorefile .dockerignore-client . +# podman run --rm -it --user 1001230000:0 --cap-drop=ALL --security-opt=no-new-privileges localhost/netbird:latest FROM alpine:3.24 RUN apk add --no-cache \ bash \ ca-certificates \ - && adduser -D -h /var/lib/netbird netbird + && adduser -D -u 1000 -h /var/lib/netbird netbird \ + && chgrp -R 0 /var/lib/netbird \ + && chmod -R g=u /var/lib/netbird WORKDIR /var/lib/netbird -USER netbird:netbird +USER 1000:0 ENV \ + HOME="/var/lib/netbird" \ NETBIRD_BIN="/usr/local/bin/netbird" \ NB_USE_NETSTACK_MODE="true" \ NB_ENABLE_NETSTACK_LOCAL_FORWARDING="true" \ @@ -29,5 +32,5 @@ ENV \ ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] ARG TARGETPLATFORM ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird -COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh -COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird +COPY --chown=1000:0 --chmod=0750 client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh +COPY --chown=1000:0 --chmod=0750 "${NETBIRD_BINARY}" /usr/local/bin/netbird diff --git a/client/internal/profilemanager/invoking_user.go b/client/internal/profilemanager/invoking_user.go index c86a6ce43..8c7bc1ab6 100644 --- a/client/internal/profilemanager/invoking_user.go +++ b/client/internal/profilemanager/invoking_user.go @@ -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,24 @@ 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()), + HomeDir: os.Getenv("HOME"), + }, nil } // IsPlainRoot reports that the process runs as root with no usable sudo diff --git a/client/internal/profilemanager/invoking_user_test.go b/client/internal/profilemanager/invoking_user_test.go index 54c8ad8fd..2fd56117c 100644 --- a/client/internal/profilemanager/invoking_user_test.go +++ b/client/internal/profilemanager/invoking_user_test.go @@ -2,6 +2,7 @@ package profilemanager import ( "errors" + "fmt" "io/fs" "os" "os/user" @@ -13,15 +14,82 @@ import ( "github.com/stretchr/testify/require" ) -func TestInvokingUserFallsBackToProcessUser(t *testing.T) { +func TestInvokingUserReturnsResolvedCurrentUser(t *testing.T) { t.Setenv(envSudoUser, "") + want := &user.User{ + Username: "misha", + Uid: "1234", + Gid: "1234", + HomeDir: filepath.Join("/home", "misha"), + } + origCurrentUser := currentUser + currentUser = func() (*user.User, error) { return want, nil } + t.Cleanup(func() { currentUser = origCurrentUser }) + got, err := InvokingUser() require.NoError(t, err) + assert.Same(t, want, got, "resolved process user should be returned unchanged") +} - current, err := user.Current() +func TestInvokingUserUsesNumericIdentityForUnmappedNonRoot(t *testing.T) { + t.Setenv(envSudoUser, "") + t.Setenv("HOME", "/var/lib/netbird") + fakeUnmappedUser(t, 1001230000, 0, errors.New("user: unknown userid 1001230000")) + + got, err := InvokingUser() require.NoError(t, err) - assert.Equal(t, current.Username, got.Username) + assert.Equal(t, &user.User{ + Username: "1001230000", + Uid: "1001230000", + Gid: "0", + HomeDir: "/var/lib/netbird", + }, got, "unmapped non-root identity should use kernel credentials") +} + +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, "") + t.Setenv("HOME", "/var/lib/netbird") + 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 +128,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 +290,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 { diff --git a/client/test/rootless-arbitrary-uid-docker.sh b/client/test/rootless-arbitrary-uid-docker.sh new file mode 100755 index 000000000..3adeda8dd --- /dev/null +++ b/client/test/rootless-arbitrary-uid-docker.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -eEuo pipefail + +RUNTIME="${CONTAINER_RUNTIME:-}" +if [[ -z "${RUNTIME}" ]]; then + if command -v docker >/dev/null 2>&1; then + RUNTIME=docker + elif command -v podman >/dev/null 2>&1; then + RUNTIME=podman + else + echo "docker or podman is required" >&2 + exit 127 + fi +fi +if ! command -v "${RUNTIME}" >/dev/null 2>&1; then + echo "container runtime not found: ${RUNTIME}" >&2 + exit 127 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IMAGE="${IMAGE:-netbird-rootless-arbitrary-uid-test:local}" +TARGETARCH="${TARGETARCH:-$(go env GOARCH)}" +PLATFORM="${PLATFORM:-linux/${TARGETARCH}}" +WAIT_TIMEOUT="${WAIT_TIMEOUT:-30}" +TMP_DIR="$(mktemp -d)" +CONTAINER="netbird-rootless-uid-${RANDOM}-$$" + +cleanup() { + local status=$? + "${RUNTIME}" rm -f "${CONTAINER}" >/dev/null 2>&1 || true + rm -rf "${TMP_DIR}" + exit "${status}" +} +trap cleanup EXIT + +container_logs() { + echo "---- ${CONTAINER} logs ----" >&2 + "${RUNTIME}" logs "${CONTAINER}" >&2 || true + echo "----------------------------" >&2 +} + +build_image() { + echo "==> Building Linux ${TARGETARCH} netbird binary" + mkdir -p "${TMP_DIR}/context/client" + cp "${ROOT_DIR}/client/Dockerfile-rootless" "${TMP_DIR}/context/Dockerfile" + cp "${ROOT_DIR}/client/netbird-entrypoint.sh" "${TMP_DIR}/context/client/netbird-entrypoint.sh" + + ( + cd "${ROOT_DIR}" + CGO_ENABLED=0 GOOS=linux GOARCH="${TARGETARCH}" \ + go build -o "${TMP_DIR}/context/netbird" ./client + ) + + echo "==> Building ${IMAGE} for ${PLATFORM}" + "${RUNTIME}" build \ + --platform "${PLATFORM}" \ + --build-arg NETBIRD_BINARY=netbird \ + -t "${IMAGE}" \ + -f "${TMP_DIR}/context/Dockerfile" \ + "${TMP_DIR}/context" +} + +start_container() { + echo "==> Starting ${CONTAINER} as unmapped UID 1001230000" + "${RUNTIME}" run --rm -d \ + --name "${CONTAINER}" \ + --user 1001230000:0 \ + --cap-drop=ALL \ + --security-opt=no-new-privileges \ + --entrypoint /usr/local/bin/netbird \ + "${IMAGE}" \ + --log-file console \ + service run >/dev/null +} + +wait_until_live() { + local deadline=$((SECONDS + WAIT_TIMEOUT)) + + while (( SECONDS < deadline )); do + if "${RUNTIME}" exec "${CONTAINER}" \ + /usr/local/bin/netbird status --check live >/dev/null 2>&1; then + return 0 + fi + + if [[ "$("${RUNTIME}" inspect -f '{{.State.Running}}' "${CONTAINER}" 2>/dev/null || true)" != "true" ]]; then + echo "container exited before the daemon became live" >&2 + container_logs + return 1 + fi + sleep 1 + done + + echo "timed out waiting for the daemon after ${WAIT_TIMEOUT}s" >&2 + container_logs + return 1 +} + +assert_arbitrary_uid_contract() { + echo "==> Verifying arbitrary UID image contract" + "${RUNTIME}" exec "${CONTAINER}" sh -ec ' + test "$(id -u)" = 1001230000 + test "$(id -g)" = 0 + test "${HOME}" = /var/lib/netbird + touch /var/lib/netbird/.uid-smoke + rm /var/lib/netbird/.uid-smoke + test -S /var/lib/netbird/netbird.sock + ' + "${RUNTIME}" exec "${CONTAINER}" \ + /usr/local/bin/netbird profile list >/dev/null +} + +build_image +start_container +wait_until_live +assert_arbitrary_uid_contract + +echo "==> Rootless arbitrary UID validation passed"