1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
//go:build linux

package builder_test

import (
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"

	"github.com/nixos/lix/gonix/builder"
)

// runInSandbox runs a busybox shell command inside the sandbox and returns its
// combined stdout/stderr and the Spawn error.  The output dir is bind-mounted
// at /out.
func runInSandbox(t *testing.T, outHostDir, script string) (string, error) {
	t.Helper()
	bbDir := busyboxDir(t)

	dirs, cleanup, err := builder.SetupChrootDir()
	if err != nil {
		t.Fatalf("SetupChrootDir: %v", err)
	}
	defer cleanup()

	spec := builder.BuildSpec{
		Builder: "/bin/sh",
		Args:    []string{"sh", "-c", script},
		Env: map[string]string{
			"out":  "/out/result",
			"PATH": "/bin",
		},
		System: "x86_64-linux",
		SandboxPaths: []builder.BoundPath{
			{Source: bbDir, Target: "/bin"},
			{Source: outHostDir, Target: "/out"},
		},
	}
	req, err := spec.ToRequest(dirs.Root, dirs.BuildDir)
	if err != nil {
		t.Fatalf("ToRequest: %v", err)
	}
	// Sanity: the seccomp filter must actually be attached.
	if len(req.Linux.SeccompFilters) == 0 {
		t.Fatal("expected a non-empty seccomp filter in the request")
	}

	var out strings.Builder
	err = builder.Spawn(req, &builder.SpawnConfig{
		SelfExe:      selfExe(t),
		Unprivileged: true,
		Stdout:       &out,
		Stderr:       &out,
	})
	return out.String(), err
}

// TestSeccompChmodSetuidDenied verifies that, under the seccomp filter, setting
// the setuid bit via chmod fails (EPERM) while a normal chmod succeeds.
func TestSeccompChmodSetuidDenied(t *testing.T) {
	if runtime.GOOS != "linux" {
		t.Skip("sandbox only on Linux")
	}
	userNSAvailable(t)

	outHostDir := t.TempDir()

	// Use a file on a writable in-sandbox filesystem (/tmp, a fresh tmpfs) for
	// the chmod probes: bind-mounted host dirs may reject chmod by the
	// namespaced uid regardless of seccomp, which would confound the test.
	// A benign chmod must succeed; a setuid chmod must be denied (EPERM) by the
	// seccomp filter.  Results are written to $out (bind-mounted to the host).
	// busybox is a single multi-call binary; applets are invoked as
	// "busybox <applet>" since only sh is symlinked into /bin.
	script := `
		f=/tmp/probe
		echo hi > "$f"
		if busybox chmod 0644 "$f" 2>/dev/null; then echo "chmod644:ok" > "$out"; else echo "chmod644:FAIL" > "$out"; fi
		if busybox chmod 4755 "$f" 2>/dev/null; then echo "chmodsuid:ALLOWED" >> "$out"; else echo "chmodsuid:denied" >> "$out"; fi
	`
	out, err := runInSandbox(t, outHostDir, script)
	t.Logf("sandbox output:\n%s", out)
	if err != nil {
		t.Fatalf("Spawn: %v", err)
	}

	result, rerr := os.ReadFile(filepath.Join(outHostDir, "result"))
	if rerr != nil {
		t.Fatalf("reading $out: %v", rerr)
	}
	got := string(result)
	if !strings.Contains(got, "chmod644:ok") {
		t.Errorf("benign chmod 0644 should succeed; output:\n%s", got)
	}
	if !strings.Contains(got, "chmodsuid:denied") {
		t.Errorf("setuid chmod 4755 should be denied by seccomp; output:\n%s", got)
	}
}