123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390 | package sandbox
import (
"bytes"
"context"
"net"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"blinker/internal/policy"
)
// platformPolicy compiles a small policy for a platform, over a home and a
// project in a temp dir.
func platformPolicy(t *testing.T, plat platform) *policy.Compiled {
t.Helper()
// Outside /tmp, which the Linux sandbox replaces with the scratch dir.
root, err := os.MkdirTemp(".", ".platform-test-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(root) })
if root, err = filepath.Abs(root); err != nil {
t.Fatal(err)
}
if root, err = filepath.EvalSymlinks(root); err != nil {
t.Fatal(err)
}
home := filepath.Join(root, "home")
env := policy.Env{
Home: home, Project: filepath.Join(home, "proj"), Scratch: filepath.Join(root, "scratch"),
SystemRead: plat.SystemReadPaths(), BaselineWrite: plat.BaselineWritePaths(),
}
for _, d := range []string{env.Project, env.Scratch, filepath.Join(home, "hidden")} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
f := &policy.File{Files: []policy.FileRule{
{Path: "**", Access: policy.Write},
{Path: "~/hidden", Access: policy.None},
}}
floor := policy.DefaultFloor()
floor.Blocklist = append(floor.Blocklist, plat.SystemTempDirs()...)
c, err := policy.Compile(policy.Layer{Name: "test policy", File: f}, floor, env)
if err != nil {
t.Fatal(err)
}
return c
}
// The macOS backend's profile, checked on any OS.
func TestDarwinWrap(t *testing.T) {
d := &darwinPlatform{sandboxExec: "/usr/bin/sandbox-exec"}
c := platformPolicy(t, d)
argv, env, _, err := d.Wrap(wrap{Policy: c, Mode: policy.UnsafeMode, Proxy: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 4321}, Argv: []string{"/bin/bash", "-c", "true"}})
if err != nil {
t.Fatal(err)
}
if len(argv) != 6 || argv[0] != "/usr/bin/sandbox-exec" || argv[1] != "-p" || !slices.Equal(argv[3:], []string{"/bin/bash", "-c", "true"}) {
t.Fatalf("argv %q", argv)
}
profile := argv[2]
for _, want := range []string{
"(deny default)",
"(allow signal process-info* (target same-sandbox))",
`(sysctl-name "kern.procargs2")`,
"(deny network*)",
`(allow network-outbound (remote ip "localhost:4321"))`,
`(deny file-read* file-read-metadata file-test-existence file-write* (subpath "` + filepath.Join(c.Env.Home, "hidden") + `"))`,
`(literal "/dev/null")`,
} {
if !strings.Contains(profile, want) {
t.Errorf("the profile lacks %s", want)
}
}
// Nothing that reaches a process outside the sandbox: launching apps,
// Apple Events, the pasteboard, preferences, the keychain.
for _, bad := range []string{`"/dev/tty"`, `(subpath "/dev")`, "(allow default)", "(allow lsopen", "(allow appleevent-send", "(allow user-preference", `"com.apple.pasteboard`, `"com.apple.cfprefsd`, `"com.apple.SecurityServer`} {
if strings.Contains(profile, bad) {
t.Errorf("the profile has %s", bad)
}
}
// The system's temp dirs are in the floor, and the scratch dir is open
// after it, even inside one.
if !strings.Contains(profile, `(deny file-read* file-read-metadata file-test-existence file-write* (subpath "/private/tmp"))`) {
t.Error("the profile does not deny /private/tmp")
}
if strings.LastIndex(profile, `(subpath "`+c.Env.Scratch+`")`) < strings.Index(profile, ";; The floor") {
t.Error("the scratch dir is not opened after the floor")
}
// The floor comes last, so no rule overrides it.
if ssh := strings.LastIndex(profile, filepath.Join(c.Env.Home, ".ssh")); ssh < strings.Index(profile, ";; The floor") {
t.Error("~/.ssh is not in the floor at the end")
}
if !slices.Contains(env, "HTTPS_PROXY=http://127.0.0.1:4321") || !slices.Contains(env, "NODE_USE_ENV_PROXY=1") || slices.ContainsFunc(env, func(kv string) bool { return strings.HasPrefix(kv, "TMPDIR=") }) {
t.Errorf("env %q", env)
}
argv, env, _, err = d.Wrap(wrap{Policy: c, Mode: policy.SafeMode, Argv: []string{"true"}})
if err != nil {
t.Fatal(err)
}
if strings.Contains(argv[2], "network-outbound") || len(env) != 0 {
t.Errorf("no network, but the profile allows some, or env is %q", env)
}
if _, _, _, err := d.Wrap(wrap{Policy: c, Proxy: &net.UnixAddr{Name: "/x/proxy.sock", Net: "unix"}, Argv: []string{"true"}}); err == nil {
t.Error("a Unix socket proxy was accepted")
}
}
// The Linux backend's command line, checked on any OS.
func TestLinuxWrap(t *testing.T) {
l := &linuxPlatform{bwrap: "/usr/bin/bwrap", exe: "/opt/blinker"}
c := platformPolicy(t, l)
argv, env, _, err := l.Wrap(wrap{Policy: c, Mode: policy.SafeMode, Proxy: &net.UnixAddr{Name: "/run/x/safe/proxy.sock", Net: "unix"}, Argv: []string{"true"}})
if err != nil {
t.Fatal(err)
}
cmd := strings.Join(argv, " ")
for _, want := range []string{"--new-session", "--unshare-all", "--dev /dev", "--ro-bind /run/x/safe " + sandboxProxyDir, "/opt/blinker " + BridgeCommand, "-- true"} {
if !strings.Contains(cmd, want) {
t.Errorf("the command lacks %s: %s", want, cmd)
}
}
if !slices.Contains(env, "HTTPS_PROXY=http://127.0.0.1:3128") {
t.Errorf("env %q", env)
}
if _, _, _, err := l.Wrap(wrap{Policy: c, Proxy: &net.TCPAddr{Port: 1}, Argv: []string{"true"}}); err == nil {
t.Error("a TCP proxy was accepted")
}
}
// fakePlatform runs commands unsandboxed, recording what it was asked:
// for testing what Sandbox gives a platform, never what a sandbox
// enforces.
type fakePlatform struct {
linuxPlatform
got []wrap
}
func (f *fakePlatform) Name() string { return "fake" }
func (f *fakePlatform) Wrap(w wrap) (argv, env, made []string, err error) {
f.got = append(f.got, w)
return w.Argv, []string{"FAKE_PLATFORM=1"}, nil, nil
}
func TestSandboxUsesPlatform(t *testing.T) {
// Outside the system's temp dir, which the floor would not hide.
home, err := os.MkdirTemp(".", ".sandbox-test-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(home) })
if home, err = filepath.Abs(home); err != nil {
t.Fatal(err)
}
project := filepath.Join(home, "proj")
if err := os.MkdirAll(project, 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("HIDDEN_VAR", "x")
t.Chdir(project)
configDir := filepath.Join(home, "config")
fake := &fakePlatform{}
scratchRoot := filepath.Join(home, "scratch")
if err := os.Mkdir(scratchRoot, 0o700); err != nil {
t.Fatal(err)
}
sb, err := newSandbox(Options{ConfigDir: configDir, ScratchRoot: scratchRoot}, fake)
if err != nil {
t.Fatal(err)
}
t.Cleanup(sb.Close)
if sb.Backend() != "fake" {
t.Errorf("backend %q", sb.Backend())
}
var out bytes.Buffer
if _, err := sb.Run(context.Background(), Cmd{Mode: policy.UnsafeMode, Argv: []string{"/usr/bin/env"}, Stdout: &out, Stderr: &out}); err != nil {
t.Fatal(err)
}
if len(fake.got) != 1 || fake.got[0].Mode != policy.UnsafeMode || fake.got[0].Proxy != nil {
t.Fatalf("the platform got %+v", fake.got)
}
if !slices.Equal(fake.got[0].Policy.Env.SystemRead, fake.SystemReadPaths()) {
t.Error("the policy's system paths are not the platform's")
}
// The system's secrets are on the floor, even where rules open /etc.
if c := fake.got[0].Policy; c.Access("/etc/shadow").Read || !c.Access("/etc/passwd").Read {
t.Error("the floor does not hold the platform's secrets, or holds too much")
}
env := out.String()
for _, want := range []string{"FAKE_PLATFORM=1", "BLINKER_MODE=unsafe", "TMPDIR=/tmp\n"} {
if !strings.Contains(env, want) {
t.Errorf("the command's env lacks %s:\n%s", want, env)
}
}
if strings.Contains(env, "HIDDEN_VAR") {
t.Error("the command got a variable no rule passes")
}
}
// WriteFile's widened mount takes its place among the policy's, so the
// hidden paths under it stay hidden.
func TestLinuxCreateInKeepsDeeperMounts(t *testing.T) {
l := &linuxPlatform{bwrap: "/usr/bin/bwrap", exe: "/opt/blinker"}
c := platformPolicy(t, l)
hidden := filepath.Join(c.Env.Home, "hidden")
args, _, err := bwrapArgs(c, bwrapOpts{createIn: c.Env.Home, empty: "/x/empty"})
if err != nil {
t.Fatal(err)
}
cmd := strings.Join(args, " ")
bind, hide := strings.Index(cmd, "--bind-try "+c.Env.Home+" "+c.Env.Home), strings.Index(cmd, "--tmpfs "+hidden)
if bind < 0 || hide < bind {
t.Errorf("the hidden dir is not mounted after the widened one: %s", cmd)
}
}
// A file whose name would close a profile string and add rules of its
// own, found by a glob, stops the command instead.
func TestDarwinProfileRefusesUnquotablePaths(t *testing.T) {
d := &darwinPlatform{sandboxExec: "/usr/bin/sandbox-exec"}
c := platformPolicy(t, d)
name := filepath.Join(c.Env.Project, `x") (allow file-read*) ("y.pem`)
if err := os.WriteFile(name, nil, 0o644); err != nil {
t.Fatal(err)
}
if _, _, _, err := d.Wrap(wrap{Policy: c, Mode: policy.SafeMode, Argv: []string{"true"}}); err == nil {
t.Error("the profile was made")
}
}
// xcrun's cache is copied into the scratch dir, never through a symlink.
func TestDarwinPrepareScratch(t *testing.T) {
src := t.TempDir()
d := &darwinPlatform{userTemp: src}
scratch := t.TempDir()
if err := d.PrepareScratch(scratch); err != nil {
t.Errorf("no cache to copy: %v", err)
}
db := filepath.Join(src, "xcrun_db")
if err := os.WriteFile(db, []byte("cache"), 0o600); err != nil {
t.Fatal(err)
}
if err := d.PrepareScratch(scratch); err != nil {
t.Fatal(err)
}
if got, err := os.ReadFile(filepath.Join(scratch, "xcrun_db")); err != nil || string(got) != "cache" {
t.Errorf("copied %q, %v", got, err)
}
secret := filepath.Join(src, "secret")
if err := os.WriteFile(secret, []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Remove(db); err != nil {
t.Fatal(err)
}
if err := os.Symlink(secret, db); err != nil {
t.Fatal(err)
}
scratch = t.TempDir()
if err := d.PrepareScratch(scratch); err == nil {
t.Error("copied through a symlink")
}
if _, err := os.Lstat(filepath.Join(scratch, "xcrun_db")); err == nil {
t.Error("the scratch dir has a cache from a symlink")
}
}
func TestUserHomes(t *testing.T) {
dir := t.TempDir()
dir, _ = filepath.EvalSymlinks(dir)
homes := filepath.Join(dir, "home")
for _, d := range []string{"home/alice", "srv/svc", "real/carol"} {
if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.Symlink(filepath.Join(dir, "real/carol"), filepath.Join(homes, "carol")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "afile"), nil, 0o644); err != nil {
t.Fatal(err)
}
passwd := filepath.Join(dir, "passwd")
lines := []string{
"root:x:0:0::/:/bin/sh", // not /
"svc:x:1:1::" + dir + "/srv/svc:/bin/false",
"svc2:x:2:2::" + dir + "/srv/svc/:/bin/false", // the same, cleaned
"gone:x:3:3::" + dir + "/srv/gone:/bin/false", // missing: kept
"proc:x:4:4::/proc:/bin/false",
"null:x:5:5::/dev/null:/bin/false",
"file:x:6:6::" + dir + "/afile:/bin/false",
"rel:x:7:7::home/rel:/bin/sh",
"glob:x:8:8::" + dir + "/srv/*:/bin/sh",
"#alice:x:9:9::/commented:/bin/sh",
"short:x:10",
"",
}
if err := os.WriteFile(passwd, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
t.Fatal(err)
}
got, err := userHomes(passwd, homes)
if err != nil {
t.Fatal(err)
}
want := []string{dir + "/srv/svc", dir + "/srv/gone", dir + "/home/alice", dir + "/real/carol"}
if !slices.Equal(got, want) {
t.Errorf("homes\n%q, want\n%q", got, want)
}
if got, err := userHomes(filepath.Join(dir, "none"), filepath.Join(dir, "none")); err != nil || len(got) != 0 {
t.Errorf("nothing listed: %q, %v", got, err)
}
}
// A followed link the sandbox already mounts, as the system's /bin (a
// link to usr/bin on many systems) is, is not made again: bubblewrap
// cannot make a symlink where a mount point is.
func TestLinuxLinksNotMadeOverMounts(t *testing.T) {
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
usrBin, bin := filepath.Join(root, "usr", "bin"), filepath.Join(root, "bin")
home := filepath.Join(root, "home")
for _, d := range []string{usrBin, filepath.Join(home, "proj")} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.Symlink("usr/bin", bin); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin)
f, err := policy.Parse([]byte("file read $PATH\n"))
if err != nil {
t.Fatal(err)
}
env := policy.Env{Home: home, Project: filepath.Join(home, "proj"), SystemRead: []string{bin, filepath.Join(root, "usr")}}
c, err := policy.Compile(policy.Layer{Name: "policy", File: f}, policy.Floor{}, env)
if err != nil {
t.Fatal(err)
}
args, _, err := bwrapArgs(c, bwrapOpts{empty: "/x/empty"})
if err != nil {
t.Fatal(err)
}
if cmd := strings.Join(args, " "); strings.Contains(cmd, "--symlink") {
t.Errorf("made a link over a mount: %s", cmd)
}
}
// missingTop finds where a missing path starts, and what is above it; it
// stops at what it cannot look into, rather than taking that for missing.
func TestMissingTop(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "file"), nil, 0o600); err != nil {
t.Fatal(err)
}
for p, want := range map[string]string{
"a/b/c": "a",
"a": "a",
"file/x": "file/x",
} {
top, parent, err := missingTop(filepath.Join(dir, p))
if err != nil || top != filepath.Join(dir, want) || parent == nil {
t.Errorf("missingTop(%s) = %s, %v, %v", p, top, parent, err)
}
}
if _, parent, _ := missingTop(filepath.Join(dir, "file/x")); parent.IsDir() {
t.Error("a file's parent shows as a directory")
}
if os.Getuid() != 0 {
locked := filepath.Join(dir, "locked")
if err := os.Mkdir(locked, 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(locked, 0o700) })
if _, _, err := missingTop(filepath.Join(locked, "x/y")); err == nil {
t.Error("a directory blinker cannot look into is taken for missing")
}
}
}
|