Files
nomad/client/allocdir/fs_linux_test.go
Tim Gross b7595c646d alloc fs: use case-insensitive check for reads of secret/private dir (#24125)
When using the Client FS APIs, we check to ensure that reads don't traverse into
the allocation's secret dir and private dir. But this check can be bypassed on
case-insensitive file systems (ex. Windows, macOS, and Linux with obscure ext4
options enabled). This allows a user with `read-fs` permissions but not
`alloc-exec` permissions to read from the secrets dir.

This changeset updates the check so that it's case-insensitive. This risks false
positives for escape (see linked Go issue), but only if a task without
filesystem isolation deliberately writes into the task working directory to do
so, which is a fail-safe failure mode.

Ref: https://github.com/golang/go/issues/18358

Co-authored-by: dduzgun-security <deniz.duzgun@hashicorp.com>
2024-10-03 14:20:24 -04:00

178 lines
5.1 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build !windows
// +build !windows
package allocdir
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"github.com/hashicorp/nomad/ci"
"github.com/shoenig/test/must"
"golang.org/x/sys/unix"
)
var notFoundErr = fmt.Errorf("not found")
func isMount(path string) (int, error) {
file, err := os.Open("/proc/self/mounts")
if err != nil {
return 0, err
}
defer file.Close()
reader := bufio.NewReaderSize(file, 64*1024)
const max = 100000
for i := 0; i < max; i++ {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
return 0, notFoundErr
}
return 0, err
}
parts := strings.SplitN(line, " ", 3)
if len(parts) != 3 {
return 0, fmt.Errorf("unexpected line: %q", line)
}
if parts[1] == path {
// Found it! Make sure it's a tmpfs
if parts[0] != "tmpfs" {
return 0, fmt.Errorf("unexpected fs: %q", parts[1])
}
sizeMatch := regexp.MustCompile(`size=(\d+)k`).FindStringSubmatch(parts[2])
if len(sizeMatch) == 0 {
return 0, fmt.Errorf("mount entry did not include size: %q", parts[2])
}
size, err := strconv.ParseInt(sizeMatch[1], 10, 64)
if err != nil {
return 0, fmt.Errorf("could not parse %q as int: %w", sizeMatch[0], err)
}
return int(size) / 1024, nil
}
}
return 0, fmt.Errorf("exceeded max mount entries (%d)", max)
}
// TestLinuxRootSecretDir asserts secret dir creation and removal are
// idempotent.
func TestLinuxRootSecretDir(t *testing.T) {
ci.Parallel(t)
if unix.Geteuid() != 0 {
t.Skip("Must be run as root")
}
secretsDir := filepath.Join(t.TempDir(), TaskSecrets)
// removing a nonexistent secrets dir should NOT error
if err := removeSecretDir(secretsDir); err != nil {
t.Fatalf("error removing nonexistent secrets dir %q: %v", secretsDir, err)
}
// run twice as it should be idempotent
if err := removeSecretDir(secretsDir); err != nil {
t.Fatalf("error removing nonexistent secrets dir %q: %v", secretsDir, err)
}
// creating a secrets dir should work
taskSecretsSize := 2
if err := createSecretDir(secretsDir, taskSecretsSize); err != nil {
t.Fatalf("error creating secrets dir %q: %v", secretsDir, err)
}
// creating it again should be a noop (NO error)
if err := createSecretDir(secretsDir, taskSecretsSize); err != nil {
t.Fatalf("error creating secrets dir %q: %v", secretsDir, err)
}
// ensure it exists and is a directory
fi, err := os.Lstat(secretsDir)
if err != nil {
t.Fatalf("error stat'ing secrets dir %q: %v", secretsDir, err)
}
if !fi.IsDir() {
t.Fatalf("secrets dir %q is not a directory and should be", secretsDir)
}
size, err := isMount(secretsDir)
must.NoError(t, err, must.Sprintf("secrets dir %q is not a mount: %v", secretsDir, err))
must.Eq(t, taskSecretsSize, size)
// now remove it
if err := removeSecretDir(secretsDir); err != nil {
t.Fatalf("error removing secrets dir %q: %v", secretsDir, err)
}
// make sure it's gone
if _, err := isMount(secretsDir); err != notFoundErr {
t.Fatalf("error ensuring secrets dir %q isn't mounted: %v", secretsDir, err)
}
// removing again should be a noop
if err := removeSecretDir(secretsDir); err != nil {
t.Fatalf("error removing nonexistent secrets dir %q: %v", secretsDir, err)
}
}
// TestLinuxUnprivilegedSecretDir asserts secret dir creation and removal are
// idempotent.
func TestLinuxUnprivilegedSecretDir(t *testing.T) {
ci.Parallel(t)
if unix.Geteuid() == 0 {
t.Skip("Must not be run as root")
}
secretsDir := filepath.Join(t.TempDir(), TaskSecrets)
// removing a nonexistent secrets dir should NOT error
if err := removeSecretDir(secretsDir); err != nil {
t.Fatalf("error removing nonexistent secrets dir %q: %v", secretsDir, err)
}
// run twice as it should be idempotent
if err := removeSecretDir(secretsDir); err != nil {
t.Fatalf("error removing nonexistent secrets dir %q: %v", secretsDir, err)
}
// creating a secrets dir should work
if err := createSecretDir(secretsDir, defaultSecretDirTmpfsSize); err != nil {
t.Fatalf("error creating secrets dir %q: %v", secretsDir, err)
}
// creating it again should be a noop (NO error)
if err := createSecretDir(secretsDir, defaultSecretDirTmpfsSize); err != nil {
t.Fatalf("error creating secrets dir %q: %v", secretsDir, err)
}
// ensure it exists and is a directory
fi, err := os.Lstat(secretsDir)
if err != nil {
t.Fatalf("error stat'ing secrets dir %q: %v", secretsDir, err)
}
if !fi.IsDir() {
t.Fatalf("secrets dir %q is not a directory and should be", secretsDir)
}
if _, err := isMount(secretsDir); err != notFoundErr {
t.Fatalf("error ensuring secrets dir %q isn't mounted: %v", secretsDir, err)
}
// now remove it
if err := removeSecretDir(secretsDir); err != nil {
t.Fatalf("error removing secrets dir %q: %v", secretsDir, err)
}
// make sure it's gone
if _, err := os.Lstat(secretsDir); err == nil {
t.Fatalf("expected secrets dir %q to be gone but it was found", secretsDir)
}
// removing again should be a noop
if err := removeSecretDir(secretsDir); err != nil {
t.Fatalf("error removing nonexistent secrets dir %q: %v", secretsDir, err)
}
}