diff --git a/internal/cli/sandbox.go b/internal/cli/sandbox.go index bb49fa8e1..c00963db2 100644 --- a/internal/cli/sandbox.go +++ b/internal/cli/sandbox.go @@ -174,7 +174,7 @@ func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps app if !setupHelper.Available() { return writeAppError(stderr, "Windows sandbox setup helper is not available", exitProvider) } - setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{ + setupPlan, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{ CommandCWD: workspaceRoot, WorkspaceRoots: []string{workspaceRoot}, PermissionProfile: profile, @@ -182,9 +182,18 @@ func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps app if err != nil { return writeAppError(stderr, err.Error(), exitCrash) } - setupArgs = append(append([]string{}, setupHelper.ArgsPrefix...), setupArgs...) + setupArgs := append(append([]string{}, setupHelper.ArgsPrefix...), setupPlan.Args...) + // BUILDING THE ARGS ALREADY WROTE TO DISK. Selecting the runtime root takes a + // lease, and taking a lease creates the runtime tree and the lease file when + // they are not there. A helper that never publishes a marker leaves that tree + // behind, attested by nothing, so the undo belongs on this side of the process + // boundary where the record of it lives. if err := deps.runSandboxSetupHelper(setupHelper.Name, setupArgs, stdout, stderr); err != nil { - return writeAppError(stderr, "Windows sandbox setup failed: "+err.Error(), exitProvider) + message := "Windows sandbox setup failed: " + err.Error() + if undo := setupPlan.Rollback(); undo != nil { + message += "\nleftover sandbox runtime state could not be removed: " + undo.Error() + } + return writeAppError(stderr, message, exitProvider) } return writeSandboxSetupResult(stdout, options.json, sandboxSetupResult{ Platform: "windows", diff --git a/internal/doctor/hardening.go b/internal/doctor/hardening.go index baf21e04c..e2d9dbeb7 100644 --- a/internal/doctor/hardening.go +++ b/internal/doctor/hardening.go @@ -99,11 +99,75 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo return &result } profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope) + // A RECORDED ROOT IS HISTORY, NOT PROOF. Doctor pins the marker's runtime + // root so the stamp check can run without taking a lease, but a command does + // not pin blindly: it derives the current cache and fallback candidates and + // honours the marker only when its root is one of them. Run setup with the + // cache at A, relocate the cache so commands derive B, and the stamped A tree + // remains: pinning A here reported a healthy sandbox immediately before every + // real command rejected A and failed on the out-of-date marker. + // + // So ask the command's own question first, through the command's own + // function. Only a root a command would still select is used to validate + // the marker; anything else is reported as out of date, with the remedy. + // + // THREE STATES, NOT TWO. The helper answers current, stale, or "I could not + // tell", and the last one used to fall through to the pinned branch: a valid + // old marker then made doctor report healthy while selectSandboxRuntimeRoot, + // reached by BuildCommandPlan, propagated the same resolution error and stopped + // every command from launching. Historical state is not a substitute for a + // failed current selection, so an error is surfaced as its own warning with the + // cause, and the pinned validation profile is built only once currentness has + // actually been established. + recorded, current, rootErr := sandbox.WindowsSandboxRecordedRuntimeRootIsCurrent(sandboxHome, workspaceRoot) + if rootErr != nil { + result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but the runtime root a command would select cannot be resolved (%s), so the recorded setup cannot be trusted and commands are likely to fail to launch.", backend.Name, rootErr.Error()), map[string]any{ + "backend": string(backend.Name), + "platform": goos, + "supportLevel": string(backend.SupportLevel()), + "setupStatus": "runtime-root-unresolved", + "error": rootErr.Error(), + "remedy": "run `zero sandbox setup` to prepare the Windows native sandbox for the current cache location", + }) + return &result + } + if recorded != "" && !current { + result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but the runtime root setup recorded (%s) is not one a command would select now, so the setup is out of date.", backend.Name, recorded), map[string]any{ + "backend": string(backend.Name), + "platform": goos, + "supportLevel": string(backend.SupportLevel()), + "setupStatus": "runtime-root-stale", + "runtimeRoot": recorded, + "remedy": "run `zero sandbox setup` to prepare the Windows native sandbox for the current cache location", + }) + return &result + } setupConfig := sandbox.WindowsSandboxSetupConfig{ - SandboxHome: sandboxHome, - CommandCWD: workspaceRoot, - WorkspaceRoots: []string{workspaceRoot}, - PermissionProfile: profile, + SandboxHome: sandboxHome, + CommandCWD: workspaceRoot, + WorkspaceRoots: []string{workspaceRoot}, + // The same augmentation setup and the command plan apply, so doctor + // fingerprints what a real command fingerprints. Checking the bare profile + // made doctor call a correctly prepared machine "out of date", which is the + // mismatch this pairing exists to close. Safe to resolve in this process: + // doctor runs in the operator's shell, not behind the sandbox TEMP + // redirection that stops the runner deriving these for itself. + // + // The RECORDED runtime root goes on the profile as well, because that is + // what makes the stamp check run at all. Without it profile.Runtime is nil, + // validateWindowsSandboxRuntimeStamp returns nil early, and doctor reported + // a healthy sandbox on a machine whose runtime tree had been evicted and + // silently recreated without the capability ACE -- precisely the state the + // stamp exists to catch, and the state where every sandboxed command then + // fails with nothing explaining why. + // + // Read from the marker rather than selected, so doctor takes no lease and + // creates nothing. A marker from an older schema records no root, which + // leaves this exactly as it was. + PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots( + sandbox.PermissionProfileWithRuntimeRoot(profile, sandbox.WindowsSandboxRecordedRuntimeRoot(sandboxHome)), + []string{workspaceRoot}, + ), } if err := sandbox.ValidateWindowsSandboxSetupMarker(setupConfig); err != nil { result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but Windows sandbox setup is missing or out of date: %v.", backend.Name, err), map[string]any{ diff --git a/internal/doctor/windows_runtime_stamp_test.go b/internal/doctor/windows_runtime_stamp_test.go new file mode 100644 index 000000000..ebab0f2ab --- /dev/null +++ b/internal/doctor/windows_runtime_stamp_test.go @@ -0,0 +1,193 @@ +package doctor + +import ( + "os" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/sandbox" +) + +// DOCTOR HAS TO ASK THE ONE QUESTION THE MARKER CANNOT ANSWER. +// +// The marker hashes ACL-plan entries, which are pathnames. Whether the directory +// those pathnames resolve to is still the tree setup provisioned is a different +// question, and the runtime stamp is what answers it. validateWindowsSandboxRuntimeStamp +// returns nil early when profile.Runtime is nil, which is correct for the setup +// side and for unrestricted profiles, and was wrong here: doctor built its +// profile with PermissionProfileFromPolicy, which never sets Runtime, so the +// check was skipped and `zero doctor` reported a healthy sandbox on exactly the +// state the stamp was added to detect -- an evicted runtime tree, silently +// recreated with ordinary permissions and no capability ACE, where every +// sandboxed command then fails with nothing explaining why. +// doctorRuntimeCandidate returns a runtime root this workspace would really +// select, taken from the candidate set the Windows plan folds in. +// +// It has to be a REAL candidate. An arbitrary path is already in the ACL plan on +// one side and not the other, so the plan hashes diverge and validation fails +// before the stamp is ever consulted -- which would make this test fail without +// the fix for a reason that has nothing to do with the stamp. +// redirectUserCache points os.UserCacheDir at test-owned storage. +// +// WITHOUT THIS THE TEST WRITES INTO THE DEVELOPER'S REAL CACHE. The runtime +// candidate is derived from the process's actual user cache directory, so the +// test was creating, recursively removing, recreating and then cleanup-removing +// a directory under the real %LocalAppData%zerountime (or ~/.cache/zero on +// Unix). The workspace digest made a collision with a live runtime tree +// unlikely rather than impossible, and "unlikely" is not the standard for a +// test that calls RemoveAll. +// +// All three variables, because os.UserCacheDir reads a different one per +// platform: %LocalAppData% on Windows, $XDG_CACHE_HOME or $HOME on Unix, and +// $HOME on macOS. +func redirectUserCache(t *testing.T) { + t.Helper() + cache := t.TempDir() + t.Setenv("LOCALAPPDATA", cache) + t.Setenv("XDG_CACHE_HOME", cache) + t.Setenv("HOME", cache) +} + +func doctorRuntimeCandidate(t *testing.T, workspace string) string { + t.Helper() + bare := doctorProfile(t, workspace) + augmented := sandbox.WindowsSandboxProfileWithRuntimeRoots(bare, []string{workspace}) + existing := map[string]bool{} + for _, root := range bare.FileSystem.WriteRoots { + existing[root.Root] = true + } + for _, root := range augmented.FileSystem.WriteRoots { + if !existing[root.Root] { + return root.Root + } + } + t.Skip("no runtime candidate is derivable in this environment") + return "" +} + +func doctorProfile(t *testing.T, workspace string) sandbox.PermissionProfile { + t.Helper() + scope, err := sandbox.NewScope(workspace, nil) + if err != nil { + t.Fatalf("NewScope: %v", err) + } + return sandbox.PermissionProfileFromPolicy(workspace, doctorSandboxPolicy(config.SandboxConfig{}), scope) +} + +func writeDoctorSetupMarker(t *testing.T, home, workspace, runtimeRoot string) { + t.Helper() + profile := doctorProfile(t, workspace) + setup := sandbox.WindowsSandboxSetupConfig{ + SandboxHome: home, + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots( + sandbox.PermissionProfileWithRuntimeRoot(profile, runtimeRoot), + []string{workspace}, + ), + } + if _, err := sandbox.WriteWindowsSandboxSetupMarker(setup); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } +} + +func TestDoctorReportsAnEvictedRuntimeTree(t *testing.T) { + home := t.TempDir() + workspace := t.TempDir() + t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home) + redirectUserCache(t) + + runtimeRoot := doctorRuntimeCandidate(t, workspace) + // Belt and braces: if the redirection above ever stops working, fail loudly + // rather than quietly operating on the developer's real cache. + if !strings.HasPrefix(runtimeRoot, os.TempDir()) && !strings.Contains(runtimeRoot, t.Name()) { + t.Fatalf("the runtime candidate %q is outside test-owned storage; this test creates and removes that path", runtimeRoot) + } + if err := os.MkdirAll(runtimeRoot, 0o700); err != nil { + t.Fatalf("create the runtime root: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(runtimeRoot) }) + writeDoctorSetupMarker(t, home, workspace, runtimeRoot) + + backend := sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken} + + // A healthy machine first, or the eviction assertion below would be satisfied + // by a check that warns unconditionally. + if result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{}); result != nil { + t.Fatalf("a freshly set-up machine was reported unhealthy: %s", result.Message) + } + + // cleanupSandboxRuntimeRoots evicts inactive roots on an age and count policy, + // and the next run recreates the pathname with inherited permissions. The plan + // hash never moves, so only the stamp can tell. + if err := os.RemoveAll(runtimeRoot); err != nil { + t.Fatalf("evict the runtime root: %v", err) + } + if err := os.MkdirAll(runtimeRoot, 0o700); err != nil { + t.Fatalf("recreate the pathname the way an ordinary run would: %v", err) + } + + result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{}) + if result == nil { + t.Fatal("doctor reported a healthy sandbox while the provisioned runtime tree was gone; every sandboxed command on this machine would fail with nothing explaining why") + } + if !strings.Contains(strings.ToLower(result.Message), "setup") { + t.Errorf("the warning does not point at setup: %s", result.Message) + } +} + +// UNKNOWN IS NOT CURRENT. +// +// WindowsSandboxRecordedRuntimeRootIsCurrent answers three ways: the recorded +// root is still one a command would select, it is not, or the inputs a command +// selects from cannot be resolved at all. The third used to fall through to the +// pinned branch, so a valid old marker and stamp made doctor report healthy +// while selectSandboxRuntimeRoot — reached by BuildCommandPlan — propagated the +// same resolution failure and stopped every command from launching. Doctor and +// the command disagreed about the same machine. +// +// Stale and unknown are different states, so this is deliberately separate from +// the cache-A-to-cache-B test rather than another assertion on the same boolean. +func TestDoctorWarnsWhenTheRuntimeRootCannotBeResolved(t *testing.T) { + home := t.TempDir() + workspace := t.TempDir() + t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home) + redirectUserCache(t) + + runtimeRoot := doctorRuntimeCandidate(t, workspace) + if !strings.HasPrefix(runtimeRoot, os.TempDir()) && !strings.Contains(runtimeRoot, t.Name()) { + t.Fatalf("the runtime candidate %q is outside test-owned storage", runtimeRoot) + } + if err := os.MkdirAll(runtimeRoot, 0o700); err != nil { + t.Fatalf("create the runtime root: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(runtimeRoot) }) + writeDoctorSetupMarker(t, home, workspace, runtimeRoot) + + backend := sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken} + + // Healthy first, so the assertion below cannot be satisfied by a check that + // warns unconditionally. + if result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{}); result != nil { + t.Fatalf("SETUP INVALID: a freshly set-up machine was reported unhealthy: %s", result.Message) + } + + // Now take away the inputs the selection is derived from, leaving the marker + // and the provisioned tree exactly as they were. os.UserCacheDir fails with no + // location to read, which is the same error a command would hit. + t.Setenv("LOCALAPPDATA", "") + t.Setenv("XDG_CACHE_HOME", "") + t.Setenv("HOME", "") + + result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{}) + if result == nil { + t.Fatal("doctor reported a healthy sandbox while the runtime root a command selects could not be resolved; every command would fail to launch with doctor saying nothing") + } + if status, _ := result.Details["setupStatus"].(string); status != "runtime-root-unresolved" { + t.Errorf("setupStatus = %q, want runtime-root-unresolved so the unknown state is not reported as staleness: %s", status, result.Message) + } + if _, ok := result.Details["error"]; !ok { + t.Errorf("the warning does not carry the resolver cause, so an operator cannot act on it: %+v", result.Details) + } +} diff --git a/internal/sandbox/main_test.go b/internal/sandbox/main_test.go new file mode 100644 index 000000000..d1ba53615 --- /dev/null +++ b/internal/sandbox/main_test.go @@ -0,0 +1,35 @@ +package sandbox + +import ( + "os" + "testing" +) + +// TestMain points the sandbox runtime's user-cache root at test-owned storage +// for the whole package. +// +// PLAN CONSTRUCTION CREATES DIRECTORIES, which is easy to miss because it reads +// like naming. windowsSandboxProfileWithProvisionedRuntime provisions the root +// it selects, and the simulated-Windows tests run on every platform, so a test +// that supplies an explicit child environment but leaves the cache alone writes +// into the developer's real one. Not hypothetical: the machine this was found on +// had accumulated thousands of entries under the real runtime root from exactly +// these runs. +// +// Done once for the package rather than per test, because the leak is in the +// DEFAULT: any new test that builds a Windows plan is affected unless its author +// remembers, and remembering is what failed here. A test that needs a specific +// cache root still overrides sandboxUserCacheDir itself. +func TestMain(m *testing.M) { + root, err := os.MkdirTemp("", "zero-sandbox-testcache-") + if err != nil { + // Fail loudly rather than silently falling back to the real cache. + panic("sandbox tests: create the test cache root: " + err.Error()) + } + sandboxUserCacheDir = func() (string, error) { return root, nil } + + code := m.Run() + + _ = os.RemoveAll(root) + os.Exit(code) +} diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index 8528e7e82..6b9e8a07d 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -189,7 +189,18 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) { } var runtimeCleanup func() if preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled { - runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot) + // The home THIS command asked for. Windows planning resolves + // ZERO_WINDOWS_SANDBOX_HOME out of spec.Env and hands it to the runner for + // marker validation, so selection has to read the same environment or the + // two disagree about which marker describes the tree. See + // pinnedSandboxRuntimeRoot. + commandSandboxHome := "" + if spec.Env != nil { + if resolved, err := ResolveWindowsSandboxHome(envListToMap(spec.Env)); err == nil { + commandSandboxHome = resolved + } + } + runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot, commandSandboxHome) if runtimeErr != nil { return CommandPlan{}, runtimeErr } diff --git a/internal/sandbox/runner_windows_integration_test.go b/internal/sandbox/runner_windows_integration_test.go index fb98f1287..a33258488 100644 --- a/internal/sandbox/runner_windows_integration_test.go +++ b/internal/sandbox/runner_windows_integration_test.go @@ -352,10 +352,11 @@ func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) strin func runWindowsRealSmokeSetup(t *testing.T, setupExe string, options WindowsSandboxSetupArgsOptions) { t.Helper() - args, err := BuildWindowsSandboxSetupArgs(options) + setupPlan, err := BuildWindowsSandboxSetupArgs(options) if err != nil { t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) } + args := setupPlan.Args ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() cmd := exec.CommandContext(ctx, setupExe, args...) diff --git a/internal/sandbox/runtime_bound_records_test.go b/internal/sandbox/runtime_bound_records_test.go new file mode 100644 index 000000000..de5c63e4e --- /dev/null +++ b/internal/sandbox/runtime_bound_records_test.go @@ -0,0 +1,88 @@ +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// THE TWO HALVES OF A ROLLBACK RECORD MUST DESCRIBE ONE OBJECT. +// +// The snapshot used to read the identity through a handle, close it, and then +// re-resolve the pathname to read the stamp. A rename between those pairs one +// directory's identity with another's bytes, and a rollback that correctly +// proves it holds the first then writes the second's contents into it. +// +// The window itself needs an elevated installer racing an unelevated renamer and +// is not reproducible here, so this pins the contract the binding provides: both +// facts come back together, and they agree with the object actually at the path. +func TestStampSnapshotPairsIdentityWithItsOwnBytes(t *testing.T) { + root := filepath.Join(t.TempDir(), "root") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + const contents = "the stamp that belongs to this directory" + if err := os.WriteFile(windowsSandboxRuntimeStampPath(root), []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + + identity, identified, prior, state, err := snapshotRuntimeStampBound(root) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + existed := state == runtimeStampPresent + if !identified { + t.Fatal("the snapshot established no identity for a directory that exists") + } + if !existed || string(prior) != contents { + t.Fatalf("prior stamp = %q existed=%v, want %q", string(prior), existed, contents) + } + if direct, ok := runtimeDirIdentity(root); !ok || direct != identity { + t.Errorf("snapshot identity %q does not describe the directory at the path (%q)", identity, direct) + } +} + +// An absent stamp still establishes the identity, because that came from the +// directory handle and not from the stamp read. +func TestStampSnapshotIdentifiesARootWithNoStamp(t *testing.T) { + root := filepath.Join(t.TempDir(), "root") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + identity, identified, _, state, err := snapshotRuntimeStampBound(root) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + existed := state == runtimeStampPresent + if !identified || identity == "" { + t.Error("a root with no stamp established no identity") + } + if existed { + t.Error("reported a prior stamp that does not exist") + } +} + +// A created directory's identity must come from the creation, so the ledger +// cannot record an object this run did not make. +func TestCreatedDirectoryIdentityDescribesWhatWasCreated(t *testing.T) { + path := filepath.Join(t.TempDir(), "created") + + identity, identified, err := createRuntimeDirIdentified(path) + if err != nil { + t.Fatalf("create: %v", err) + } + if !identified || identity == "" { + t.Fatal("creation established no identity") + } + if info, statErr := os.Stat(path); statErr != nil || !info.IsDir() { + t.Fatalf("the directory was not created: %v", statErr) + } + if direct, ok := runtimeDirIdentity(path); !ok || direct != identity { + t.Errorf("creation identity %q does not describe the directory now at the path (%q)", identity, direct) + } + + // Creating over something that exists is the caller's already-handled signal. + if _, _, again := createRuntimeDirIdentified(path); !os.IsExist(again) { + t.Errorf("creating over an existing directory returned %v, want an IsExist error", again) + } +} diff --git a/internal/sandbox/runtime_compensation_identity_test.go b/internal/sandbox/runtime_compensation_identity_test.go new file mode 100644 index 000000000..1bd96b58b --- /dev/null +++ b/internal/sandbox/runtime_compensation_identity_test.go @@ -0,0 +1,112 @@ +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// COMPENSATION MUST PROVE IT HOLDS THE OBJECT IT CHANGED. +// +// The forward apply and its stamp go through one handle, so they are provably +// about one object. Compensation runs later, after those handles have closed, +// and used to resolve the pathname again. Rename the original aside, put an +// ordinary directory at the name, and a pathname-only undo strips a stamp from +// the substitute, or writes bytes snapshotted from another object onto it, and +// then removes it as though this run had created it, while the moved original +// keeps this run's grant and stamp. +func TestStampCompensationRefusesAReplacementDirectory(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "runtime-root") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + stampPath := windowsSandboxRuntimeStampPath(root) + if err := os.WriteFile(stampPath, []byte("previous-plan-hash"), 0o600); err != nil { + t.Fatal(err) + } + + snapshot, err := snapshotWindowsSandboxRuntimeStamp(root) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if snapshot.priorState != runtimeStampPresent { + t.Fatal("SETUP INVALID: the snapshot did not record the pre-existing stamp") + } + + // The original is moved aside and an ordinary directory takes the name. + moved := filepath.Join(parent, "moved-aside") + if err := os.Rename(root, moved); err != nil { + t.Skipf("cannot rename the runtime root on this filesystem: %v", err) + } + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + substituteStamp := filepath.Join(root, filepath.Base(stampPath)) + if err := os.WriteFile(substituteStamp, []byte("not-ours"), 0o600); err != nil { + t.Fatal(err) + } + + err = snapshot.restore() + if err == nil { + t.Fatal("compensation mutated a directory it never touched and reported success") + } + for _, want := range []string{"no longer the directory", "replacement"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the error does not say what was left behind (%q): %v", want, err) + } + } + if body, readErr := os.ReadFile(substituteStamp); readErr != nil || string(body) != "not-ours" { + t.Errorf("the substitute's stamp was overwritten: %q %v", string(body), readErr) + } + if body, readErr := os.ReadFile(filepath.Join(moved, filepath.Base(stampPath))); readErr != nil || string(body) != "previous-plan-hash" { + t.Errorf("the original lost its stamp: %q %v", string(body), readErr) + } +} + +// And the created-directory ledger applies the same rule, or the stamp check +// just moves the damage one line down. +func TestCreatedDirectoryCompensationRefusesAReplacement(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "created-root") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + rollback := windowsRuntimeRootRollback{created: createdRuntimeDirsForTest(root)} + + moved := filepath.Join(parent, "moved") + if err := os.Rename(root, moved); err != nil { + t.Skipf("cannot rename on this filesystem: %v", err) + } + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + + err := rollback.run() + if err == nil { + t.Fatal("a substitute directory was removed as though this run had created it") + } + if !strings.Contains(err.Error(), "no longer the directory this run created") { + t.Errorf("the error does not name the replacement: %v", err) + } + if _, statErr := os.Stat(root); statErr != nil { + t.Errorf("the substitute was removed: %v", statErr) + } +} + +// The ordinary case still cleans up, or the guard would be refusing everything. +func TestCompensationStillRemovesWhatThisRunCreated(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "mine") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + rollback := windowsRuntimeRootRollback{created: createdRuntimeDirsForTest(root)} + if err := rollback.run(); err != nil { + t.Fatalf("compensation refused a directory it really did create: %v", err) + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Errorf("the directory this run created survived compensation: %v", err) + } +} diff --git a/internal/sandbox/runtime_compensation_other.go b/internal/sandbox/runtime_compensation_other.go new file mode 100644 index 000000000..fd23d3d91 --- /dev/null +++ b/internal/sandbox/runtime_compensation_other.go @@ -0,0 +1,57 @@ +//go:build !windows + +package sandbox + +import ( + "fmt" + "os" +) + +// runtimeCompensationSwapSeam exists so the shared compensation code compiles +// everywhere. Only the Windows build closes a check-then-mutate window, because +// only there does setup run elevated against a tree an unelevated process can +// rename. +var runtimeCompensationSwapSeam func() + +func compensateRuntimeStampBound(root string, identity string, prior []byte, existed bool) error { + current, ok := runtimeDirIdentity(root) + if !ok { + return fmt.Errorf("identify the sandbox runtime root %s for stamp compensation", root) + } + if current != identity { + return fmt.Errorf("sandbox runtime root %s is no longer the directory this setup stamped; "+ + "leaving the replacement untouched, and the original still carries this run's stamp", root) + } + if runtimeCompensationSwapSeam != nil { + runtimeCompensationSwapSeam() + } + path := windowsSandboxRuntimeStampPath(root) + if !existed { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err) + } + return nil + } + if err := os.WriteFile(path, prior, 0o600); err != nil { + return fmt.Errorf("restore the previous sandbox runtime setup stamp: %w", err) + } + return nil +} + +func removeCreatedRuntimeDirBound(path string, identity string) error { + current, ok := runtimeDirIdentity(path) + if !ok { + return nil + } + if current != identity { + return fmt.Errorf("sandbox runtime root %s is no longer the directory this run created; "+ + "leaving the replacement in place", path) + } + if runtimeCompensationSwapSeam != nil { + runtimeCompensationSwapSeam() + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove sandbox runtime root %s created by this run: %w", path, err) + } + return nil +} diff --git a/internal/sandbox/runtime_compensation_swap_windows_test.go b/internal/sandbox/runtime_compensation_swap_windows_test.go new file mode 100644 index 000000000..a5f95e7ed --- /dev/null +++ b/internal/sandbox/runtime_compensation_swap_windows_test.go @@ -0,0 +1,100 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// swapDuringCompensation installs a seam that runs once, between the identity +// check and the mutation, and replaces root with a fresh directory carrying a +// file of its own. +func swapDuringCompensation(t *testing.T, root string, aside string) *string { + t.Helper() + fired := false + previous := runtimeCompensationSwapSeam + t.Cleanup(func() { runtimeCompensationSwapSeam = previous }) + runtimeCompensationSwapSeam = func() { + if fired { + return + } + fired = true + if err := os.Rename(root, aside); err != nil { + t.Logf("could not rename the original aside: %v", err) + return + } + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the substitute: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "substitute.txt"), []byte("belongs to whoever put it here"), 0o600); err != nil { + t.Fatalf("seed the substitute: %v", err) + } + } + return &aside +} + +// A REPLACEMENT AFTER THE CHECK MUST NOT REACH THE SUBSTITUTE. +// +// Compensation used to read the identity through a handle, close it, and then +// resolve the pathname again for the write or the delete. A rename plus a +// replacement in that interval made the comparison true about one object while +// the mutation landed on another, under elevation. Holding the handle across +// both means the mutation follows the object that was verified, and whatever now +// answers to the name is untouched. +func TestStampCompensationDoesNotReachASubstitute(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "zero", "runtime", "v1", "abcd") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime root: %v", err) + } + if err := writeWindowsSandboxRuntimeStamp(root, "planhash"); err != nil { + t.Fatalf("write the stamp: %v", err) + } + identity, ok := runtimeDirIdentity(root) + if !ok { + t.Fatal("identify the runtime root") + } + aside := filepath.Join(parent, "renamed-aside") + swapDuringCompensation(t, root, aside) + + // Removal of a stamp this run wrote, i.e. the fresh-setup rollback. + _ = compensateRuntimeStampBound(root, identity, nil, false) + + // Whatever the outcome, the substitute is not this run's business. + substitute := filepath.Join(root, "substitute.txt") + if _, err := os.Stat(substitute); err != nil { + t.Errorf("compensation removed a file from the substitute directory: %v", err) + } + if _, err := os.Stat(windowsSandboxRuntimeStampPath(root)); err == nil { + t.Error("compensation created a stamp inside the substitute directory") + } + // And the original, which is the object that was verified, is the one that + // lost the stamp this run wrote. + if _, err := os.Stat(windowsSandboxRuntimeStampPath(aside)); err == nil { + t.Error("the stamp this run wrote is still on the original object, so compensation followed the name instead") + } +} + +// The same for the delete, where following the name would remove a directory +// this run never created. +func TestDirectoryCompensationDoesNotRemoveASubstitute(t *testing.T) { + parent := t.TempDir() + created := filepath.Join(parent, "created") + if err := os.MkdirAll(created, 0o700); err != nil { + t.Fatalf("create the directory: %v", err) + } + identity, ok := runtimeDirIdentity(created) + if !ok { + t.Fatal("identify the created directory") + } + aside := filepath.Join(parent, "renamed-aside") + swapDuringCompensation(t, created, aside) + + _ = removeCreatedRuntimeDirBound(created, identity) + + if _, err := os.Stat(filepath.Join(created, "substitute.txt")); err != nil { + t.Errorf("compensation removed the substitute directory's contents: %v", err) + } +} diff --git a/internal/sandbox/runtime_compensation_verify_windows_test.go b/internal/sandbox/runtime_compensation_verify_windows_test.go new file mode 100644 index 000000000..3f5d547c2 --- /dev/null +++ b/internal/sandbox/runtime_compensation_verify_windows_test.go @@ -0,0 +1,78 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +// ONLY NOT-FOUND IS EVIDENCE OF REMOVAL. +// +// The verification probe used to read any Lstat error as absence, so a sharing +// violation, an access denial, or an entry left delete-pending by another +// process that holds a share-delete handle all reported complete compensation +// for a directory that is still there. A holder able to clear the disposition +// could then make the "removed" object visible again, and setup would already +// have said the rollback finished. +// +// The three outcomes are distinguished with a probe seam, because a real +// filesystem will not produce the third on demand. +func TestDeletionVerificationDistinguishesAllThreeOutcomes(t *testing.T) { + previous := runtimeCompensationStat + t.Cleanup(func() { runtimeCompensationStat = previous }) + + newDir := func() (string, string) { + t.Helper() + dir := filepath.Join(t.TempDir(), "created") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + identity, ok := runtimeDirIdentity(dir) + if !ok { + t.Fatal("identify the created directory") + } + return dir, identity + } + + t.Run("absence is proven and reported as success", func(t *testing.T) { + dir, identity := newDir() + runtimeCompensationStat = func(string) (fs.FileInfo, error) { + return nil, &os.PathError{Op: "lstat", Path: dir, Err: os.ErrNotExist} + } + if err := removeCreatedRuntimeDirBound(dir, identity); err != nil { + t.Errorf("a proven-absent directory was reported as a failure: %v", err) + } + }) + + t.Run("still present is reported", func(t *testing.T) { + dir, identity := newDir() + runtimeCompensationStat = func(path string) (fs.FileInfo, error) { return os.Stat(".") } + err := removeCreatedRuntimeDirBound(dir, identity) + if err == nil { + t.Fatal("a directory still present after deletion was reported as removed") + } + if !strings.Contains(err.Error(), "still present") { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("an unverifiable probe is residue, not success", func(t *testing.T) { + dir, identity := newDir() + // What a share-delete holder produces: neither absence nor presence. + runtimeCompensationStat = func(path string) (fs.FileInfo, error) { + return nil, &os.PathError{Op: "lstat", Path: path, Err: errors.New("Access is denied.")} + } + err := removeCreatedRuntimeDirBound(dir, identity) + if err == nil { + t.Fatal("an unverifiable removal was reported as complete compensation") + } + if !strings.Contains(err.Error(), "could not be verified") { + t.Errorf("the error does not say the removal is unproven: %v", err) + } + }) +} diff --git a/internal/sandbox/runtime_compensation_windows.go b/internal/sandbox/runtime_compensation_windows.go new file mode 100644 index 000000000..673c4313d --- /dev/null +++ b/internal/sandbox/runtime_compensation_windows.go @@ -0,0 +1,186 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "fmt" + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +// THE HANDLE IS THE OBJECT. A pathname is not. +// +// Compensation used to read the directory identity through a handle, close it, +// and then resolve the pathname again for the mutation. A rename followed by a +// replacement between those two steps makes the comparison true about one +// object while the write or delete lands on another, and this runs elevated, so +// a redirected pathname gives the mutation reach the replacer does not have +// directly. +// +// Opening once and keeping the handle open across BOTH the identity check and +// the mutation removes the interval. The child operations are relative to that +// handle, so no ancestor is re-resolved either. + +// runtimeCompensationSwapSeam runs between the identity check and the mutation. +// Nil in production; a test installs one to replace the directory in exactly the +// window this design closes. +var runtimeCompensationSwapSeam func() + +// runtimeCompensationStat is the post-deletion existence probe. A var so a test +// can produce the third outcome, an inspection that neither proves absence nor +// presence, which no real filesystem produces on demand. +var runtimeCompensationStat = os.Lstat + +type fileDispositionInfo struct { + DeleteFile byte +} + +func handleRuntimeIdentity(handle windows.Handle) (string, error) { + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return "", err + } + return fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow), nil +} + +// openVerifiedRuntimeDirectory opens path without following a link at the final +// component and returns it only if it is still the object identity names. +func openVerifiedRuntimeDirectory(path string, identity string, access uint32, what string) (windows.Handle, error) { + utf16Path, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + handle, err := windows.CreateFile( + utf16Path, + access|windows.FILE_READ_ATTRIBUTES, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return 0, err + } + current, err := handleRuntimeIdentity(handle) + if err != nil { + _ = windows.CloseHandle(handle) + return 0, fmt.Errorf("identify the sandbox runtime directory %s for compensation: %w", path, err) + } + if current != identity { + _ = windows.CloseHandle(handle) + return 0, fmt.Errorf("sandbox runtime root %s is no longer the directory this run %s; "+ + "leaving the replacement in place, and the original still carries this run's changes", path, what) + } + return handle, nil +} + +// markForDeletion queues the object the handle names for removal. It never +// resolves a pathname, so it can only reach what this process already holds. +func markForDeletion(handle windows.Handle) error { + info := fileDispositionInfo{DeleteFile: 1} + return windows.SetFileInformationByHandle( + handle, + windows.FileDispositionInfo, + (*byte)(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ) +} + +// compensateRuntimeStampBound restores or removes the stamp through a handle on +// the directory whose identity still matches. +// +// Both branches DELETE first. The stamp carries a protected DACL that withholds +// write from the root owner, so an in-place overwrite is denied under the very +// token that wrote it; and recreating it through the ordinary writer is what +// puts that DACL back, which a raw write would not. +func compensateRuntimeStampBound(root string, identity string, prior []byte, existed bool) error { + directory, err := openVerifiedRuntimeDirectory(root, identity, + windows.FILE_TRAVERSE|windowsFileAddFile|windows.READ_CONTROL|windows.SYNCHRONIZE, "stamped") + if err != nil { + return err + } + defer windows.CloseHandle(directory) + + if runtimeCompensationSwapSeam != nil { + runtimeCompensationSwapSeam() + } + + if err := deleteRuntimeStampChild(directory); err != nil { + return err + } + if !existed { + return nil + } + // Recreated through the ordinary writer so it is protected again, and so the + // reader ACE is resolved the same way a fresh setup resolves it. + if err := writeWindowsRuntimeStampToDirectoryHandle(directory, string(prior)); err != nil { + return fmt.Errorf("restore the previous sandbox runtime setup stamp: %w", err) + } + return nil +} + +// deleteRuntimeStampChild removes the stamp relative to an already verified +// directory handle. A stamp that is not there is the desired end state; anything +// else is reported rather than swallowed. +func deleteRuntimeStampChild(directory windows.Handle) error { + stamp, err := openWindowsChildNoFollow(directory, windowsSandboxRuntimeStampName, + windows.DELETE|windows.FILE_READ_ATTRIBUTES, windows.FILE_NON_DIRECTORY_FILE) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) || errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err) + } + if err := markForDeletion(stamp); err != nil { + _ = windows.CloseHandle(stamp) + return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err) + } + // The entry goes when the last handle closes. + _ = windows.CloseHandle(stamp) + return nil +} + +// removeCreatedRuntimeDirBound removes a directory this run created, through a +// handle on the object identity names. +func removeCreatedRuntimeDirBound(path string, identity string) error { + handle, err := openVerifiedRuntimeDirectory(path, identity, windows.DELETE, "created") + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if runtimeCompensationSwapSeam != nil { + runtimeCompensationSwapSeam() + } + if err := markForDeletion(handle); err != nil { + _ = windows.CloseHandle(handle) + return fmt.Errorf("remove sandbox runtime root %s created by this run: %w", path, err) + } + // The entry goes when the last handle closes, so releasing ours is what makes + // the removal observable. + _ = windows.CloseHandle(handle) + // REPORTED SUCCESS IS NOT PROOF. A handle-bound operation accepting the call + // has been seen not to take effect (PR #751, the promote rename), so the + // outcome is checked rather than assumed. + // + // THREE OUTCOMES, NOT TWO. This used to read any Lstat error as absence, so a + // sharing violation, an access denial, or a delete-pending entry held open by + // another process all reported complete compensation for a directory that is + // still there. A holder that clears the disposition can then make the + // "removed" object visible again. Only not-found is evidence of removal; + // everything else is at best unproven and has to be reported as residue. + _, statErr := runtimeCompensationStat(path) + switch { + case errors.Is(statErr, os.ErrNotExist): + return nil + case statErr == nil: + return fmt.Errorf("remove sandbox runtime root %s created by this run: it is still present after the deletion was accepted", path) + default: + return fmt.Errorf("remove sandbox runtime root %s created by this run: its removal could not be verified, so it may still be present: %w", path, statErr) + } +} diff --git a/internal/sandbox/runtime_create.go b/internal/sandbox/runtime_create.go new file mode 100644 index 000000000..c7e2336ba --- /dev/null +++ b/internal/sandbox/runtime_create.go @@ -0,0 +1,15 @@ +package sandbox + +// runtimeIdentityAfterCreate resolves a just-created runtime directory's +// identity BY PATHNAME, which is a second resolution of the same name and +// therefore a window: the directory created can be renamed away and an ordinary +// one substituted before this runs, and the ledger then records the substitute +// for a directory the run never made. +// +// It is the honest implementation off Windows, where the elevated-installer +// versus unelevated-renamer split this closes does not apply. On Windows the +// creation returns the handle and identity is read from that, so nothing there +// may call this: a Windows test asserts the count is zero, which is what makes +// "the creation establishes the identity" a checked property rather than a +// comment. +var runtimeIdentityAfterCreate = runtimeDirIdentity diff --git a/internal/sandbox/runtime_create_other.go b/internal/sandbox/runtime_create_other.go new file mode 100644 index 000000000..59c5a08bc --- /dev/null +++ b/internal/sandbox/runtime_create_other.go @@ -0,0 +1,15 @@ +//go:build !windows + +package sandbox + +import "os" + +// createRuntimeDirIdentified keeps the pathname form off Windows, where the +// elevated-installer-versus-unelevated-renamer split this closes does not apply. +func createRuntimeDirIdentified(path string) (string, bool, error) { + if err := os.Mkdir(path, 0o700); err != nil { + return "", false, err + } + identity, ok := runtimeIdentityAfterCreate(path) + return identity, ok, nil +} diff --git a/internal/sandbox/runtime_create_windows.go b/internal/sandbox/runtime_create_windows.go new file mode 100644 index 000000000..0c3e7c69b --- /dev/null +++ b/internal/sandbox/runtime_create_windows.go @@ -0,0 +1,113 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "unsafe" + + "golang.org/x/sys/windows" +) + +// createRuntimeDirIdentified creates path and returns the identity of the +// directory it created, read from the handle the creation itself returned. +// +// THE CREATION MUST BE THE THING THAT ESTABLISHES IDENTITY. os.Mkdir followed by +// runtimeDirIdentity(path) is two resolutions of one name: Mkdir creates A, and +// the reopen can land on a B substituted in between, so the rollback ledger +// records B's identity for a directory this run never made. Compensation then +// correctly proves it holds B and deletes it, while A keeps this run's ACL and +// stamp under a name nothing is tracking. The runtime parent belongs to the +// ordinary user, so that substitution needs no privilege. +// +// Win32 CreateFile cannot create a directory at all, whatever disposition it is +// given, so an earlier attempt to do this with CREATE_NEW fell through to the +// Mkdir-plus-reopen path on EVERY real creation and the documented handle +// contract never once held. NtCreateFile with FILE_CREATE and +// FILE_DIRECTORY_FILE does create one, and returns the handle to it. +// +// If the atomic create cannot be completed, this returns an error rather than +// manufacturing an ownership record from a reopen: an unidentified create must +// stop setup before privileged state is applied, not enter the ledger as though +// it were proven. +func createRuntimeDirIdentified(path string) (string, bool, error) { + clean := filepath.Clean(path) + parentPath, leaf := filepath.Split(clean) + leaf = filepath.Clean(leaf) + parentPath = filepath.Clean(parentPath) + if leaf == "" || leaf == "." || parentPath == clean { + return "", false, fmt.Errorf("sandbox runtime path %s has no component to create", path) + } + + // The parent is either a directory that already existed when the missing + // components were computed, or one this same loop created a moment ago. + parent, err := openWindowsDirectoryByName(parentPath) + if err != nil { + return "", false, fmt.Errorf("open sandbox runtime parent %s: %w", parentPath, err) + } + defer windows.CloseHandle(parent) + + handle, err := createWindowsChildDirectory(parent, leaf) + if err != nil { + // A collision is the same signal os.Mkdir gives for a component another + // process won the race to create, and the caller already treats that as + // "not ours" (windows_setup.go). Returned as a *PathError so BOTH + // os.IsExist, which the caller uses and which does not unwrap %w, and + // errors.Is recognize it; wrapping with %w alone would have turned a + // benign race into a hard setup failure. + if errors.Is(err, windows.STATUS_OBJECT_NAME_COLLISION) { + return "", false, &os.PathError{Op: "mkdir", Path: clean, Err: os.ErrExist} + } + return "", false, fmt.Errorf("create sandbox runtime directory %s: %w", clean, err) + } + defer windows.CloseHandle(handle) + + identity, idErr := handleRuntimeIdentity(handle) + if idErr != nil { + return "", false, fmt.Errorf("identify the sandbox runtime directory created at %s: %w", clean, idErr) + } + return identity, true, nil +} + +// createWindowsChildDirectory creates exactly one directory component beneath +// parent and returns the handle to the object it created. +// +// Relative to a handle, so no ancestor is re-resolved and there is no interval +// for a swap to land in. FILE_CREATE fails rather than opening anything that is +// already there, so the returned handle cannot describe a pre-existing object, +// and FILE_OPEN_REPARSE_POINT keeps the failure honest if one is. +func createWindowsChildDirectory(parent windows.Handle, name string) (windows.Handle, error) { + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return 0, fmt.Errorf("encode sandbox runtime component %s: %w", name, err) + } + attributes := windows.OBJECT_ATTRIBUTES{ + RootDirectory: parent, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + var handle windows.Handle + var iosb windows.IO_STATUS_BLOCK + err = windows.NtCreateFile( + &handle, + windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE|windows.SYNCHRONIZE, + &attributes, + &iosb, + nil, + windows.FILE_ATTRIBUTE_DIRECTORY, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_CREATE, + windows.FILE_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, + ) + if err != nil { + return 0, err + } + return handle, nil +} diff --git a/internal/sandbox/runtime_descend_other.go b/internal/sandbox/runtime_descend_other.go new file mode 100644 index 000000000..6704c9425 --- /dev/null +++ b/internal/sandbox/runtime_descend_other.go @@ -0,0 +1,24 @@ +//go:build !windows + +package sandbox + +import "golang.org/x/sys/unix" + +// createRuntimeTailHandleRelative creates the owned tail through the same +// no-follow boundary the lease uses. +// +// This was an os.Mkdir loop over full pathnames, on the reasoning that the +// elevated-installer-versus-unelevated-renamer split the Windows descent closes +// does not apply here. That is true of the elevation asymmetry and false of the +// substitution: the fallback root is a predictable name under a temp directory +// every local account can write to, so another user can put a link at an owned +// component and have this loop create the rest of the tree inside it. Same +// descent, same refusal, one implementation per platform rather than one +// protected platform. +func createRuntimeTailHandleRelative(base string, tail []string) ([]windowsCreatedRuntimeDir, error) { + created, parent, err := createRuntimeTailRetainingFD(base, tail) + if parent >= 0 { + _ = unix.Close(parent) + } + return created, err +} diff --git a/internal/sandbox/runtime_descend_seams.go b/internal/sandbox/runtime_descend_seams.go new file mode 100644 index 000000000..8102b31d6 --- /dev/null +++ b/internal/sandbox/runtime_descend_seams.go @@ -0,0 +1,22 @@ +package sandbox + +// The two seams the rooted descent exposes, shared by both platform +// implementations so a test asserts the same property on either. +// +// They live apart from the Windows descent they started in because the POSIX +// fallback now takes the same shape, and a seam defined next to one of two +// implementations is how the other one quietly ends up untested. + +// runtimeDescentBarrier, when set, runs after the base directory has been opened +// and before the first owned component is touched. It exists so a test can swap +// an owned component for a link at exactly the point the old pathname walk was +// vulnerable, and prove the redirected target is never created or granted. Nil in +// production. +var runtimeDescentBarrier func() + +// runtimeBaseOpenedByName, when set, receives the ONE path this descent opens by +// name. The whole security property is which path that is: the fixed cache or +// temp directory above the owned tail, never a predictable component Zero owns. A +// test can assert it directly instead of inferring it from whether a swap +// happened to be caught, which is not discriminating. Nil in production. +var runtimeBaseOpenedByName func(string) diff --git a/internal/sandbox/runtime_descend_unix.go b/internal/sandbox/runtime_descend_unix.go new file mode 100644 index 000000000..034d4ce64 --- /dev/null +++ b/internal/sandbox/runtime_descend_unix.go @@ -0,0 +1,147 @@ +//go:build !windows + +package sandbox + +import ( + "errors" + "fmt" + "path/filepath" + "strconv" + + "golang.org/x/sys/unix" +) + +// createRuntimeTailRetainingFD creates the owned tail of the runtime root +// beneath base, one component at a time, from retained descriptors. +// +// THE NAME IS NOT AUTHORIZATION, ON THIS SIDE EITHER. +// +// The fallback root moved from an atomically minted os.MkdirTemp parent to a +// predictable /tmp/zero-u/runtime/v1/. The stable name is required, +// because setup and every later process have to agree on one root without +// talking to each other, but it also means another local account can name the +// first owned component before this one does. refuseAliasedRuntimeComponents +// answers about a component that is ABSENT by saying there is nothing to alias, +// and the code then called os.MkdirAll on the parent and opened .lease by +// full pathname. Both follow a link planted after that answer, so the guard was +// authorizing writes it could not see the destination of. +// +// So the base is opened by name exactly once, and every component below it is +// created or opened relative to a retained descriptor with O_NOFOLLOW. A link at +// an owned component is then an ELOOP or ENOTDIR from the kernel rather than a +// redirection nobody noticed, and no owned name is ever resolved twice. +// +// The base itself is opened WITHOUT O_NOFOLLOW. It belongs to the operator and is +// legitimately a link on macOS, where /tmp resolves through /private. Only the +// tail Zero creates is held to the stricter rule. +func createRuntimeTailRetainingFD(base string, tail []string) ([]windowsCreatedRuntimeDir, int, error) { + if runtimeBaseOpenedByName != nil { + runtimeBaseOpenedByName(base) + } + parent, err := unix.Open(base, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err != nil { + return nil, -1, fmt.Errorf("open sandbox runtime base %s: %w", base, err) + } + + if runtimeDescentBarrier != nil { + runtimeDescentBarrier() + } + + var created []windowsCreatedRuntimeDir + for _, path := range tail { + name := filepath.Base(path) + child, madeIt, openErr := openOrCreateRuntimeChildNoFollow(parent, name) + if openErr != nil { + _ = unix.Close(parent) + return created, -1, fmt.Errorf("open sandbox runtime component %s: %w", path, openErr) + } + if err := refuseForeignRuntimeDirectory(child, path); err != nil { + _ = unix.Close(child) + _ = unix.Close(parent) + return created, -1, err + } + if madeIt { + identity, idErr := runtimeDirectoryIdentity(child) + if idErr != nil { + _ = unix.Close(child) + _ = unix.Close(parent) + return created, -1, fmt.Errorf("identify the sandbox runtime directory created at %s: %w", path, idErr) + } + created = append(created, windowsCreatedRuntimeDir{path: path, identity: identity, identified: true}) + } + _ = unix.Close(parent) + parent = child + } + return created, parent, nil +} + +// openOrCreateRuntimeChildNoFollow opens name under parent, creating it if it is +// not there, and never follows a link at that name. +// +// madeIt distinguishes the directory this process created from one that was +// already there, because only the former belongs on the rollback ledger. +func openOrCreateRuntimeChildNoFollow(parent int, name string) (fd int, madeIt bool, err error) { + const flags = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC + child, openErr := unix.Openat(parent, name, flags, 0) + if openErr == nil { + return child, false, nil + } + if !errors.Is(openErr, unix.ENOENT) { + // ELOOP or ENOTDIR here IS the finding: a link sits where a directory of + // ours should be. Returned as-is rather than translated, because the two + // platforms spell it differently and the caller only needs it to fail. + return -1, false, openErr + } + if mkErr := unix.Mkdirat(parent, name, 0o700); mkErr != nil { + if !errors.Is(mkErr, unix.EEXIST) { + return -1, false, mkErr + } + // Somebody created it between the open and the create. That is an ordinary + // race with another Zero process, and the reopen below is still no-follow, + // so a link that arrived in the same window is refused rather than used. + child, openErr = unix.Openat(parent, name, flags, 0) + if openErr != nil { + return -1, false, openErr + } + return child, false, nil + } + child, openErr = unix.Openat(parent, name, flags, 0) + if openErr != nil { + return -1, false, openErr + } + return child, true, nil +} + +// refuseForeignRuntimeDirectory proves the directory reached belongs to this +// user and is not group- or world-writable. +// +// Asked of the DESCRIPTOR, so it describes the object the descent is holding +// rather than whatever the name resolves to now. Ownership is the part that +// matters on a shared temp root: a component another account created first is +// theirs, and continuing into it would put the sandbox runtime inside a tree they +// control even though nothing about it is a link. +func refuseForeignRuntimeDirectory(fd int, path string) error { + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return fmt.Errorf("inspect sandbox runtime component %s: %w", path, err) + } + if uid := unix.Getuid(); uid >= 0 && stat.Uid != uint32(uid) { + return fmt.Errorf("refusing to use the sandbox runtime component %s: it is owned by uid %d, not %d, so another account chose what the sandbox writes into", + path, stat.Uid, uid) + } + if stat.Mode&(unix.S_IWGRP|unix.S_IWOTH) != 0 { + return fmt.Errorf("refusing to use the sandbox runtime component %s: mode %#o lets another account replace what is inside it", + path, stat.Mode&0o7777) + } + return nil +} + +// runtimeDirectoryIdentity names the object behind a descriptor, so rollback can +// tell the directory it created from one that replaced it under the same name. +func runtimeDirectoryIdentity(fd int) (string, error) { + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return "", err + } + return strconv.FormatUint(uint64(stat.Dev), 10) + ":" + strconv.FormatUint(uint64(stat.Ino), 10), nil +} diff --git a/internal/sandbox/runtime_descend_windows.go b/internal/sandbox/runtime_descend_windows.go new file mode 100644 index 000000000..220ea7eda --- /dev/null +++ b/internal/sandbox/runtime_descend_windows.go @@ -0,0 +1,109 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "fmt" + "path/filepath" + + "golang.org/x/sys/windows" +) + +// createRuntimeTailHandleRelative creates the owned tail of the runtime root +// beneath base, one component at a time, from retained handles. +// +// THE NAME IS NOT AUTHORIZATION. The previous shape found the deepest existing +// ancestor with os.Stat and then created each missing component by opening its +// parent BY NAME. Both follow a junction, so a local user could replace the +// predictable owned "zero" component with a junction after the pre-check, +// point it anywhere, let elevated setup create runtime\v1\ beneath that +// target, and put the original back before the post-check. The post-check then +// saw an ordinary path, the plan applied normally, and rollback reported the +// separately created target only as identity-mismatched residue. +// +// So the base is opened by name exactly once, and everything below it is +// addressed relative to a handle with FILE_OPEN_REPARSE_POINT: an existing +// component is opened no-follow and refused if it is a link, and a missing one +// is created relative to its parent's handle and identified from the handle the +// create returned. No component name is resolved twice, so there is no interval +// for a swap to land in, and a junction placed at any owned component is seen +// as the link it is rather than followed. +// +// Redirected cache and TEMP locations ABOVE the owned tail are still allowed: +// they are part of base, which is the operator's business. The restriction is +// on the zero/runtime/v1/ components Zero itself owns. +func createRuntimeTailHandleRelative(base string, tail []string) ([]windowsCreatedRuntimeDir, error) { + created, parent, err := createRuntimeTailRetainingHandle(base, tail) + if parent != 0 { + _ = windows.CloseHandle(parent) + } + return created, err +} + +// createRuntimeTailRetainingHandle is the same descent, but it hands the caller +// the retained handle to the deepest component instead of closing it. +// +// Lease acquisition needs that handle: the lease file is a sibling of the +// runtime root, so creating it by pathname would re-resolve the owned components +// the descent just verified, which is the whole interval this design removes. +// The caller owns the returned handle and must close it, including when an error +// is returned alongside a non-zero handle. +func createRuntimeTailRetainingHandle(base string, tail []string) ([]windowsCreatedRuntimeDir, windows.Handle, error) { + if runtimeBaseOpenedByName != nil { + runtimeBaseOpenedByName(base) + } + parent, err := openWindowsDirectoryByName(base) + if err != nil { + return nil, 0, fmt.Errorf("open sandbox runtime base %s: %w", base, err) + } + + if runtimeDescentBarrier != nil { + runtimeDescentBarrier() + } + + var created []windowsCreatedRuntimeDir + for _, path := range tail { + name := filepath.Base(path) + // Exists already: another process won the race, or it was there before + // setup began. Opened no-follow, so a junction here is refused rather than + // descended, and it is not ours to record. + existing, openErr := openWindowsChildNoFollow(parent, name, + windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE, windows.FILE_DIRECTORY_FILE) + if openErr == nil { + _ = windows.CloseHandle(parent) + parent = existing + continue + } + if !isWindowsNotFound(openErr) { + return created, parent, fmt.Errorf("inspect sandbox runtime component %s: %w", path, openErr) + } + handle, createErr := createWindowsChildDirectory(parent, name) + if createErr != nil { + if errors.Is(createErr, windows.STATUS_OBJECT_NAME_COLLISION) { + // Created between the open and the create by someone else. Same + // answer as the os.IsExist branch the caller already had: not ours, + // but not a failure either. Reopen no-follow so the descent + // continues through a verified object rather than a name. + existing, reopenErr := openWindowsChildNoFollow(parent, name, + windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE, windows.FILE_DIRECTORY_FILE) + if reopenErr != nil { + return created, parent, fmt.Errorf("inspect sandbox runtime component %s after a concurrent create: %w", path, reopenErr) + } + _ = windows.CloseHandle(parent) + parent = existing + continue + } + return created, parent, fmt.Errorf("create sandbox runtime root %s: %w", path, createErr) + } + identity, idErr := handleRuntimeIdentity(handle) + if idErr != nil { + _ = windows.CloseHandle(handle) + return created, parent, fmt.Errorf("identify the sandbox runtime directory created at %s: %w", path, idErr) + } + created = append(created, windowsCreatedRuntimeDir{path: path, identity: identity, identified: true}) + _ = windows.CloseHandle(parent) + parent = handle + } + return created, parent, nil +} diff --git a/internal/sandbox/runtime_descent_swap_windows_test.go b/internal/sandbox/runtime_descent_swap_windows_test.go new file mode 100644 index 000000000..47aa9c7d5 --- /dev/null +++ b/internal/sandbox/runtime_descent_swap_windows_test.go @@ -0,0 +1,93 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// THE SWAP THE OLD WALK COULD NOT SEE. +// +// createRuntimeDirRecording used to find the deepest existing ancestor with +// os.Stat and then create each missing component by opening its parent BY +// NAME. Both follow a junction. So between the pre-check and the create, a +// local user could replace the predictable owned "zero" component with a +// junction into a directory of their choosing, and elevated setup would create +// runtime\v1\ beneath that target, apply its ACL plan there, and then +// find an ordinary path at the post-check once the original was put back. +// +// This plants the junction at the deterministic barrier between opening the +// base and touching the first owned component, which is that exact interval, +// and proves two things: nothing is created beneath the redirected target, and +// the descent refuses rather than continuing through the link. A junction +// needs no privilege on Windows, which is why it is the shape used here rather +// than a symlink that would skip on an unelevated box. +func TestRuntimeDescentRefusesAJunctionSwappedIntoAnOwnedComponent(t *testing.T) { + base := t.TempDir() + target := t.TempDir() + + zero := filepath.Join(base, "zero") + tail := []string{zero, filepath.Join(zero, "runtime")} + + previous := runtimeDescentBarrier + runtimeDescentBarrier = func() { + out, err := exec.Command("cmd", "/c", "mklink", "/J", zero, target).CombinedOutput() + if err != nil { + t.Fatalf("mklink /J: %v\n%s", err, out) + } + } + t.Cleanup(func() { runtimeDescentBarrier = previous }) + + created, err := createRuntimeTailHandleRelative(base, tail) + if err == nil { + t.Fatalf("the descent continued through a junction and reported success: created=%v", created) + } + if len(created) != 0 { + t.Errorf("a junction-swapped component still produced ownership records: %v", created) + } + + // The whole point: the redirected target must be untouched. + entries, readErr := os.ReadDir(target) + if readErr != nil { + t.Fatal(readErr) + } + if len(entries) != 0 { + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + t.Errorf("setup created %v beneath the junction target, which is somebody else's directory", names) + } +} + +// And the honest control: with no swap, the same descent creates the tail, +// records an identity for each component from the creation handle, and the +// result matches what the name resolves to afterwards. +func TestRuntimeDescentCreatesTheOwnedTailFromHandles(t *testing.T) { + base := t.TempDir() + zero := filepath.Join(base, "zero") + tail := []string{zero, filepath.Join(zero, "runtime"), filepath.Join(zero, "runtime", "v1")} + + created, err := createRuntimeTailHandleRelative(base, tail) + if err != nil { + t.Fatalf("descent: %v", err) + } + if len(created) != len(tail) { + t.Fatalf("created %d components, want %d: %v", len(created), len(tail), created) + } + for i, record := range created { + if record.path != tail[i] { + t.Errorf("record %d path = %q, want %q", i, record.path, tail[i]) + } + if !record.identified { + t.Errorf("record %d for %s carries no identity", i, record.path) + continue + } + if now, ok := runtimeDirIdentity(record.path); !ok || now != record.identity { + t.Errorf("record %d identity %q does not describe the directory at %s (%q)", i, record.identity, record.path, now) + } + } +} diff --git a/internal/sandbox/runtime_dir_identity_other.go b/internal/sandbox/runtime_dir_identity_other.go new file mode 100644 index 000000000..798356d63 --- /dev/null +++ b/internal/sandbox/runtime_dir_identity_other.go @@ -0,0 +1,27 @@ +//go:build !windows + +package sandbox + +import ( + "fmt" + "os" + "syscall" +) + +// runtimeDirIdentity identifies the directory currently at path. +// +// device plus inode, read with Lstat so a link substituted at the final +// component is identified as the link rather than followed. The Windows build +// cannot use os.SameFile for this and explains why; here the same eager capture +// keeps both platforms on one rule. +func runtimeDirIdentity(path string) (string, bool) { + info, err := os.Lstat(path) + if err != nil { + return "", false + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "", false + } + return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino), true +} diff --git a/internal/sandbox/runtime_dir_identity_windows.go b/internal/sandbox/runtime_dir_identity_windows.go new file mode 100644 index 000000000..e9a15938c --- /dev/null +++ b/internal/sandbox/runtime_dir_identity_windows.go @@ -0,0 +1,45 @@ +//go:build windows + +package sandbox + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +// runtimeDirIdentity identifies the directory currently at path, EAGERLY. +// +// os.SameFile cannot be used for this on Windows. A Windows fileStat loads its +// volume serial and file index lazily, by PATHNAME, at comparison time, so an +// identity captured before a replacement and compared afterwards reports the +// substitute as the same object and the original as a different one: exactly +// backwards, and silently. Measured, not reasoned about. +// +// Reading the identity through a handle at capture time removes the lazy step. +// Opened with FILE_FLAG_OPEN_REPARSE_POINT so a link substituted at the final +// component is identified as the link it is rather than followed. +func runtimeDirIdentity(path string) (string, bool) { + utf16Path, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", false + } + handle, err := windows.CreateFile( + utf16Path, + windows.FILE_READ_ATTRIBUTES, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return "", false + } + defer windows.CloseHandle(handle) + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return "", false + } + return fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow), true +} diff --git a/internal/sandbox/runtime_fallback_user_scope_test.go b/internal/sandbox/runtime_fallback_user_scope_test.go new file mode 100644 index 000000000..6d36f1ee6 --- /dev/null +++ b/internal/sandbox/runtime_fallback_user_scope_test.go @@ -0,0 +1,106 @@ +package sandbox + +import ( + "path/filepath" + "runtime" + "strings" + "testing" +) + +// EVERY OWNERSHIP-CHECKED ANCESTOR MUST ALREADY BE INSIDE ONE USER'S NAMESPACE. +// +// The temp-derived fallback lives under a directory that is shared on Unix +// whenever TMPDIR is unset. Runtime preparation creates and ownership-checks +// each component of the tail at 0700, so a fixed first component meant the +// first account to use the fallback created a private directory every other +// account was then refused at: traversal fails on the mode, and relaxing the +// mode fails the ownership guard instead. The per-workspace digest is the leaf, +// so it never got the chance to separate them. +// +// The invariant is therefore about the SHALLOWEST checked component, not the +// leaf: it has to carry the user scope, or the guards below it are guarding a +// namespace two users share. +func TestFallbackRuntimeRootScopesItsShallowestOwnedComponent(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("os.TempDir() resolves inside the user profile on Windows, so the shared-temp collision cannot arise") + } + workspace := t.TempDir() + tempHome := t.TempDir() + t.Setenv("TMPDIR", tempHome) + + root, err := fallbackSandboxRuntimeRoot(workspace) + if err != nil { + t.Fatalf("fallbackSandboxRuntimeRoot: %v", err) + } + components := ownedRuntimeComponents(root) + if len(components) == 0 { + t.Fatal("no ownership-checked components derived for the fallback root") + } + // ownedRuntimeComponents walks upward, so the last entry is the shallowest + // directory the guards create and validate. + shallowest := filepath.Base(components[len(components)-1]) + scope := sandboxRuntimeUserScope() + if !strings.Contains(shallowest, scope) { + t.Errorf("the shallowest ownership-checked component is %q and does not carry the user scope %q; "+ + "two accounts on a shared temp would contend for it", shallowest, scope) + } +} + +// The workspace still decides the leaf, so setup and the command derive the same +// root for the same workspace. Scoping the top must not have moved that. +func TestFallbackRuntimeRootStaysStableForOneWorkspace(t *testing.T) { + workspace := t.TempDir() + tempHome := t.TempDir() + t.Setenv("TMPDIR", tempHome) + t.Setenv("TMP", tempHome) + t.Setenv("TEMP", tempHome) + + first, err := fallbackSandboxRuntimeRoot(workspace) + if err != nil { + t.Fatal(err) + } + second, err := fallbackSandboxRuntimeRoot(workspace) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Errorf("the fallback root is not stable for one workspace:\n %s\n %s", first, second) + } + + other, err := fallbackSandboxRuntimeRoot(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if other == first { + t.Error("two different workspaces derived the same fallback root") + } + if filepath.Dir(other) != filepath.Dir(first) { + t.Errorf("two workspaces for one user should differ only in the leaf:\n %s\n %s", first, other) + } +} + +// And the Windows side is a deliberate choice, not an omission. Its temp root +// is already inside the user profile, and the names have to stay fixed so +// windowsSandboxRuntimeOwnedTail can still recognise a root that an elevated +// setup running as a different account built. +func TestFallbackOwnedNamesAreUnscopedOnWindowsOnly(t *testing.T) { + names := sandboxRuntimeFallbackOwnedNames() + if len(names) != len(windowsSandboxRuntimeOwnedNames) { + t.Fatalf("fallback names = %v, want the same depth as %v", names, windowsSandboxRuntimeOwnedNames) + } + // The components below the first are shared by both platforms, so a change + // to them would break the tail matcher on Windows. + for index := 1; index < len(names); index++ { + if names[index] != windowsSandboxRuntimeOwnedNames[index] { + t.Errorf("component %d = %q, want %q", index, names[index], windowsSandboxRuntimeOwnedNames[index]) + } + } + scoped := names[0] != windowsSandboxRuntimeOwnedNames[0] + if runtime.GOOS == "windows" && scoped { + t.Errorf("the Windows fallback scoped its first component to %q; the tail matcher compares fixed names", names[0]) + } + if runtime.GOOS != "windows" && !scoped { + t.Errorf("the first component is %q on %s, where the temp root can be shared between accounts", + names[0], runtime.GOOS) + } +} diff --git a/internal/sandbox/runtime_home_authority_test.go b/internal/sandbox/runtime_home_authority_test.go new file mode 100644 index 000000000..07203061b --- /dev/null +++ b/internal/sandbox/runtime_home_authority_test.go @@ -0,0 +1,74 @@ +package sandbox + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// writeRecordedRoot puts a setup marker naming root under sandboxHome. +func writeRecordedRoot(t *testing.T, sandboxHome, root string) { + t.Helper() + path := WindowsSandboxSetupMarkerPath(sandboxHome) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + blob, err := json.Marshal(WindowsSandboxSetupMarker{SchemaVersion: windowsSandboxSetupMarkerSchemaVersion, RuntimeRoot: root}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, blob, 0o600); err != nil { + t.Fatal(err) + } +} + +// ONE COMMAND, ONE SANDBOX HOME. +// +// Runtime preparation happens before Windows platform planning, and it used to +// resolve the sandbox home from the AMBIENT environment while the planner +// resolves ZERO_WINDOWS_SANDBOX_HOME out of the command's own spec.Env and hands +// that one to the runner for marker validation. So a command that explicitly +// selects home B, while the parent process still points at home A, pinned A's +// recorded root into the profile; the runner then loaded B's marker, saw a +// different root, and rejected the command as out of date even though setup for +// B was valid. The two homes need no different derivation rules to disagree, +// only different valid selections from the same preferred/fallback pair. +func TestThePinnedRootComesFromTheCommandsOwnSandboxHome(t *testing.T) { + homeA := t.TempDir() + homeB := t.TempDir() + preferred := filepath.Join(t.TempDir(), "preferred") + fallback := filepath.Join(t.TempDir(), "fallback") + + writeRecordedRoot(t, homeA, preferred) + writeRecordedRoot(t, homeB, fallback) + + if got := pinnedSandboxRuntimeRoot(t.TempDir(), preferred, fallback, homeB); got != fallback { + t.Errorf("pinned %q for a command that selected home B, want %q: the ambient home decided instead of the command's", got, fallback) + } + if got := pinnedSandboxRuntimeRoot(t.TempDir(), preferred, fallback, homeA); got != preferred { + t.Errorf("pinned %q for home A, want %q", got, preferred) + } +} + +// A recorded root that this workspace could not select is still refused, which +// is the protection that stops one workspace's tree being pinned into another. +func TestARecordedRootFromAnotherWorkspaceIsStillRefused(t *testing.T) { + home := t.TempDir() + writeRecordedRoot(t, home, filepath.Join(t.TempDir(), "someone-elses-tree")) + if got := pinnedSandboxRuntimeRoot(t.TempDir(), filepath.Join(t.TempDir(), "preferred"), filepath.Join(t.TempDir(), "fallback"), home); got != "" { + t.Errorf("pinned %q, want none: it matches neither candidate this workspace derives", got) + } +} + +// No command context means the ambient environment is the only authority there +// is, so an empty home must still resolve rather than refusing outright. +func TestAnEmptyCommandHomeFallsBackToTheAmbientEnvironment(t *testing.T) { + home := t.TempDir() + t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home) + preferred := filepath.Join(t.TempDir(), "preferred") + writeRecordedRoot(t, home, preferred) + if got := pinnedSandboxRuntimeRoot(t.TempDir(), preferred, filepath.Join(t.TempDir(), "fallback"), ""); got != preferred { + t.Errorf("pinned %q with no command home, want the ambient one to decide (%q)", got, preferred) + } +} diff --git a/internal/sandbox/runtime_lease.go b/internal/sandbox/runtime_lease.go index b05530fae..06343e808 100644 --- a/internal/sandbox/runtime_lease.go +++ b/internal/sandbox/runtime_lease.go @@ -7,9 +7,33 @@ import ( const sandboxRuntimeLeaseSuffix = ".lease" +// sandboxRuntimeLease is one holder's grip on a runtime root. +// +// createdFile says this acquisition is the one that brought the lease file into +// existence. Compensation needs it: rollback refuses a non-empty directory, and +// the lease sits beside the leaf inside a directory a failed setup has to be able +// to remove, so the file has to go with it. It may only go if it was ours, which +// is a fact only the create knows. type sandboxRuntimeLease struct { handle runtimeLeaseHandle once sync.Once + // root is the runtime root this lease is for, kept so compensation can name + // the lease file without deriving it again. + root string + // createdFile says THIS acquisition brought the lease file into existence. + createdFile bool +} + +// createdLeaseFile reports whether compensation may remove the lease file. +// +// Only the acquisition that created it may, and only while nothing else holds +// it. A lease that was already there belongs to whoever made it, and removing it +// would take the coordination object out from under a live command. +func (lease *sandboxRuntimeLease) createdLeaseFile() (string, bool) { + if lease == nil || !lease.createdFile { + return "", false + } + return sandboxRuntimeLeasePath(lease.root), true } func sandboxRuntimeLeasePath(root string) string { @@ -25,7 +49,7 @@ func acquireSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) { } func tryAcquireSandboxRuntimeCleanupLease(root string) (*sandboxRuntimeLease, bool, error) { - handle, inUse, err := tryAcquireExclusiveRuntimeLease(sandboxRuntimeLeasePath(root)) + handle, inUse, err := tryAcquireExclusiveRuntimeLease(root) if err != nil || inUse { return nil, inUse, err } diff --git a/internal/sandbox/runtime_lease_barrier.go b/internal/sandbox/runtime_lease_barrier.go new file mode 100644 index 000000000..15f531fb3 --- /dev/null +++ b/internal/sandbox/runtime_lease_barrier.go @@ -0,0 +1,15 @@ +package sandbox + +// runtimeLeasePreCreateBarrier, when set, runs after lease acquisition has +// decided what it is going to do and before it creates anything. +// +// It exists so a test can reproduce the CHECK-THEN-USE RACE rather than a +// junction that was already there when acquisition started. The two are not the +// same finding: a junction present from the outset was already refused by the +// alias pre-check, while one planted in this window defeated it, because +// os.MkdirAll and the lease open both resolved the component again and followed +// it. A test that only plants the junction up front passes against the defective +// implementation and proves nothing. +// +// Nil in production. +var runtimeLeasePreCreateBarrier func() diff --git a/internal/sandbox/runtime_lease_junction_windows_test.go b/internal/sandbox/runtime_lease_junction_windows_test.go new file mode 100644 index 000000000..386858bcf --- /dev/null +++ b/internal/sandbox/runtime_lease_junction_windows_test.go @@ -0,0 +1,142 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// leaseRootUnder builds a production-shaped runtime root beneath a test-owned +// cache directory, so the owned tail is real rather than assumed. +func leaseRootUnder(t *testing.T, cacheRoot string) string { + t.Helper() + workspace := t.TempDir() + root, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot)) + if !ok { + t.Fatalf("SETUP INVALID: no deterministic runtime root under %s", cacheRoot) + } + return root +} + +// THE LEASE IS THE FIRST WRITE SETUP MAKES, SO IT IS THE ONE THAT MATTERS MOST. +// +// prepareSandboxRuntimeLease checked the owned components for aliases and then +// created the parent with os.MkdirAll and opened ".lease" by pathname. +// Both follow. An ordinary same-account process could replace "zero", "runtime" +// or "v1" with a junction after the check and before either call, and elevated +// setup would then build the tree and the lease file inside somebody else's +// target. Putting the component back afterwards left the later handle-relative +// provisioning working on the legitimate tree, so no post-check ever saw it, and +// the rollback could not compensate what it had no record of. +// +// A junction needs no privilege on Windows, so the caller's half of this runs on +// an ordinary unelevated box. +func TestRuntimeLeaseRefusesAJunctionPlantedAfterTheCheck(t *testing.T) { + cacheRoot := t.TempDir() + target := t.TempDir() + root := leaseRootUnder(t, cacheRoot) + + owned := filepath.Join(canonicalSandboxWorkspaceRoot(cacheRoot), "zero") + if !strings.HasPrefix(strings.ToLower(root), strings.ToLower(owned)) { + t.Fatalf("SETUP INVALID: %s does not sit under the owned component %s", root, owned) + } + + // THE JUNCTION ARRIVES IN THE WINDOW, WHICH IS THE WHOLE POINT. + // + // One that is already there when acquisition starts was refused by the old + // alias pre-check too, so a test that plants it up front passes against the + // defective implementation and proves nothing. This one lands after the + // decision and before the first create, which is exactly where os.MkdirAll and + // the pathname lease open used to follow it. + previous := runtimeLeasePreCreateBarrier + t.Cleanup(func() { runtimeLeasePreCreateBarrier = previous }) + planted := false + runtimeLeasePreCreateBarrier = func() { + if planted { + return + } + planted = true + if out, err := exec.Command("cmd", "/c", "mklink", "/J", owned, target).CombinedOutput(); err != nil { + t.Logf("mklink /J unavailable: %v: %s", err, out) + } + } + + lease, _, err := prepareSandboxRuntimeLeaseRecording(root) + if lease != nil { + lease.release() + } + if !planted { + t.Skip("the pre-create barrier never ran, so the race was not reproduced") + } + if _, statErr := os.Lstat(owned); statErr != nil { + t.Skipf("the junction could not be created here: %v", statErr) + } + + entries, readErr := os.ReadDir(target) + if readErr != nil { + t.Fatal(readErr) + } + if len(entries) != 0 { + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + t.Fatalf("lease acquisition wrote %v beneath a junction planted after its own check; err=%v", names, err) + } + if err == nil { + t.Fatal("lease acquisition reported success through a junction planted after its check") + } +} + +// And an ordinary cache tree still gets its lease, or the refusal above would be +// satisfied by an acquirer that refuses everything. +func TestRuntimeLeaseStillAcquiresOnAnOrdinaryTree(t *testing.T) { + cacheRoot := t.TempDir() + root := leaseRootUnder(t, cacheRoot) + + lease, created, err := prepareSandboxRuntimeLeaseRecording(root) + if err != nil { + t.Fatalf("an ordinary cache tree was refused: %v", err) + } + defer lease.release() + + if _, statErr := os.Stat(sandboxRuntimeLeasePath(root)); statErr != nil { + t.Fatalf("the lease file was not created: %v", statErr) + } + // The components above the leaf are recorded, so a later failure can undo + // them. The leaf itself belongs to provisioning and must NOT appear here. + for _, entry := range created { + if strings.EqualFold(filepath.Clean(entry.path), filepath.Clean(root)) { + t.Fatalf("lease acquisition claimed the runtime leaf %s, which provisioning owns", root) + } + } + if len(created) == 0 { + t.Fatal("SETUP INVALID: nothing was recorded as created, so the accounting assertion above is vacuous") + } +} + +// A second acquisition over an existing tree records nothing: it created nothing, +// so it owns nothing, and rollback must not remove a tree it found. +func TestRuntimeLeaseRecordsOnlyWhatItCreated(t *testing.T) { + cacheRoot := t.TempDir() + root := leaseRootUnder(t, cacheRoot) + + first, _, err := prepareSandboxRuntimeLeaseRecording(root) + if err != nil { + t.Fatalf("first acquisition: %v", err) + } + first.release() + + second, created, err := prepareSandboxRuntimeLeaseRecording(root) + if err != nil { + t.Fatalf("second acquisition: %v", err) + } + defer second.release() + if len(created) != 0 { + t.Fatalf("the second acquisition claimed %d directories it did not create; rollback would delete a tree it found", len(created)) + } +} diff --git a/internal/sandbox/runtime_lease_link_unix_test.go b/internal/sandbox/runtime_lease_link_unix_test.go new file mode 100644 index 000000000..c52442a92 --- /dev/null +++ b/internal/sandbox/runtime_lease_link_unix_test.go @@ -0,0 +1,216 @@ +//go:build !windows + +package sandbox + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/unix" +) + +// unixLeaseRootUnder builds a production-shaped runtime root beneath a +// test-owned base, so the owned tail is real rather than assumed. +func unixLeaseRootUnder(t *testing.T, base string) string { + t.Helper() + workspace := t.TempDir() + root, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(base)) + if !ok { + t.Fatalf("SETUP INVALID: no deterministic runtime root under %s", base) + } + return root +} + +// countEntriesUnder reports how many entries exist anywhere beneath dir, so a +// test can say "nothing was written here" rather than checking one name. +func countEntriesUnder(t *testing.T, dir string) []string { + t.Helper() + var found []string + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if path == dir { + return nil + } + found = append(found, path) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", dir, err) + } + return found +} + +// A PRE-CHECK CANNOT AUTHORIZE A PATHNAME WRITE THAT COMES AFTER IT. +// +// The fallback root moved from an atomically minted MkdirTemp parent to a +// predictable /tmp/zero-u/runtime/v1/. The stable name is required, +// because setup and every later process have to agree on one root without talking +// to each other, but it also means another local account can name the first owned +// component first. refuseAliasedRuntimeComponents answers about an ABSENT +// component by saying there is nothing to alias, which is the state a fresh +// fallback is in, and os.MkdirAll plus the pathname lease open then followed a +// link planted after that answer. +// +// The link arrives after the last validation and before the first create, which +// is the only window that matters: one planted earlier was refused by the old +// pre-check too, so a test that plants it up front passes against the defect and +// proves nothing. +func TestRuntimeLeaseRefusesALinkPlantedAfterTheCheck(t *testing.T) { + base := t.TempDir() + target := t.TempDir() + root := unixLeaseRootUnder(t, base) + + owned := filepath.Join(canonicalSandboxWorkspaceRoot(base), "zero") + if !filepath.HasPrefix(root, owned) { + t.Fatalf("SETUP INVALID: %s does not sit under the owned component %s", root, owned) + } + + previous := runtimeDescentBarrier + t.Cleanup(func() { runtimeDescentBarrier = previous }) + planted := false + runtimeDescentBarrier = func() { + if planted { + return + } + planted = true + if err := os.Symlink(target, owned); err != nil { + t.Errorf("plant the link: %v", err) + } + } + + lease, _, err := prepareSandboxRuntimeLeaseRecording(root) + if lease != nil { + lease.release() + } + + // SETUP: the swap really happened in the window, or nothing was under test. + if !planted { + t.Fatal("SETUP INVALID: the descent never reached the barrier, so no link was planted") + } + if err == nil { + t.Fatal("lease acquisition succeeded through a link planted at the first owned component") + } + // ELOOP on Linux, and either that or ENOTDIR elsewhere. Asserted as a class + // rather than one kernel's spelling. + if !errors.Is(err, unix.ELOOP) && !errors.Is(err, unix.ENOTDIR) { + t.Errorf("refused for the wrong reason, want a no-follow refusal: %v", err) + } + + // THE POINT IS WHERE THE WRITES DID NOT GO. An error alone would also be + // produced by a later guard noticing the link after the tree was built inside + // somebody else's directory. + if leaked := countEntriesUnder(t, target); len(leaked) != 0 { + t.Fatalf("the redirected target received %v; the refusal happened after the writes rather than instead of them", leaked) + } +} + +// CONTROL: an ordinary tree still gets its lease, and the one path opened by name +// is the operator's base rather than a component Zero owns. +// +// Refusing everything would satisfy the test above. Asserting the base directly +// is what makes this discriminating: inferring it from whether a swap was caught +// says nothing about which name was trusted. +func TestRuntimeLeaseOpensOnlyTheBaseByName(t *testing.T) { + base := t.TempDir() + root := unixLeaseRootUnder(t, base) + + previousBase := runtimeBaseOpenedByName + t.Cleanup(func() { runtimeBaseOpenedByName = previousBase }) + var byName []string + runtimeBaseOpenedByName = func(path string) { byName = append(byName, path) } + + lease, created, err := prepareSandboxRuntimeLeaseRecording(root) + if err != nil { + t.Fatalf("lease acquisition failed on an ordinary tree: %v", err) + } + lease.release() + + if len(byName) != 1 { + t.Fatalf("opened %v by name, want exactly the base", byName) + } + if got := byName[0]; got != canonicalSandboxWorkspaceRoot(base) && got != base { + t.Fatalf("opened %q by name, want the base %q", got, base) + } + if len(created) == 0 { + t.Fatal("the descent created the owned components but recorded none of them, so rollback has nothing to undo") + } + if _, err := os.Lstat(sandboxRuntimeLeasePath(root)); err != nil { + t.Fatalf("no lease file was created on an ordinary tree: %v", err) + } +} + +// And a link at the LEASE NAME itself is refused by both holders, so the shared +// and exclusive sides cannot end up locking different objects. +func TestRuntimeLeaseRefusesALinkAtTheLeaseName(t *testing.T) { + base := t.TempDir() + root := unixLeaseRootUnder(t, base) + + lease, _, err := prepareSandboxRuntimeLeaseRecording(root) + if err != nil { + t.Fatalf("SETUP: cannot seed a lease: %v", err) + } + lease.release() + leasePath := sandboxRuntimeLeasePath(root) + if err := os.Remove(leasePath); err != nil { + t.Fatalf("SETUP INVALID: cannot clear the seeded lease: %v", err) + } + target := filepath.Join(t.TempDir(), "target.lease") + if err := os.WriteFile(target, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, leasePath); err != nil { + t.Fatalf("SETUP: cannot plant the link: %v", err) + } + + shared, _, sharedErr := prepareSandboxRuntimeLeaseRecording(root) + if shared != nil { + shared.release() + } + if sharedErr == nil { + t.Error("shared acquisition accepted a link at the lease name") + } + + cleanup, inUse, cleanupErr := tryAcquireSandboxRuntimeCleanupLease(root) + if cleanup != nil { + cleanup.release() + } + if cleanupErr == nil { + t.Errorf("cleanup accepted a link at the lease name (inUse=%t); it would treat a lock on %s as proof the root is free to delete", inUse, target) + } +} + +// CONTROL: the two sides still coordinate on an ordinary lease. +func TestUnixSharedLeaseMakesCleanupReportInUse(t *testing.T) { + base := t.TempDir() + root := unixLeaseRootUnder(t, base) + + held, _, err := prepareSandboxRuntimeLeaseRecording(root) + if err != nil { + t.Fatalf("cannot acquire a runtime lease: %v", err) + } + + lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root) + if lease != nil { + lease.release() + } + if err != nil { + t.Fatalf("cleanup failed against a legitimately held lease: %v", err) + } + if !inUse { + t.Fatal("cleanup reported the runtime root free while a shared lease was held; it would delete a tree a live command is using") + } + + held.release() + lease, inUse, err = tryAcquireSandboxRuntimeCleanupLease(root) + if err != nil { + t.Fatalf("cleanup failed after the shared lease was released: %v", err) + } + if inUse { + t.Fatal("cleanup still reported the root in use after the only holder released it, so reclamation never happens") + } + lease.release() +} diff --git a/internal/sandbox/runtime_lease_ownership_test.go b/internal/sandbox/runtime_lease_ownership_test.go new file mode 100644 index 000000000..259dd3e7a --- /dev/null +++ b/internal/sandbox/runtime_lease_ownership_test.go @@ -0,0 +1,93 @@ +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// runtimeRootUnderTest builds a root with the shape production uses, so the +// components refuseAliasedRuntimeComponents walks stay inside test-owned +// storage. +// +// Owned depth is the fixed names plus the digest, so a root placed directly in +// t.TempDir() puts an owned component on /tmp, which the Unix ownership guard +// correctly refuses because /tmp belongs to root. That is the guard working, not +// a test environment problem, and it only shows up off Windows. +func runtimeRootUnderTest(t *testing.T, leaf string) string { + t.Helper() + root := filepath.Join(t.TempDir(), "zero", "runtime", "v1", leaf) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + return root +} + +// A LEASE IS OWNERSHIP FOR A TRANSACTION, NOT A SELECTION PROBE. +// +// Setup took a lease only to learn which root won and released it at once, so +// nothing owned the selected root while the elevated helper provisioned the +// tree, applied the ACL and stamp, installed network state and wrote the marker. +// A command for another workspace scans the same runtime parent and excludes +// only its own current root, so it can take this root's cleanup lease and +// RemoveAll it mid-transaction; setup then publishes success for a pathname that +// is gone. +// +// This pins the mechanism the fix relies on: a held lease is what makes the +// cleanup's exclusive acquire fail. +func TestAHeldRuntimeLeaseStopsConcurrentCleanup(t *testing.T) { + root := runtimeRootUnderTest(t, "deadbeefdeadbeef") + + lease, err := prepareSandboxRuntimeLease(root) + if err != nil { + t.Fatalf("acquire the runtime lease: %v", err) + } + removeSandboxRuntimeRootIfUnused(root) + if _, err := os.Stat(root); err != nil { + t.Fatalf("cleanup removed a root that setup was holding: %v", err) + } + lease.release() + + // And once nothing holds it, cleanup does its job: without this half the test + // would pass against a cleanup that never removes anything. + removeSandboxRuntimeRootIfUnused(root) + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatalf("an unheld root survived cleanup: %v", err) + } +} + +// The lease is on the root the caller names, so holding one root does not +// protect a sibling: setup reserving its own selection must not stop the +// cleanup doing its work elsewhere. +func TestAHeldLeaseDoesNotProtectASiblingRoot(t *testing.T) { + held := runtimeRootUnderTest(t, "aaaaaaaaaaaaaaaa") + sibling := filepath.Join(filepath.Dir(held), "bbbbbbbbbbbbbbbb") + if err := os.MkdirAll(sibling, 0o700); err != nil { + t.Fatal(err) + } + lease, err := prepareSandboxRuntimeLease(held) + if err != nil { + t.Fatal(err) + } + defer lease.release() + + removeSandboxRuntimeRootIfUnused(sibling) + if _, err := os.Stat(sibling); !os.IsNotExist(err) { + t.Errorf("holding one root blocked cleanup of an unrelated sibling: %v", err) + } + if _, err := os.Stat(held); err != nil { + t.Errorf("the held root was removed: %v", err) + } +} + +// createdRuntimeDirsForTest builds identity-bound rollback records for paths a +// test made itself, so a test can express "these are the directories this run +// created" without restating the identity capture. +func createdRuntimeDirsForTest(paths ...string) []windowsCreatedRuntimeDir { + records := make([]windowsCreatedRuntimeDir, 0, len(paths)) + for _, path := range paths { + identity, identified := runtimeDirIdentity(path) + records = append(records, windowsCreatedRuntimeDir{path: path, identity: identity, identified: identified}) + } + return records +} diff --git a/internal/sandbox/runtime_lease_platform_other.go b/internal/sandbox/runtime_lease_platform_other.go new file mode 100644 index 000000000..3b317dd67 --- /dev/null +++ b/internal/sandbox/runtime_lease_platform_other.go @@ -0,0 +1,21 @@ +//go:build !windows + +package sandbox + +// acquireRuntimeLeaseForPlatform creates and locks the lease through the same +// rooted no-follow boundary the Windows side uses. +// +// A PRE-CHECK IS NOT A BOUNDARY. This used to call refuseAliasedRuntimeComponents +// and then os.MkdirAll plus a pathname lease open. The guard answers about an +// ABSENT component by saying there is nothing to alias, which is exactly the +// state a fresh fallback root is in, and both calls that followed resolve the +// name again. A link planted in between was therefore created through and +// written into, and the next check could only report it after the writes. +// +// The alias guard is gone from this path rather than kept alongside: retaining it +// would suggest the two together are the protection, when the descent is the +// protection and the guard is the thing that could not be one. Other callers +// still use it where a pathname really is all there is. +func acquireRuntimeLeaseForPlatform(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) { + return acquireRuntimeLeaseRootedUnix(root) +} diff --git a/internal/sandbox/runtime_lease_platform_windows.go b/internal/sandbox/runtime_lease_platform_windows.go new file mode 100644 index 000000000..3c05b5e20 --- /dev/null +++ b/internal/sandbox/runtime_lease_platform_windows.go @@ -0,0 +1,10 @@ +//go:build windows + +package sandbox + +// acquireRuntimeLeaseForPlatform routes Windows through the rooted, no-follow +// acquisition. See acquireRuntimeLeaseRooted for why the pathname walk it +// replaces was unsafe. +func acquireRuntimeLeaseForPlatform(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) { + return acquireRuntimeLeaseRooted(root) +} diff --git a/internal/sandbox/runtime_lease_reparse_windows_test.go b/internal/sandbox/runtime_lease_reparse_windows_test.go new file mode 100644 index 000000000..0a23413d5 --- /dev/null +++ b/internal/sandbox/runtime_lease_reparse_windows_test.go @@ -0,0 +1,299 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// seedRuntimeLeaseTree creates the owned tail and the lease the way a first run +// does, then removes just the lease file so a test can put something else at that +// name. The tree has to exist, or cleanup's descent has nothing to open and the +// test would pass for the wrong reason. +func seedRuntimeLeaseTree(t *testing.T, root string) string { + t.Helper() + lease, _, err := prepareSandboxRuntimeLeaseRecording(root) + if err != nil { + t.Skipf("cannot seed a runtime lease here: %v", err) + } + lease.release() + leasePath := sandboxRuntimeLeasePath(root) + if err := os.Remove(leasePath); err != nil { + t.Fatalf("SETUP INVALID: cannot clear the seeded lease: %v", err) + } + return leasePath +} + +// plantFileLink puts a FILE symbolic link at leasePath pointing at an ordinary +// file, and returns the target. +// +// A file link, not a junction: a junction is a directory and FILE_NON_DIRECTORY_FILE +// already refuses it, so a junction here would pass against the defect and prove +// nothing. Creating one needs SeCreateSymbolicLinkPrivilege, which an ordinary +// unelevated account does not hold, so this skips there and runs on CI. +func plantFileLink(t *testing.T, leasePath string) string { + t.Helper() + target := filepath.Join(t.TempDir(), "target.lease") + if err := os.WriteFile(target, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, leasePath); err != nil { + t.Skipf("cannot create a file symbolic link here, which needs SeCreateSymbolicLinkPrivilege: %v", err) + } + // SETUP: it really is a reparse point, or nothing below is about links. + info, err := os.Lstat(leasePath) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("SETUP INVALID: %s is not a link (%v, %v)", leasePath, info, err) + } + return target +} + +// assertTargetUnlocked proves nobody holds a lock on the link's target, which is +// the object the defect made the two sides fight over. +func assertTargetUnlocked(t *testing.T, target string) { + t.Helper() + file, err := os.OpenFile(target, os.O_RDWR, 0o600) + if err != nil { + t.Fatalf("open the link target: %v", err) + } + defer func() { _ = file.Close() }() + var overlapped windows.Overlapped + flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY) + if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &overlapped); err != nil { + t.Fatalf("the link target is locked, so the refusal happened after the lease had already been taken on it: %v", err) + } + _ = windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &overlapped) +} + +// FILE_OPEN_REPARSE_POINT SAYS DO NOT FOLLOW, NOT REFUSE. +// +// The rooted opener passed that flag and FILE_NON_DIRECTORY_FILE and treated the +// pair as a guarantee. It is not: the flag returns a handle to the LINK, and +// FILE_NON_DIRECTORY_FILE excludes directories rather than non-directory reparse +// objects. A file symbolic link at .lease was opened, wrapped and locked +// as though it were the lease. +// +// That matters because cleanup opened the same name by pathname with no +// no-follow flag at all, so it locked the link's TARGET. Two holders, two +// objects, both calls succeeding, and cleanup free to RemoveAll a runtime root a +// live command was using. +func TestSharedLeaseRefusesAFileLinkAtTheLeaseName(t *testing.T) { + cacheRoot := t.TempDir() + root := leaseRootUnder(t, cacheRoot) + leasePath := seedRuntimeLeaseTree(t, root) + target := plantFileLink(t, leasePath) + + lease, _, err := prepareSandboxRuntimeLeaseRecording(root) + if lease != nil { + lease.release() + } + if err == nil { + t.Fatal("shared acquisition accepted a file link at the lease name, so it is holding a lock on an object cleanup does not see") + } + if !strings.Contains(err.Error(), "reparse point") { + t.Errorf("refused for the wrong reason: %v", err) + } + assertTargetUnlocked(t, target) +} + +// And cleanup refuses the same object, so it cannot decide the root is free by +// locking something else. +func TestCleanupLeaseRefusesAFileLinkAtTheLeaseName(t *testing.T) { + cacheRoot := t.TempDir() + root := leaseRootUnder(t, cacheRoot) + leasePath := seedRuntimeLeaseTree(t, root) + target := plantFileLink(t, leasePath) + + lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root) + if lease != nil { + lease.release() + } + if err == nil { + t.Fatalf("cleanup accepted a file link at the lease name (inUse=%t); it would treat a lock on %s as proof the runtime root is free to delete", inUse, target) + } + if !strings.Contains(err.Error(), "reparse point") { + t.Errorf("refused for the wrong reason: %v", err) + } + assertTargetUnlocked(t, target) +} + +// CONTROL: THE TWO SIDES REALLY DO COORDINATE ON AN ORDINARY LEASE. +// +// Without this, refusing everything would satisfy the two tests above. This one +// runs everywhere, including on an unelevated box with no symbolic-link +// privilege, so the mutual-exclusion contract stays pinned even where the link +// cases skip. +func TestASharedLeaseMakesCleanupReportInUse(t *testing.T) { + cacheRoot := t.TempDir() + root := leaseRootUnder(t, cacheRoot) + + held, _, err := prepareSandboxRuntimeLeaseRecording(root) + if err != nil { + t.Skipf("cannot acquire a runtime lease here: %v", err) + } + + lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root) + if lease != nil { + lease.release() + } + if err != nil { + t.Fatalf("cleanup failed against a legitimately held lease: %v", err) + } + if !inUse { + t.Fatal("cleanup reported the runtime root free while a shared lease was held; it would delete a tree a live command is using") + } + + held.release() + + lease, inUse, err = tryAcquireSandboxRuntimeCleanupLease(root) + if err != nil { + t.Fatalf("cleanup failed after the shared lease was released: %v", err) + } + if inUse { + t.Fatal("cleanup still reported the runtime root in use after the only holder released it, so reclamation never happens") + } + if lease == nil { + t.Fatal("cleanup reported the root free but handed back no lease") + } + lease.release() +} + +// CONTROL: a legitimate lease is still SHARED between processes. +// +// The classification must not turn the shared lease into an exclusive one, or +// two concurrent commands on one workspace would stop working. +func TestAnOrdinaryLeaseIsStillSharedByTwoHolders(t *testing.T) { + cacheRoot := t.TempDir() + root := leaseRootUnder(t, cacheRoot) + + first, _, err := prepareSandboxRuntimeLeaseRecording(root) + if err != nil { + t.Skipf("cannot acquire a runtime lease here: %v", err) + } + defer first.release() + + second, _, err := prepareSandboxRuntimeLeaseRecording(root) + if err != nil { + t.Fatalf("a second holder was refused an existing ordinary lease: %v", err) + } + second.release() +} + +// reparseGUIDDataBuffer is the non-Microsoft form of REPARSE_GUID_DATA_BUFFER. +// +// Setting a tag with the Microsoft bit clear needs only write access to the file, +// unlike a symbolic link, so this runs on an ordinary unelevated account and the +// classification stays pinned where the link cases skip. +type reparseGUIDDataBuffer struct { + ReparseTag uint32 + ReparseDataLength uint16 + Reserved uint16 + ReparseGUID windows.GUID + Data [16]byte +} + +// plantGenericReparsePoint turns an ordinary file at path into a reparse object. +func plantGenericReparsePoint(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + name, err := windows.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + handle, err := windows.CreateFile(name, + windows.GENERIC_WRITE|windows.FILE_WRITE_ATTRIBUTES, 0, nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS, 0) + if err != nil { + t.Skipf("cannot open %s to set a reparse tag: %v", path, err) + } + defer func() { _ = windows.CloseHandle(handle) }() + + buffer := reparseGUIDDataBuffer{ + // Bit 31 clear, so this is a third-party tag and needs no privilege. + ReparseTag: 0x00000042, + ReparseDataLength: 16, + ReparseGUID: windows.GUID{Data1: 0x5ee0e5f1, Data2: 0x1a11, Data3: 0x4d3b, Data4: [8]byte{0x9a, 0x77, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}}, + } + size := uint32(unsafe.Sizeof(buffer)) + var returned uint32 + if err := windows.DeviceIoControl(handle, windowsFSCTLSetReparsePoint, + (*byte)(unsafe.Pointer(&buffer)), size, nil, 0, &returned, nil); err != nil { + t.Skipf("cannot set a generic reparse tag here: %v", err) + } +} + +const windowsFSCTLSetReparsePoint = 0x000900A4 + +// ANY REPARSE OBJECT AT THE LEASE NAME IS REFUSED, NOT ONLY A SYMBOLIC LINK. +// +// The requirement is that the name denotes an ordinary file, because that is what +// makes the shared holder and cleanup lock the same thing. Keying on the link +// shape instead would leave every other reparse tag accepted, and it would leave +// this untested on any machine without the symbolic-link privilege. +// +// WHAT THIS CASE CANNOT SHOW, so nobody reads a local pass as more than it is: +// an unknown third-party tag is unresolvable, so the OLD pathname cleanup fails +// on it too, with ERROR_CANT_ACCESS_FILE rather than by classifying anything. It +// therefore pins that both sites refuse, and the reason check below is what +// separates a refusal from an accident. Only the symbolic-link cases above show +// the half that matters most, a pathname open SUCCEEDING on the target, and those +// need the privilege. Read their result in CI, not here. +func TestLeaseRefusesAnyReparseObjectAtTheLeaseName(t *testing.T) { + for name, acquire := range map[string]func(string) error{ + "shared": func(root string) error { + lease, _, err := prepareSandboxRuntimeLeaseRecording(root) + if lease != nil { + lease.release() + } + return err + }, + "cleanup": func(root string) error { + lease, _, err := tryAcquireSandboxRuntimeCleanupLease(root) + if lease != nil { + lease.release() + } + return err + }, + } { + t.Run(name, func(t *testing.T) { + cacheRoot := t.TempDir() + root := leaseRootUnder(t, cacheRoot) + leasePath := seedRuntimeLeaseTree(t, root) + plantGenericReparsePoint(t, leasePath) + + // SETUP: the attribute really is set, or this asserts nothing. + info, err := os.Lstat(leasePath) + if err != nil || info.Mode()&os.ModeIrregular == 0 && info.Mode()&os.ModeSymlink == 0 { + attrs, statErr := windowsFileAttributes(leasePath) + if statErr != nil || attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT == 0 { + t.Fatalf("SETUP INVALID: %s is not a reparse object (attrs=%#x err=%v)", leasePath, attrs, statErr) + } + } + + err = acquire(root) + if err == nil { + t.Fatalf("%s acquisition accepted a reparse object at the lease name", name) + } + if !strings.Contains(err.Error(), "reparse point") { + t.Errorf("refused for the wrong reason: %v", err) + } + }) + } +} + +func windowsFileAttributes(path string) (uint32, error) { + name, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + return windows.GetFileAttributes(name) +} diff --git a/internal/sandbox/runtime_lease_rooted_unix.go b/internal/sandbox/runtime_lease_rooted_unix.go new file mode 100644 index 000000000..0b4776219 --- /dev/null +++ b/internal/sandbox/runtime_lease_rooted_unix.go @@ -0,0 +1,167 @@ +//go:build !windows + +package sandbox + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +// acquireRuntimeLeaseRootedUnix creates and locks the lease for root without +// resolving an owned component by name. +// +// The lease is the FIRST thing setup writes, so it is the write a redirection has +// to be caught before, not after. It is a sibling of the runtime root rather than +// one of its owned components, which is why the alias guard never looked at it at +// all: it walks the components, and the lease is not one of them. +// +// Only the components above the leaf are created here. The leaf belongs to +// provisioning, which records it for rollback, and creating it here would mean +// two owners for one directory. +func acquireRuntimeLeaseRootedUnix(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) { + base, components, owned := windowsSandboxRuntimeOwnedTail(root) + if !owned || len(components) == 0 { + // Fail rather than fall back to the pathname walk: the walk is the defect. + return nil, nil, fmt.Errorf("acquire sandbox runtime lease for %s: %w", root, errRuntimeTailNotOwned) + } + if runtimeLeasePreCreateBarrier != nil { + runtimeLeasePreCreateBarrier() + } + // The base is the operator's, and may legitimately be a redirected or linked + // temp location, so it is created and opened by name exactly as before. + // Everything below it is Zero's and is addressed by descriptor. + if err := os.MkdirAll(base, 0o700); err != nil { + return nil, nil, fmt.Errorf("create sandbox runtime base: %w", err) + } + + // The lease sits beside the leaf, so the deepest directory needed here is the + // leaf's parent. + tail := make([]string, 0, len(components)-1) + current := base + for _, component := range components[:len(components)-1] { + current = filepath.Join(current, component) + tail = append(tail, current) + } + + created, parent, err := createRuntimeTailRetainingFD(base, tail) + if err != nil { + return nil, created, err + } + defer func() { _ = unix.Close(parent) }() + + handle, madeLease, err := acquireSharedRuntimeLeaseAtFD(parent, filepath.Base(sandboxRuntimeLeasePath(root))) + if err != nil { + return nil, created, fmt.Errorf("acquire sandbox runtime lease: %w", err) + } + return &sandboxRuntimeLease{handle: handle, root: root, createdFile: madeLease}, created, nil +} + +// acquireSharedRuntimeLeaseAtFD opens the lease relative to a verified parent and +// takes the shared lock on it. +func acquireSharedRuntimeLeaseAtFD(parent int, name string) (runtimeLeaseHandle, bool, error) { + file, created, err := openRuntimeLeaseAtFD(parent, name) + if err != nil { + return runtimeLeaseHandle{}, false, err + } + if err := unix.Flock(int(file.Fd()), unix.LOCK_SH); err != nil { + _ = file.Close() + return runtimeLeaseHandle{}, false, err + } + return runtimeLeaseHandle{file: file}, created, nil +} + +// openRuntimeLeaseAtFD opens or creates the lease under parent and proves it is +// an ordinary file. +// +// O_NOFOLLOW so a symlink planted at the lease name is an ELOOP rather than an +// open of its target, and a regular-file check besides, because O_NOFOLLOW says +// nothing about a fifo or a device a local user can also create there. Both +// holders have to end up on the same object or the lock protects nothing. +func openRuntimeLeaseAtFD(parent int, name string) (*os.File, bool, error) { + const flags = unix.O_RDWR | unix.O_NOFOLLOW | unix.O_CLOEXEC + // O_EXCL FIRST, so the create is what reports the create. A Stat beforehand + // answers about a moment that has passed by the time the open runs, and + // compensation would then delete a lease another process had just made. + created := true + fd, err := unix.Openat(parent, name, flags|unix.O_CREAT|unix.O_EXCL, 0o600) + if errors.Is(err, unix.EEXIST) { + created = false + fd, err = unix.Openat(parent, name, flags, 0) + } + if err != nil { + if errors.Is(err, unix.ELOOP) { + return nil, false, fmt.Errorf("refusing to use the sandbox runtime lease at %s: it is a link, so the holders would lock different objects: %w", name, err) + } + return nil, false, err + } + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + _ = unix.Close(fd) + return nil, false, fmt.Errorf("inspect the sandbox runtime lease %s: %w", name, err) + } + if stat.Mode&unix.S_IFMT != unix.S_IFREG { + _ = unix.Close(fd) + return nil, false, fmt.Errorf("refusing to use the sandbox runtime lease at %s: it is not an ordinary file (mode %#o)", name, stat.Mode&unix.S_IFMT) + } + if uid := unix.Getuid(); uid >= 0 && stat.Uid != uint32(uid) { + _ = unix.Close(fd) + return nil, false, fmt.Errorf("refusing to use the sandbox runtime lease at %s: it is owned by uid %d, not %d", name, stat.Uid, uid) + } + file := os.NewFile(uintptr(fd), name) + if file == nil { + _ = unix.Close(fd) + return nil, false, fmt.Errorf("wrap the sandbox runtime lease handle for %s", name) + } + return file, created, nil +} + +// tryAcquireExclusiveRuntimeLeaseRootedUnix is cleanup's acquisition, resolved +// the way acquisition resolves it. +// +// It opens and never creates the tree: a runtime root that is not there has no +// lease to take, and cleanup rebuilding it in order to lock it would be inventing +// the thing it is about to remove. +func tryAcquireExclusiveRuntimeLeaseRootedUnix(root string) (runtimeLeaseHandle, bool, error) { + base, components, owned := windowsSandboxRuntimeOwnedTail(root) + if !owned || len(components) == 0 { + return runtimeLeaseHandle{}, false, fmt.Errorf("open the sandbox runtime lease parent for %s: %w", root, errRuntimeTailNotOwned) + } + parent, err := unix.Open(base, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err != nil { + return runtimeLeaseHandle{}, false, fmt.Errorf("open sandbox runtime base %s: %w", base, err) + } + path := base + for _, name := range components[:len(components)-1] { + path = filepath.Join(path, name) + child, openErr := unix.Openat(parent, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if openErr != nil { + _ = unix.Close(parent) + return runtimeLeaseHandle{}, false, fmt.Errorf("open sandbox runtime component %s: %w", path, openErr) + } + if err := refuseForeignRuntimeDirectory(child, path); err != nil { + _ = unix.Close(child) + _ = unix.Close(parent) + return runtimeLeaseHandle{}, false, err + } + _ = unix.Close(parent) + parent = child + } + defer func() { _ = unix.Close(parent) }() + + file, _, err := openRuntimeLeaseAtFD(parent, filepath.Base(sandboxRuntimeLeasePath(root))) + if err != nil { + return runtimeLeaseHandle{}, false, err + } + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { + _ = file.Close() + if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) { + return runtimeLeaseHandle{}, true, nil + } + return runtimeLeaseHandle{}, false, err + } + return runtimeLeaseHandle{file: file}, false, nil +} diff --git a/internal/sandbox/runtime_lease_rooted_windows.go b/internal/sandbox/runtime_lease_rooted_windows.go new file mode 100644 index 000000000..7537d1e60 --- /dev/null +++ b/internal/sandbox/runtime_lease_rooted_windows.go @@ -0,0 +1,264 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "unsafe" + + "golang.org/x/sys/windows" +) + +// acquireRuntimeLeaseRooted creates and locks the lease file for root without +// ever resolving an owned component by name. +// +// THE LEASE IS ACQUIRED BEFORE ANYTHING ELSE, SO IT CANNOT BE THE WEAK LINK. +// +// Provisioning already descends from the fixed cache or TEMP base through +// retained no-follow handles, because a predictable owned component is exactly +// what an ordinary same-account process can replace with a junction. Lease +// acquisition ran first and did neither: it checked the components for aliases, +// then called os.MkdirAll on the parent by pathname and opened +// ".lease" by pathname, both of which follow. A junction dropped on +// "zero", "runtime" or "v1" between the check and either call put elevated +// setup's first writes inside somebody else's tree, and restoring the component +// afterwards left the later handle-relative provisioning working on the +// legitimate tree so no post-check ever saw it. +// +// The lease file is a SIBLING of the runtime root rather than one of its owned +// components, so refuseAliasedRuntimeComponents never inspected it at all. Here +// it is created relative to the retained handle for the directory that contains +// it, which is the deepest owned component, so its name is resolved exactly once +// and relative to a verified object. +// +// Only the components above the leaf are created. The leaf itself belongs to +// provisioning, which records it for rollback; creating it here would mean two +// owners for one directory. +func acquireRuntimeLeaseRooted(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) { + base, components, owned := windowsSandboxRuntimeOwnedTail(root) + if !owned { + // Fail rather than fall back to the pathname walk: the walk is the defect. + return nil, nil, fmt.Errorf("acquire sandbox runtime lease for %s: %w", root, errRuntimeTailNotOwned) + } + if len(components) == 0 { + return nil, nil, fmt.Errorf("acquire sandbox runtime lease for %s: %w", root, errRuntimeTailNotOwned) + } + // The base is the operator's, and may legitimately be a redirected cache or + // TEMP location, so it is created and opened by name exactly as provisioning + // does. Everything below it is Zero's and is addressed by handle. + if runtimeLeasePreCreateBarrier != nil { + runtimeLeasePreCreateBarrier() + } + if err := os.MkdirAll(base, 0o700); err != nil { + return nil, nil, fmt.Errorf("create sandbox runtime base: %w", err) + } + + // The lease sits beside the leaf, so the deepest directory needed here is the + // leaf's parent. + parents := components[:len(components)-1] + tail := make([]string, 0, len(parents)) + current := base + for _, component := range parents { + current = filepath.Join(current, component) + tail = append(tail, current) + } + + created, parent, err := createRuntimeTailRetainingHandle(base, tail) + if err != nil { + if parent != 0 { + _ = windows.CloseHandle(parent) + } + return nil, created, err + } + defer func() { _ = windows.CloseHandle(parent) }() + + handle, madeLease, err := acquireSharedRuntimeLeaseAt(parent, filepath.Base(sandboxRuntimeLeasePath(root))) + if err != nil { + return nil, created, fmt.Errorf("acquire sandbox runtime lease: %w", err) + } + return &sandboxRuntimeLease{handle: handle, root: root, createdFile: madeLease}, created, nil +} + +// acquireSharedRuntimeLeaseAt is acquireSharedRuntimeLease with the file named +// relative to a directory handle instead of by full path. +func acquireSharedRuntimeLeaseAt(parent windows.Handle, name string) (runtimeLeaseHandle, bool, error) { + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return runtimeLeaseHandle{}, false, fmt.Errorf("encode sandbox runtime lease name %s: %w", name, err) + } + attributes := windows.OBJECT_ATTRIBUTES{ + RootDirectory: parent, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + var handle windows.Handle + var iosb windows.IO_STATUS_BLOCK + // FILE_OPEN_IF because the lease is shared: whoever gets there first creates + // it. FILE_NON_DIRECTORY_FILE so a directory under that name is refused. + // + // FILE_OPEN_REPARSE_POINT IS NOT A REFUSAL. It says do not follow, so the call + // returns a handle to the LINK, and FILE_NON_DIRECTORY_FILE excludes + // directories rather than non-directory reparse objects. A file symbolic link + // planted at .lease was therefore opened and locked as if it were the + // lease. No-follow and classification are two requirements; the flag is only + // the first, and the handle is asked for the second below. + err = windows.NtCreateFile( + &handle, + windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE, + &attributes, + &iosb, + nil, + windows.FILE_ATTRIBUTE_NORMAL, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_OPEN_IF, + windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, + ) + if err != nil { + return runtimeLeaseHandle{}, false, err + } + // FILE_CREATED here, rather than a Stat before the call, because only the + // create itself can distinguish the file it made from one that arrived a + // moment earlier. + created := iosb.Information == windowsFileCreatedDisposition + if err := refuseReparseRuntimeLeaseHandle(handle, name); err != nil { + _ = windows.CloseHandle(handle) + return runtimeLeaseHandle{}, false, err + } + file := os.NewFile(uintptr(handle), name) + if file == nil { + _ = windows.CloseHandle(handle) + return runtimeLeaseHandle{}, false, fmt.Errorf("wrap the sandbox runtime lease handle for %s", name) + } + lease := runtimeLeaseHandle{file: file} + if err := windows.LockFileEx(windows.Handle(file.Fd()), 0, 0, 1, 0, &lease.overlapped); err != nil { + _ = file.Close() + return runtimeLeaseHandle{}, false, err + } + return lease, created, nil +} + +// refuseReparseRuntimeLeaseHandle proves the opened lease is an ordinary file. +// +// Asked of the HANDLE, not the name, so there is no second resolution for a +// substitution to land in. This is the check the directory descent already makes +// in openWindowsChildNoFollow; the lease carried the no-follow flag and not the +// classification that has to go with it. +func refuseReparseRuntimeLeaseHandle(handle windows.Handle, name string) error { + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return fmt.Errorf("inspect the sandbox runtime lease %s: %w", name, err) + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return fmt.Errorf("refusing to use the sandbox runtime lease at %s: a reparse point here means the shared and exclusive holders would lock different objects", name) + } + return nil +} + +// openRuntimeLeaseParentRooted opens, and never creates, the directory holding +// the lease for root. +// +// Cleanup's side of the contract. It descends the same owned components from the +// same base with the same no-follow opens acquisition uses, so both sides resolve +// the lease name relative to a verified directory rather than by pathname. +// +// It creates nothing. A runtime tree that is not there has no lease to take, and +// cleanup rebuilding the tree in order to lock it would be inventing the thing it +// is about to remove. +func openRuntimeLeaseParentRooted(root string) (windows.Handle, error) { + base, components, owned := windowsSandboxRuntimeOwnedTail(root) + if !owned || len(components) == 0 { + return 0, fmt.Errorf("open the sandbox runtime lease parent for %s: %w", root, errRuntimeTailNotOwned) + } + parent, err := openWindowsDirectoryByName(base) + if err != nil { + return 0, fmt.Errorf("open sandbox runtime base %s: %w", base, err) + } + // The lease is a SIBLING of the leaf, so the deepest directory needed here is + // the leaf's parent, exactly as in acquisition. + for _, name := range components[:len(components)-1] { + child, openErr := openWindowsChildNoFollow(parent, name, + windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE, windows.FILE_DIRECTORY_FILE) + if openErr != nil { + _ = windows.CloseHandle(parent) + return 0, openErr + } + _ = windows.CloseHandle(parent) + parent = child + } + return parent, nil +} + +// tryAcquireExclusiveRuntimeLeaseRooted is cleanup's acquisition, resolved the +// way acquisition resolves it. +func tryAcquireExclusiveRuntimeLeaseRooted(root string) (runtimeLeaseHandle, bool, error) { + parent, err := openRuntimeLeaseParentRooted(root) + if err != nil { + return runtimeLeaseHandle{}, false, err + } + defer func() { _ = windows.CloseHandle(parent) }() + + name := filepath.Base(sandboxRuntimeLeasePath(root)) + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return runtimeLeaseHandle{}, false, fmt.Errorf("encode sandbox runtime lease name %s: %w", name, err) + } + attributes := windows.OBJECT_ATTRIBUTES{ + RootDirectory: parent, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + var handle windows.Handle + var iosb windows.IO_STATUS_BLOCK + // FILE_OPEN_IF matches what cleanup did by pathname with O_CREATE: a runtime + // root whose lease file is gone is held by nobody, and creating the empty lease + // is how that is expressed. + err = windows.NtCreateFile( + &handle, + windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE, + &attributes, + &iosb, + nil, + windows.FILE_ATTRIBUTE_NORMAL, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_OPEN_IF, + windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, + ) + if err != nil { + return runtimeLeaseHandle{}, false, err + } + if err := refuseReparseRuntimeLeaseHandle(handle, name); err != nil { + _ = windows.CloseHandle(handle) + return runtimeLeaseHandle{}, false, err + } + file := os.NewFile(uintptr(handle), name) + if file == nil { + _ = windows.CloseHandle(handle) + return runtimeLeaseHandle{}, false, fmt.Errorf("wrap the sandbox runtime lease handle for %s", name) + } + lease := runtimeLeaseHandle{file: file} + flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY) + if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &lease.overlapped); err != nil { + _ = file.Close() + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return runtimeLeaseHandle{}, true, nil + } + return runtimeLeaseHandle{}, false, err + } + return lease, false, nil +} + +// windowsFileCreatedDisposition is the IO_STATUS_BLOCK Information value that +// says NtCreateFile made the file rather than opening one that was there. +// x/sys/windows does not export it. +const windowsFileCreatedDisposition = 2 diff --git a/internal/sandbox/runtime_lease_unix.go b/internal/sandbox/runtime_lease_unix.go index 3f51f6d90..a44c57a6e 100644 --- a/internal/sandbox/runtime_lease_unix.go +++ b/internal/sandbox/runtime_lease_unix.go @@ -3,7 +3,6 @@ package sandbox import ( - "errors" "os" "golang.org/x/sys/unix" @@ -25,19 +24,15 @@ func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) { return runtimeLeaseHandle{file: file}, nil } -func tryAcquireExclusiveRuntimeLease(path string) (runtimeLeaseHandle, bool, error) { - file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) - if err != nil { - return runtimeLeaseHandle{}, false, err - } - if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { - _ = file.Close() - if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) { - return runtimeLeaseHandle{}, true, nil - } - return runtimeLeaseHandle{}, false, err - } - return runtimeLeaseHandle{file: file}, false, nil +// BOTH SIDES HAVE TO MEAN THE SAME OBJECT. +// +// Cleanup opened the lease by full pathname while acquisition opened it relative +// to a verified parent, so a symlink at .lease gave the two of them +// different files: a live command held a shared lock on one, cleanup took an +// exclusive lock on the other, both succeeded, and cleanup went on to remove a +// runtime root that was still in use. +func tryAcquireExclusiveRuntimeLease(root string) (runtimeLeaseHandle, bool, error) { + return tryAcquireExclusiveRuntimeLeaseRootedUnix(root) } func (lease runtimeLeaseHandle) release() { diff --git a/internal/sandbox/runtime_lease_windows.go b/internal/sandbox/runtime_lease_windows.go index a27594728..532baebbd 100644 --- a/internal/sandbox/runtime_lease_windows.go +++ b/internal/sandbox/runtime_lease_windows.go @@ -3,7 +3,6 @@ package sandbox import ( - "errors" "os" "golang.org/x/sys/windows" @@ -27,21 +26,19 @@ func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) { return handle, nil } -func tryAcquireExclusiveRuntimeLease(path string) (runtimeLeaseHandle, bool, error) { - file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) - if err != nil { - return runtimeLeaseHandle{}, false, err - } - handle := runtimeLeaseHandle{file: file} - flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY) - if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &handle.overlapped); err != nil { - _ = file.Close() - if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { - return runtimeLeaseHandle{}, true, nil - } - return runtimeLeaseHandle{}, false, err - } - return handle, false, nil +// BOTH SIDES HAVE TO MEAN THE SAME OBJECT. +// +// This opened the lease by full pathname with os.OpenFile and no no-follow flag, +// while shared acquisition opened it relative to a retained parent handle. A file +// symbolic link at .lease therefore handed the two sides different +// objects: setup or a running command held a shared lock on the link, cleanup +// took an exclusive lock on its target, both calls succeeded, and cleanup went on +// to RemoveAll a root somebody was still using. +// +// Mutual exclusion is a property of the OBJECT, so cleanup resolves the name the +// way acquisition does and refuses the same reparse objects. +func tryAcquireExclusiveRuntimeLease(root string) (runtimeLeaseHandle, bool, error) { + return tryAcquireExclusiveRuntimeLeaseRooted(root) } func (lease runtimeLeaseHandle) release() { diff --git a/internal/sandbox/runtime_owned_tail_scope_test.go b/internal/sandbox/runtime_owned_tail_scope_test.go new file mode 100644 index 000000000..e69391c52 --- /dev/null +++ b/internal/sandbox/runtime_owned_tail_scope_test.go @@ -0,0 +1,49 @@ +package sandbox + +import ( + "path/filepath" + "testing" +) + +// THE SHAPE HAS TO HOLD FOR BOTH SPELLINGS OF THE FIRST COMPONENT. +// +// The cache-derived root uses the fixed name; the temp-derived fallback scopes +// that component to the user wherever the temp root is shared. A tail that +// stops matching loses the rooted no-follow traversal AND the owned-component +// guard, and neither failure is visible where it happens. +// +// The scoped spelling does not exist on Windows, so without the seam this +// branch could only ever be exercised by another platform's CI. +func TestOwnedTailAcceptsBothFirstComponentSpellings(t *testing.T) { + previous := fallbackOwnedNamesForMatch + t.Cleanup(func() { fallbackOwnedNamesForMatch = previous }) + fallbackOwnedNamesForMatch = func() []string { + return []string{windowsSandboxRuntimeOwnedNames[0] + "-u1001", "runtime", "v1"} + } + + base := filepath.Join("C:", "shared") + for _, testCase := range []struct { + name string + first string + want bool + }{ + {"fixed cache spelling", windowsSandboxRuntimeOwnedNames[0], true}, + {"user-scoped fallback spelling", windowsSandboxRuntimeOwnedNames[0] + "-u1001", true}, + {"a different user's scope", windowsSandboxRuntimeOwnedNames[0] + "-u2002", false}, + {"an unrelated directory", "notzero", false}, + } { + t.Run(testCase.name, func(t *testing.T) { + root := filepath.Join(base, testCase.first, "runtime", "v1", "abcdef0123456789") + if _, _, ok := windowsSandboxRuntimeOwnedTail(root); ok != testCase.want { + t.Errorf("owned tail for %s = %v, want %v", root, ok, testCase.want) + } + }) + } + + // The components below the first stay fixed for both spellings, or the + // traversal would accept a tree Zero does not own. + wrong := filepath.Join(base, windowsSandboxRuntimeOwnedNames[0]+"-u1001", "elsewhere", "v1", "abcdef0123456789") + if _, _, ok := windowsSandboxRuntimeOwnedTail(wrong); ok { + t.Errorf("owned tail accepted %s, whose middle component is not one Zero owns", wrong) + } +} diff --git a/internal/sandbox/runtime_physical_path.go b/internal/sandbox/runtime_physical_path.go new file mode 100644 index 000000000..38a8f0702 --- /dev/null +++ b/internal/sandbox/runtime_physical_path.go @@ -0,0 +1,15 @@ +//go:build !windows + +package sandbox + +// physicalSandboxPath resolves path to the spelling the filesystem itself uses. +// +// Off Windows that is what canonicalSandboxWorkspaceRoot already does: +// filepath.EvalSymlinks follows every symlink, and there is no junction to +// follow. Two aliases remain unresolved and are handled by the identity walk in +// runtimeRootWithinWorkspace instead: a differing case on a case-insensitive +// volume, which EvalSymlinks preserves, and a bind mount, which no userspace +// path API resolves because the kernel deliberately presents it as a real path. +func physicalSandboxPath(path string) string { + return canonicalSandboxWorkspaceRoot(path) +} diff --git a/internal/sandbox/runtime_physical_path_windows.go b/internal/sandbox/runtime_physical_path_windows.go new file mode 100644 index 000000000..c57dd8e99 --- /dev/null +++ b/internal/sandbox/runtime_physical_path_windows.go @@ -0,0 +1,116 @@ +//go:build windows + +package sandbox + +import ( + "path/filepath" + "strings" + + "golang.org/x/sys/windows" +) + +const ( + // GetFinalPathNameByHandle flags. golang.org/x/sys/windows does not export + // these; both are zero, and naming them keeps the call site readable. + fileNameNormalized = 0x0 + volumeNameDOS = 0x0 +) + +// physicalSandboxPath resolves path to the spelling the filesystem itself uses. +// +// canonicalSandboxWorkspaceRoot cannot do this on Windows. filepath.EvalSymlinks +// returns a directory JUNCTION unchanged, so a TEMP that reaches into the +// workspace through one still measures as outside it, and that is how a runtime +// tree ends up inside the very workspace the sandbox exists to confine. +// GetFinalPathNameByHandle answers with the target's real path, junctions and +// mount points followed and casing as stored on disk. +// +// This deliberately opens WITHOUT FILE_FLAG_OPEN_REPARSE_POINT, the opposite of +// openWindowsACLTarget. That helper must refuse to follow a reparse point, +// because following one is the path-swap it guards against. Here the whole +// question is where the reparse point leads, and the answer is only ever used to +// decide that a runtime root is contained, never that it is safe. +func physicalSandboxPath(path string) string { + cleaned := canonicalSandboxWorkspaceRoot(path) + if cleaned == "" || cleaned == "." { + return cleaned + } + // The runtime root does not exist yet at derivation time, which is the point. + // Resolve the deepest ancestor that does exist and re-append the rest, the + // same shape canonicalSandboxWorkspaceRoot uses for EvalSymlinks. + remainder := "" + current := cleaned + for { + if resolved, ok := finalWindowsPathName(current); ok { + if remainder == "" { + return resolved + } + return filepath.Join(resolved, remainder) + } + parent := filepath.Dir(current) + if parent == current { + // Nothing along the path could be opened. The cleaned form is the best + // answer available, and the caller's spelling comparison already ran. + return cleaned + } + remainder = filepath.Join(filepath.Base(current), remainder) + current = parent + } +} + +func finalWindowsPathName(path string) (string, bool) { + utf16Path, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", false + } + // Zero desired access is enough: GetFinalPathNameByHandle reads metadata, so + // this cannot be refused for lack of read rights on the directory contents. + handle, err := windows.CreateFile( + utf16Path, + 0, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS, + 0, + ) + if err != nil { + return "", false + } + defer func() { _ = windows.CloseHandle(handle) }() + + buffer := make([]uint16, windows.MAX_PATH) + for attempt := 0; attempt < 2; attempt++ { + n, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), fileNameNormalized|volumeNameDOS) + if err != nil { + return "", false + } + if int(n) >= len(buffer) { + // TWO DIFFERENT CONVENTIONS, and the boundary is where they meet. On + // success the return value EXCLUDES the terminating null; on an + // insufficient buffer it INCLUDES it. So n == len(buffer) cannot be + // read as a complete path: a success that large would not have fitted + // its own terminator, which means the only reading left is a required + // size. Retrying on >= costs one extra call in a case that may not be + // reachable and removes the need to be right about which it was. + buffer = make([]uint16, int(n)+1) + continue + } + if n == 0 { + return "", false + } + return trimWindowsExtendedPrefix(windows.UTF16ToString(buffer[:n])), true + } + return "", false +} + +// trimWindowsExtendedPrefix converts the \\?\ form GetFinalPathNameByHandle +// returns into an ordinary path, so it compares against paths the rest of this +// package builds with filepath.Join. \\?\UNC\server\share becomes +// \\server\share; anything else loses the \\?\ and keeps its drive letter. +func trimWindowsExtendedPrefix(path string) string { + if rest, ok := strings.CutPrefix(path, `\\?\UNC\`); ok { + return `\\` + rest + } + return strings.TrimPrefix(path, `\\?\`) +} diff --git a/internal/sandbox/runtime_record_states_windows_test.go b/internal/sandbox/runtime_record_states_windows_test.go new file mode 100644 index 000000000..7836fa9e2 --- /dev/null +++ b/internal/sandbox/runtime_record_states_windows_test.go @@ -0,0 +1,208 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// THE CREATION ITSELF HAS TO ESTABLISH THE IDENTITY. +// +// Win32 CreateFile cannot create a directory whatever disposition it is given, +// so the CREATE_NEW attempt fell through to os.Mkdir plus a separate reopen on +// EVERY real creation and the documented creation-handle contract never once +// held. The runtime parent belongs to the ordinary user, who can rename the new +// directory A away and drop an ordinary directory B at the predictable name in +// between, and the ledger then records B for a directory this run never made. +// +// Driving the interleaving is not the point, and a barrier there would only +// prove the window was still measurable. The property is that no reopen exists +// to be raced: whatever the name resolves to afterwards, the recorded identity +// is the object the create returned. +func TestCreatedRuntimeDirIdentityComesFromTheCreation(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "created") + + // THE DETERMINISTIC BARRIER. The old shape resolved the pathname a second + // time after os.Mkdir, and that reopen is the whole window. Counting the + // resolutions turns "the creation establishes the identity" into something + // checked: on Windows the create returns the handle, so this must be zero. + resolutions := 0 + restore := runtimeIdentityAfterCreate + runtimeIdentityAfterCreate = func(p string) (string, bool) { + resolutions++ + return restore(p) + } + t.Cleanup(func() { runtimeIdentityAfterCreate = restore }) + + identity, identified, err := createRuntimeDirIdentified(path) + if err != nil || !identified { + t.Fatalf("create: identity=%q identified=%v err=%v", identity, identified, err) + } + if resolutions != 0 { + t.Errorf("the creation resolved the pathname again %d time(s); identity must come from the creation handle", resolutions) + } + info, statErr := os.Stat(path) + if statErr != nil || !info.IsDir() { + t.Fatalf("no directory was created: err=%v", statErr) + } + + // Substitute the whole directory the way the parent's owner could, then ask + // what the name says now. The record must still describe what was created. + aside := filepath.Join(root, "moved-aside") + if err := os.Rename(path, aside); err != nil { + t.Skipf("cannot rename the runtime directory on this filesystem: %v", err) + } + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + + substitute, ok := runtimeDirIdentity(path) + if !ok { + t.Fatal("the substitute could not be identified, so the comparison proves nothing") + } + if identity == substitute { + t.Fatal("SETUP INVALID: the substitute has the same identity as the created directory") + } + moved, ok := runtimeDirIdentity(aside) + if !ok { + t.Fatal("the created directory could not be identified after the rename") + } + if identity != moved { + t.Errorf("the recorded identity %q describes neither the created directory (%q) nor anything this run owns", identity, moved) + } +} + +// A component another process created first must stay "not ours", and it must +// keep saying so through os.IsExist, which is what the caller asks and which +// does not unwrap a %w chain. +func TestCreatedRuntimeDirRefusesAnExistingName(t *testing.T) { + path := filepath.Join(t.TempDir(), "created") + if _, _, err := createRuntimeDirIdentified(path); err != nil { + t.Fatalf("first create: %v", err) + } + identity, identified, err := createRuntimeDirIdentified(path) + if !os.IsExist(err) { + t.Errorf("creating over an existing directory returned %v, want an IsExist error", err) + } + if identified || identity != "" { + t.Errorf("a refused create still produced an ownership record: identity=%q identified=%v", identity, identified) + } +} + +// THE THREE STATES ARE DIFFERENT FACTS. +// +// "Read it and there was nothing" and "could not read it" both used to arrive as +// existed=false. The stamp writer uses FILE_OVERWRITE_IF and can replace an +// existing stamp even where the read was denied, so that lie let a setup which +// then FAILED delete an attestation it had no record of, leaving the previous +// run's marker pointing at a runtime root it can no longer prove. +func TestRuntimeStampSnapshotSeparatesAbsentPresentAndUnknown(t *testing.T) { + t.Run("absent", func(t *testing.T) { + root := t.TempDir() + _, _, prior, state, err := snapshotRuntimeStampBound(root) + if err != nil { + t.Fatalf("a readable root with no stamp is not an error: %v", err) + } + if state != runtimeStampAbsent { + t.Errorf("state = %v, want absent", state) + } + if prior != nil { + t.Errorf("absent produced prior bytes %q", prior) + } + }) + + t.Run("present", func(t *testing.T) { + root := t.TempDir() + want := []byte("prior-attestation") + if err := os.WriteFile(filepath.Join(root, windowsSandboxRuntimeStampName), want, 0o600); err != nil { + t.Fatal(err) + } + _, _, prior, state, err := snapshotRuntimeStampBound(root) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if state != runtimeStampPresent { + t.Errorf("state = %v, want present", state) + } + if string(prior) != string(want) { + t.Errorf("prior = %q, want %q", prior, want) + } + }) + + // A stamp NAME that cannot be read as a file. The child open is refused for a + // reason that is emphatically not "not found", which is the whole distinction. + t.Run("unknown", func(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, windowsSandboxRuntimeStampName), 0o700); err != nil { + t.Fatal(err) + } + _, _, prior, state, err := snapshotRuntimeStampBound(root) + if err == nil { + t.Fatal("an unreadable stamp was reported as a successful snapshot") + } + if state != runtimeStampUnknown { + t.Errorf("state = %v, want unknown", state) + } + if prior != nil { + t.Errorf("unknown produced prior bytes %q", prior) + } + }) +} + +// AND UNKNOWN MUST NOT AUTHORIZE COMPENSATION. +// +// The forward mutation is refused before it begins, so this asserts the second +// half: a record that somehow reached compensation with an unproven prior state +// leaves the current stamp alone and says so, rather than deleting it and +// returning with nothing to put back. +func TestUnknownPriorStampIsNeverCompensated(t *testing.T) { + root := t.TempDir() + stampPath := filepath.Join(root, windowsSandboxRuntimeStampName) + current := []byte("the-attestation-of-the-previous-successful-setup") + if err := os.WriteFile(stampPath, current, 0o600); err != nil { + t.Fatal(err) + } + identity, identified := runtimeDirIdentity(root) + if !identified { + t.Fatal("SETUP INVALID: the runtime root could not be identified") + } + + snapshot := windowsSandboxStampSnapshot{ + path: stampPath, + priorState: runtimeStampUnknown, + root: root, + rootIdentity: identity, + rootIdentified: true, + } + err := snapshot.restore() + if err == nil { + t.Fatal("compensation acted on a prior state it never established, and reported success") + } + + after, readErr := os.ReadFile(stampPath) + if readErr != nil { + t.Fatalf("the existing stamp was destroyed by a rollback that had nothing to restore: %v", readErr) + } + if string(after) != string(current) { + t.Errorf("the existing stamp was rewritten: got %q, want %q", after, current) + } +} + +// And setup refuses BEFORE the ACL and stamp are applied, which is the half that +// keeps the writer from running at all. +func TestSetupRefusesAnUnreadablePriorStamp(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, windowsSandboxRuntimeStampName), 0o700); err != nil { + t.Fatal(err) + } + snapshot, err := snapshotWindowsSandboxRuntimeStamp(root) + if err == nil { + t.Fatal("setup accepted a snapshot that could not read the prior stamp") + } + if snapshot.priorState != runtimeStampUnknown { + t.Errorf("the refused snapshot carried state %v, want unknown", snapshot.priorState) + } +} diff --git a/internal/sandbox/runtime_recorded_fallback_test.go b/internal/sandbox/runtime_recorded_fallback_test.go new file mode 100644 index 000000000..4057bc1e8 --- /dev/null +++ b/internal/sandbox/runtime_recorded_fallback_test.go @@ -0,0 +1,117 @@ +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// A RECORDED ROOT IS HISTORY. IT MUST NOT BE RE-DERIVED FROM TODAY'S TEMP. +// +// Setup reaches the temp fallback when the preferred cache-derived root cannot +// be leased, and it records that concrete root in the marker. Command selection +// then recognised the record only by deriving today's fallback from this +// process's TEMP and comparing. A later IDE, service or terminal running with a +// different TEMP therefore matched neither of today's candidates: the record was +// ignored, selection produced a tree setup never provisioned, and the runner +// rejected the marker as out of date. Re-running setup from the original +// environment repeats the original answer and does not make the other +// environment converge. +// +// The record is now recognised by its own shape instead, which is the question +// that was actually being asked: does this belong to this workspace and home. +// +// Not gated on Windows. The marker only exists where setup wrote one, so this is +// Windows-only in practice, but keeping it platform-neutral means the +// setup-to-command contract runs on every CI runner rather than only one. +func TestARecordedFallbackSurvivesATempChange(t *testing.T) { + home := t.TempDir() + workspace := canonicalSandboxWorkspaceRoot(t.TempDir()) + + tempA := t.TempDir() + t.Setenv("TMP", tempA) + t.Setenv("TEMP", tempA) + recorded, err := fallbackSandboxRuntimeRoot(workspace) + if err != nil { + t.Skipf("no fallback runtime root available here: %v", err) + } + if err := os.MkdirAll(recorded, 0o700); err != nil { + t.Fatal(err) + } + writeRecordedRoot(t, home, recorded) + + // SETUP: with the original TEMP the record is honoured, so a failure after the + // flip is about the flip and not about the record being unusable. + preferred := preferredRuntimeRootFor(t, workspace) + if got := pinnedSandboxRuntimeRoot(workspace, preferred, recorded, home); got != recorded { + t.Fatalf("SETUP INVALID: the record is not honoured even before the temp change: got %q, want %q", got, recorded) + } + + // The same command, run later from an environment with a different TEMP. + tempB := t.TempDir() + t.Setenv("TMP", tempB) + t.Setenv("TEMP", tempB) + derivedNow, err := fallbackSandboxRuntimeRoot(workspace) + if err != nil { + t.Skipf("no fallback runtime root under the second temp: %v", err) + } + if sameWindowsRuntimeRootPath(derivedNow, recorded) { + t.Skip("SETUP: the temp change did not move the derived fallback, so there is nothing to diverge") + } + + if got := pinnedSandboxRuntimeRoot(workspace, preferred, derivedNow, home); got != recorded { + t.Fatalf("selection abandoned the root setup provisioned after only TEMP changed: got %q, want the recorded %q", got, recorded) + } +} + +// preferredRuntimeRootFor is the cache-derived candidate, which must stay a +// candidate: this change is about the fallback only. +func preferredRuntimeRootFor(t *testing.T, workspace string) string { + t.Helper() + cacheRoot, err := sandboxUserCacheDir() + if err != nil { + t.Skipf("no user cache directory here: %v", err) + } + root, err := sandboxRuntimeRootFor(workspace, canonicalSandboxWorkspaceRoot(cacheRoot)) + if err != nil { + t.Skipf("no preferred runtime root here: %v", err) + } + return root +} + +// AND THE PROTECTION THAT MADE THE OLD COMPARISON WORTH HAVING STAYS. +// +// Recognising the record by shape must not turn into recognising anybody's +// record. A fallback root belonging to a DIFFERENT workspace has the same owned +// shape and a different digest, so it is still refused. +func TestARecordedFallbackForAnotherWorkspaceIsStillRefused(t *testing.T) { + home := t.TempDir() + mine := canonicalSandboxWorkspaceRoot(t.TempDir()) + theirs := canonicalSandboxWorkspaceRoot(t.TempDir()) + + temp := t.TempDir() + t.Setenv("TMP", temp) + t.Setenv("TEMP", temp) + + theirRoot, err := fallbackSandboxRuntimeRoot(theirs) + if err != nil { + t.Skipf("no fallback runtime root available here: %v", err) + } + writeRecordedRoot(t, home, theirRoot) + + if got := pinnedSandboxRuntimeRoot(mine, filepath.Join(t.TempDir(), "preferred"), filepath.Join(t.TempDir(), "fallback"), home); got != "" { + t.Fatalf("pinned %q, which was provisioned for another workspace", got) + } +} + +// A record with no owned runtime shape at all is refused too, so the shape test +// is doing work rather than waving anything through. +func TestARecordedRootWithoutTheOwnedShapeIsRefused(t *testing.T) { + home := t.TempDir() + workspace := canonicalSandboxWorkspaceRoot(t.TempDir()) + writeRecordedRoot(t, home, filepath.Join(t.TempDir(), "not-a-runtime-root")) + + if got := pinnedSandboxRuntimeRoot(workspace, filepath.Join(t.TempDir(), "preferred"), filepath.Join(t.TempDir(), "fallback"), home); got != "" { + t.Fatalf("pinned %q, which has none of the owned runtime shape", got) + } +} diff --git a/internal/sandbox/runtime_recording_base_windows_test.go b/internal/sandbox/runtime_recording_base_windows_test.go new file mode 100644 index 000000000..a9cb460d7 --- /dev/null +++ b/internal/sandbox/runtime_recording_base_windows_test.go @@ -0,0 +1,115 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// EXISTENCE MUST NOT CHOOSE THE TRUST BOUNDARY. +// +// createRuntimeDirRecording used to os.Stat its way to the deepest component +// that already existed and open THAT by name. On the ordinary second-workspace +// shape \zero\runtime\v1 is already there and only the digest is missing, +// so the single by-name open was v1 — one of the predictable, user-owned +// components the rooted traversal exists to protect, not the cache directory +// above the tail. +// +// The race that opens: the pre-check accepts an ordinary v1, os.Stat selects it, +// the local owner replaces it with a junction, the by-name open follows it, and +// elevated setup creates the digest beneath the redirected target. Putting the +// original back makes the post-check see an ordinary pathname again, so the +// creation record names the redirected object under the original path and +// compensation leaves a privileged creation as residue. +// +// This drives the PRODUCTION entry point rather than the descent helper, because +// the helper is handed a base that has already been chosen — which is precisely +// the decision under test. A junction needs no privilege on Windows, so this +// runs unelevated. +func TestRuntimeRecordingOpensTheFixedBaseNotTheDeepestExistingComponent(t *testing.T) { + cacheBase := t.TempDir() + target := t.TempDir() + + // The shape a second workspace finds: everything above the digest already + // exists, so the old walk would have picked v1 as its by-name base. + v1 := filepath.Join(cacheBase, "zero", "runtime", "v1") + if err := os.MkdirAll(v1, 0o755); err != nil { + t.Fatal(err) + } + root := filepath.Join(v1, "deadbeefdeadbeef") + + // Sanity: the helper agrees the fixed base is the cache directory, not v1. + base, components, owned := windowsSandboxRuntimeOwnedTail(root) + if !owned || base != cacheBase { + t.Fatalf("SETUP INVALID: owned tail resolved base=%q owned=%v, want %q", base, owned, cacheBase) + } + if len(components) == 0 { + t.Fatal("SETUP INVALID: the owned tail has no components") + } + + // THE PROPERTY, OBSERVED DIRECTLY. Whether a particular swap is caught depends + // on where the barrier sits; which path is opened by name does not, and that is + // the finding. Recorded here so the assertion cannot pass for an unrelated + // reason. + var openedByName []string + previousOpen := runtimeBaseOpenedByName + runtimeBaseOpenedByName = func(path string) { openedByName = append(openedByName, path) } + t.Cleanup(func() { runtimeBaseOpenedByName = previousOpen }) + + swapped := false + previous := runtimeDescentBarrier + runtimeDescentBarrier = func() { + // Fires after the base has been opened and before the first owned + // component is touched: exactly the interval the old walk was vulnerable + // in. Replace v1 with a junction into a directory the test watches. + if err := os.Remove(v1); err != nil { + t.Fatalf("SETUP INVALID: could not clear v1 to plant the junction: %v", err) + } + out, err := exec.Command("cmd", "/c", "mklink", "/J", v1, target).CombinedOutput() + if err != nil { + t.Fatalf("SETUP INVALID: mklink /J: %v\n%s", err, out) + } + swapped = true + } + t.Cleanup(func() { runtimeDescentBarrier = previous }) + + created, err := createRuntimeDirRecording(root) + + if !swapped { + t.Fatal("SETUP INVALID: the barrier never ran, so no swap was attempted") + } + // Exactly one by-name open, and it is the cache directory above the tail. + // Under the old walk this was v1, a component the local user controls. + if len(openedByName) != 1 { + t.Fatalf("the descent opened %d paths by name, want exactly 1: %v", len(openedByName), openedByName) + } + if !strings.EqualFold(openedByName[0], cacheBase) { + t.Fatalf("opened %q by name, want the fixed base %q: existence must not choose the trust boundary", openedByName[0], cacheBase) + } + if err == nil { + t.Fatalf("the recording walked through a junction at an owned component and reported success: created=%v", created) + } + // The redirected target must be untouched: no digest created beneath it. + entries, readErr := os.ReadDir(target) + if readErr != nil { + t.Fatal(readErr) + } + if len(entries) != 0 { + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + t.Errorf("elevated setup created %v beneath the redirected target", names) + } + // And nothing may be recorded as ours under a pathname that now names + // somebody else's object, since that is what compensation acts on. + for _, record := range created { + if strings.HasPrefix(strings.ToLower(record.path), strings.ToLower(v1)) { + t.Errorf("a redirected creation was recorded as ours: %+v", record) + } + } +} diff --git a/internal/sandbox/runtime_root_alias_test.go b/internal/sandbox/runtime_root_alias_test.go new file mode 100644 index 000000000..36b0e76bd --- /dev/null +++ b/internal/sandbox/runtime_root_alias_test.go @@ -0,0 +1,180 @@ +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// aliasTo returns a second path that IS target, spelled in a way +// canonicalSandboxWorkspaceRoot does not fold, or "" when this platform offers +// none. +// +// A plain symlink is no good: EvalSymlinks folds it, so the spelling comparison +// already wins and nothing downstream is exercised. The two aliases that survive +// canonicalization are a Windows directory junction, which needs no privilege, +// and an upper-cased spelling on a case-insensitive volume, which is the macOS +// default. +func aliasTo(t *testing.T, target string) string { + t.Helper() + + if runtime.GOOS == "windows" { + link := filepath.Join(t.TempDir(), "alias") + if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil { + t.Logf("mklink /J unavailable: %v %s", err, out) + return "" + } + // A junction that did not actually land on the target proves nothing. + targetInfo, err := os.Stat(target) + if err != nil { + t.Fatalf("stat target: %v", err) + } + linkInfo, err := os.Stat(link) + if err != nil || !os.SameFile(targetInfo, linkInfo) { + t.Fatalf("mklink reported success but %s is not %s", link, target) + } + return link + } + + upper := strings.ToUpper(target) + if upper == target { + return "" + } + targetInfo, err := os.Stat(target) + if err != nil { + t.Fatalf("stat target: %v", err) + } + upperInfo, err := os.Stat(upper) + if err != nil || !os.SameFile(targetInfo, upperInfo) { + // Case-sensitive volume: the upper-cased name is a different directory, or + // none at all. Correctly not an alias. + return "" + } + return upper +} + +// TestRuntimeRootRefusesAWorkspaceReachedByAnAlias is the regression for the gap +// the macOS Smoke run exposed and the junction gap found alongside it. +// canonicalSandboxWorkspaceRoot folds the aliases EvalSymlinks folds and no +// others, so a runtime root that reaches the workspace under a junction, or under +// a different casing on a case-insensitive volume, measured as OUTSIDE the +// workspace and the runtime tree was allowed to live inside the tree the sandbox +// exists to confine. +// +// Both alias shapes are covered on purpose. An alias whose target IS the +// workspace root is caught by the identity walk; an alias into a SUBDIRECTORY is +// not, because the walk climbs a spelling and a junction has no spelling chain +// back into its target's parent. Only the physical-path resolution catches that +// one, and a test that exercised the root shape alone reported green while it was +// broken. +func TestRuntimeRootRefusesAWorkspaceReachedByAnAlias(t *testing.T) { + for _, shape := range []struct { + name string + suffix []string + }{ + {name: "alias to the workspace root"}, + {name: "alias into a workspace subdirectory", suffix: []string{"build", "tmp"}}, + } { + t.Run(shape.name, func(t *testing.T) { + workspaceRoot := canonicalSandboxWorkspaceRoot(filepath.Join(t.TempDir(), "workspace")) + target := filepath.Join(append([]string{workspaceRoot}, shape.suffix...)...) + if err := os.MkdirAll(target, 0o700); err != nil { + t.Fatalf("create target: %v", err) + } + + alias := aliasTo(t, target) + if alias == "" { + t.Skip("no alias spelling is constructible here") + } + + // Precondition, asserted rather than assumed: the plain spelling + // comparison has to MISS. If canonicalization folds this alias the old + // code already handled it and the test proves nothing. + probe := filepath.Join(canonicalSandboxWorkspaceRoot(alias), "zero", "runtime", "v1", "0123456789abcdef") + if pathWithinRoot(workspaceRoot, probe) { + t.Skipf("canonicalization already folds %s into %s", alias, workspaceRoot) + } + + t.Setenv("TMP", alias) + t.Setenv("TEMP", alias) + t.Setenv("TMPDIR", alias) + + root, err := fallbackSandboxRuntimeRoot(workspaceRoot) + if err == nil { + t.Fatalf("fallback returned runtime root %s for a TEMP that reaches %s through %s; the sandbox would keep its own cache inside the tree it is confining", root, target, alias) + } + if !strings.Contains(err.Error(), "inside workspace") { + t.Fatalf("fallback refused for the wrong reason: %v", err) + } + }) + } +} + +// TestDeterministicRuntimeRootRejectsAnAliasedCache covers the other call site. +// Reverting only deterministicSandboxRuntimeRoot left the whole package green +// before this existed, so the cache-derived root had no alias coverage at all. +func TestDeterministicRuntimeRootRejectsAnAliasedCache(t *testing.T) { + workspaceRoot := canonicalSandboxWorkspaceRoot(filepath.Join(t.TempDir(), "workspace")) + inner := filepath.Join(workspaceRoot, "cachehome") + if err := os.MkdirAll(inner, 0o700); err != nil { + t.Fatalf("create target: %v", err) + } + + // The derived root is /zero/runtime/v1/, so aliasing /zero + // is what puts the whole tree inside the workspace. + cacheRoot := filepath.Join(t.TempDir(), "cache") + if err := os.MkdirAll(cacheRoot, 0o700); err != nil { + t.Fatalf("create cache: %v", err) + } + // A junction on Windows, a symlink elsewhere. Both point at inner by its REAL + // spelling on purpose. Stacking a case alias on top would build the one shape + // the doc on runtimeRootWithinWorkspace says stays open off Windows, an alias + // into a workspace SUBDIRECTORY that no spelling chain reaches, and the test + // would then be asserting a guarantee macOS does not make. + link := filepath.Join(cacheRoot, "zero") + if runtime.GOOS == "windows" { + if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, inner).CombinedOutput(); err != nil { + t.Skipf("mklink /J unavailable: %v %s", err, out) + } + } else if err := os.Symlink(inner, link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + innerInfo, err := os.Stat(inner) + if err != nil { + t.Fatalf("stat target: %v", err) + } + linkInfo, err := os.Stat(link) + if err != nil || !os.SameFile(innerInfo, linkInfo) { + t.Fatalf("%s is not an alias of %s", link, inner) + } + + root, usableOutside := deterministicSandboxRuntimeRoot(workspaceRoot, cacheRoot) + if usableOutside { + t.Fatalf("cache-derived runtime root %s reported as outside %s while it resolves inside it", root, workspaceRoot) + } +} + +// TestRuntimeRootWithinWorkspaceKeepsAGenuinelyOutsideRootUsable is the other +// half. The three checks only ever ADD containment answers, so the one thing that +// could go wrong is reporting containment for a root that is merely adjacent. +func TestRuntimeRootWithinWorkspaceKeepsAGenuinelyOutsideRootUsable(t *testing.T) { + parent := t.TempDir() + workspaceRoot := canonicalSandboxWorkspaceRoot(filepath.Join(parent, "workspace")) + sibling := filepath.Join(parent, "workspace-runtime", "zero", "runtime", "v1", "0123456789abcdef") + if err := os.MkdirAll(workspaceRoot, 0o700); err != nil { + t.Fatalf("create workspace: %v", err) + } + if err := os.MkdirAll(sibling, 0o700); err != nil { + t.Fatalf("create sibling: %v", err) + } + + if runtimeRootWithinWorkspace(workspaceRoot, sibling) { + t.Fatalf("%s reported as inside %s; a sibling sharing a name prefix is not contained", sibling, workspaceRoot) + } + if _, usableOutside := deterministicSandboxRuntimeRoot(workspaceRoot, filepath.Join(parent, "cache")); !usableOutside { + t.Fatalf("a cache root outside the workspace was reported unusable") + } +} diff --git a/internal/sandbox/runtime_root_guard.go b/internal/sandbox/runtime_root_guard.go new file mode 100644 index 000000000..49d5f05ff --- /dev/null +++ b/internal/sandbox/runtime_root_guard.go @@ -0,0 +1,100 @@ +package sandbox + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// errRuntimeComponentAliased marks a refusal that must NOT be treated as a +// reason to relocate. +// +// selectSandboxRuntimeRoot falls back to the temp root when the preferred one +// cannot be leased, which is right for an unusable directory and wrong for a +// hostile one: relocating would leave the attacker's link in place, say nothing, +// and simply move to the next predictable name for them to take as well. A +// machine in this state needs an operator, not a retry. +var errRuntimeComponentAliased = errors.New("sandbox runtime component is aliased") + +// THE RUNTIME TREE IS CREATED IN A DIRECTORY OTHER PEOPLE CAN WRITE TO. +// +// The fallback root used to come from os.MkdirTemp, which mints a random name +// atomically at mode 0700, so no other local user could name the directory, let +// alone pre-create it. Deriving it from a digest of the workspace path bought +// stable cache reuse across runs and gave that name away: every component is +// computable by anyone who can guess the workspace path, and on Linux +// os.TempDir() is the shared, world-writable /tmp whenever TMPDIR is unset. +// +// What follows from a name another user can create is not subtle. +// os.MkdirAll returns nil when Stat says the path is already a directory, and +// Stat FOLLOWS LINKS, so a link planted at the leaf is silently accepted; the +// cache, data and tmp directories are then created inside whatever it points at, +// os.Chmod and os.Chtimes follow it too, and the root is handed to the platform +// backend as a WRITE ROOT (a read-write bind under bwrap) with TMPDIR, GOCACHE, +// GOMODCACHE and the package-manager caches all pointed inside it. The sandbox +// would be granting the confined command write access to a directory an +// attacker chose. +// +// Two things close it, and both are needed. The path carries a per-user +// component so ordinary users are not sharing one tree, and every component Zero +// owns is verified to be a real directory belonging to this user before anything +// is created through it. The name alone is not enough: /tmp is world-writable, +// so another user can create the per-user directory FIRST and wait. +// +// This is the same rule refuseReparsedRuntimeAncestors applies during elevated +// Windows setup. That guard was never on this path, which is the shared one +// every platform takes for every command. +func refuseAliasedRuntimeComponents(root string) error { + for _, component := range ownedRuntimeComponents(root) { + info, err := os.Lstat(component) + if err != nil { + if os.IsNotExist(err) { + // Not there yet, so there is nothing to alias. The caller re-checks + // after creation, because this alone is a check-then-use. + continue + } + return fmt.Errorf("inspect sandbox runtime component %s: %w", component, err) + } + // ModeIrregular as well as ModeSymlink: a Windows junction is reported as + // irregular, needs no privilege to create, and a guard written against + // symlinks alone is inert against it. + if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 { + return fmt.Errorf("%w: refusing to use the sandbox runtime through a link at %s: "+ + "a link here redirects the directory the sandbox is granted write access to", errRuntimeComponentAliased, component) + } + if !info.IsDir() { + // NOT tagged as aliased, deliberately. An ordinary file sitting where a + // runtime component belongs is a broken machine rather than a hostile + // one, and relocating to the other candidate is the sane recovery that + // was already there. Only a link or a directory belonging to somebody + // else says an attacker chose this path, and those are the two the + // caller must refuse outright rather than route around. + return fmt.Errorf("sandbox runtime component %s exists and is not a directory", component) + } + if err := refuseForeignRuntimeComponent(component, info); err != nil { + return err + } + } + return nil +} + +// ownedRuntimeComponents lists the trailing components Zero creates, deepest +// first. Anything above them belongs to the user or the machine. +func ownedRuntimeComponents(root string) []string { + cleaned := filepath.Clean(root) + if cleaned == "" || cleaned == "." { + return nil + } + components := make([]string, 0, windowsSandboxRuntimeOwnedDepth) + current := cleaned + for range windowsSandboxRuntimeOwnedDepth { + components = append(components, current) + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return components +} diff --git a/internal/sandbox/runtime_root_guard_helper_test.go b/internal/sandbox/runtime_root_guard_helper_test.go new file mode 100644 index 000000000..87246de3b --- /dev/null +++ b/internal/sandbox/runtime_root_guard_helper_test.go @@ -0,0 +1,16 @@ +package sandbox + +import ( + "crypto/sha256" + "encoding/hex" + "testing" +) + +// digestFor recomputes the fallback leaf name the way fallbackSandboxRuntimeRoot +// does, so the test moves with the implementation rather than pinning a literal. +func digestFor(workspaceRoot string, scope string) string { + digest := sha256.Sum256([]byte(canonicalSandboxWorkspaceRoot(workspaceRoot) + "\x00" + scope)) + return hex.EncodeToString(digest[:8]) +} + +var _ = testing.Verbose diff --git a/internal/sandbox/runtime_root_guard_link_unix_test.go b/internal/sandbox/runtime_root_guard_link_unix_test.go new file mode 100644 index 000000000..33afd4f49 --- /dev/null +++ b/internal/sandbox/runtime_root_guard_link_unix_test.go @@ -0,0 +1,16 @@ +//go:build !windows + +package sandbox + +import ( + "os" + "testing" +) + +// A POSIX symlink is the reachable alias off Windows. +func linkRuntimeComponent(t *testing.T, link, target string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Skipf("cannot create a symlink in this environment: %v", err) + } +} diff --git a/internal/sandbox/runtime_root_guard_link_windows_test.go b/internal/sandbox/runtime_root_guard_link_windows_test.go new file mode 100644 index 000000000..a4d67a16f --- /dev/null +++ b/internal/sandbox/runtime_root_guard_link_windows_test.go @@ -0,0 +1,19 @@ +//go:build windows + +package sandbox + +import ( + "os/exec" + "testing" +) + +// A JUNCTION, not a symlink: it needs no privilege, which is what makes it the +// alias an ordinary local user can actually plant, and os.Lstat reports it as +// ModeIrregular rather than ModeSymlink. +func linkRuntimeComponent(t *testing.T, link, target string) { + t.Helper() + output, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput() + if err != nil { + t.Skipf("cannot create a junction in this environment: %v (%s)", err, output) + } +} diff --git a/internal/sandbox/runtime_root_guard_test.go b/internal/sandbox/runtime_root_guard_test.go new file mode 100644 index 000000000..268691f30 --- /dev/null +++ b/internal/sandbox/runtime_root_guard_test.go @@ -0,0 +1,208 @@ +package sandbox + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// A LINK AT AN OWNED COMPONENT MUST NOT BE BUILT THROUGH. +// +// This is the shared path every platform takes for every command, and until now +// it had no guard at all: the only link refusal in this package was wired into +// elevated Windows setup. os.MkdirAll returns nil when Stat says the path is +// already a directory, and Stat FOLLOWS links, so a link planted at an owned +// component is accepted silently. The cache, data and tmp directories are then +// created inside whatever it points at, Chmod and Chtimes follow it too, and the +// root is handed to the backend as a WRITE ROOT with TMPDIR, GOCACHE and the +// package-manager caches pointed inside it. The sandbox would be granting the +// confined command write access to a directory somebody else chose. +func TestTheRuntimeGuardRefusesALinkAtEveryOwnedComponent(t *testing.T) { + for depth := range windowsSandboxRuntimeOwnedDepth { + t.Run("replaced "+string(rune('0'+depth))+" levels above the leaf", func(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + + swapped := root + for range depth { + swapped = filepath.Dir(swapped) + } + tail, err := filepath.Rel(swapped, root) + if err != nil { + t.Fatalf("relate the swapped component to the root: %v", err) + } + if err := os.RemoveAll(swapped); err != nil { + t.Fatalf("clear the component to replace: %v", err) + } + target := filepath.Join(t.TempDir(), "somewhere-else") + if err := os.MkdirAll(target, 0o700); err != nil { + t.Fatalf("create the link target: %v", err) + } + linkRuntimeComponent(t, swapped, target) + if tail != "." { + if err := os.MkdirAll(filepath.Join(target, tail), 0o700); err != nil { + t.Fatalf("recreate the components below the link: %v", err) + } + } + + err = refuseAliasedRuntimeComponents(root) + if err == nil { + t.Fatalf("a link at an owned component was accepted; the runtime tree would have been built inside %s and granted to the sandbox as a write root", target) + } + // ASSERTED ON THE SENTINEL, not on a word in the message. + // + // This read strings.Contains(err.Error(), "link") and was vacuous: + // t.TempDir() names its directory after the test, the subtest was called + // "link N levels above the leaf", and every component path therefore + // contained "link". Deleting the reparse refusal left a different error + // ("exists and is not a directory") whose PATH still satisfied the + // assertion, so all four subtests passed with the fix removed. + // + // The sentinel is also the real contract: selectSandboxRuntimeRoot + // branches on errors.Is to decide refuse-versus-relocate. + if !errors.Is(err, errRuntimeComponentAliased) { + t.Errorf("a link was not reported as a hostile alias, so selection would relocate around it instead of refusing: %v", err) + } + if !strings.Contains(err.Error(), "redirects the directory") { + t.Errorf("the refusal does not name the reason: %v", err) + } + }) + } +} + +// An ordinary tree passes, or the guard above would be satisfied by one that +// refuses everything. +func TestTheRuntimeGuardAcceptsAnOrdinaryTree(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + if err := refuseAliasedRuntimeComponents(root); err != nil { + t.Fatalf("an ordinary runtime tree was refused: %v", err) + } +} + +// A tree that does not exist yet is fine: there is nothing to alias, and this is +// the ordinary first-run case. +func TestTheRuntimeGuardAcceptsATreeThatDoesNotExistYet(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := refuseAliasedRuntimeComponents(root); err != nil { + t.Fatalf("an absent runtime tree was refused: %v", err) + } +} + +// A FILE where an owned component belongs is refused too, rather than producing +// a confusing failure deeper in. +func TestTheRuntimeGuardRefusesAFileWhereADirectoryBelongs(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil { + t.Fatalf("create the runtime parents: %v", err) + } + if err := os.WriteFile(root, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("seed the file: %v", err) + } + if err := refuseAliasedRuntimeComponents(root); err == nil { + t.Fatal("a file standing where the runtime root belongs was accepted") + } +} + +// THE SHARED-TEMP FALLBACK IS SCOPED TO THIS USER. +// +// It replaced os.MkdirTemp, which minted a random 0700 directory atomically, so +// no other local user could name it. A digest of the workspace path alone is the +// same string for every account on the host, and on Linux os.TempDir() is the +// world-writable /tmp whenever TMPDIR is unset: two users on the same path would +// name one directory and the first one there would own it. +func TestTheTempFallbackRootIsScopedToTheUser(t *testing.T) { + workspace := t.TempDir() + root, err := fallbackSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace)) + if err != nil { + t.Fatalf("fallbackSandboxRuntimeRoot: %v", err) + } + scope := sandboxRuntimeUserScope() + if strings.TrimSpace(scope) == "" { + t.Fatal("the user scope is empty, so the digest is the same for every account") + } + + // The leaf must move when the scope does. Recomputed the way the function + // does, rather than asserting on a hardcoded digest. + same := digestFor(workspace, scope) + other := digestFor(workspace, scope+"-someone-else") + if same == other { + t.Fatal("the user scope does not reach the digest") + } + if !strings.HasSuffix(filepath.Clean(root), same) { + t.Errorf("the fallback root %s does not end in the user-scoped digest %s", root, same) + } +} + +// And the fallback still has the shape the owned-component guard and the Windows +// rooted traversal both recognize. A path that stops matching silently loses +// BOTH protections, which is the expensive direction. +func TestTheTempFallbackRootKeepsTheOwnedShape(t *testing.T) { + workspace := t.TempDir() + root, err := fallbackSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace)) + if err != nil { + t.Fatalf("fallbackSandboxRuntimeRoot: %v", err) + } + if _, _, ok := windowsSandboxRuntimeOwnedTail(root); !ok { + t.Fatalf("the fallback root %s is not recognized as an owned runtime tail, so the rooted traversal falls back to opening it by name", root) + } + components := ownedRuntimeComponents(root) + if len(components) != windowsSandboxRuntimeOwnedDepth { + t.Fatalf("the guard walks %d components of %s, want %d", len(components), root, windowsSandboxRuntimeOwnedDepth) + } +} + +// THROUGH prepareSandboxRuntime, not the helper. +// +// The helper being correct proves nothing on its own: the guard has to be on the +// path runner.go actually calls, before the MkdirAll that would build the tree +// through the link and before the root is handed back as a write root. +func TestPreparingTheRuntimeRefusesALinkedRoot(t *testing.T) { + workspace := t.TempDir() + cacheRoot := t.TempDir() + original := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = original }) + + canonical := canonicalSandboxWorkspaceRoot(workspace) + root, err := sandboxRuntimeRootFor(canonical, canonicalSandboxWorkspaceRoot(cacheRoot)) + if err != nil { + t.Skipf("no cache-derived runtime root in this environment: %v", err) + } + + // Somebody else got to the predictable name first and pointed it elsewhere. + if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil { + t.Fatalf("create the runtime parents: %v", err) + } + target := filepath.Join(t.TempDir(), "somewhere-else") + if err := os.MkdirAll(target, 0o700); err != nil { + t.Fatalf("create the link target: %v", err) + } + linkRuntimeComponent(t, root, target) + + runtimeState, cleanup, err := prepareSandboxRuntime(canonical, "") + if cleanup != nil { + cleanup() + } + if err == nil { + t.Fatalf("the runtime was prepared at %s through a link; %s would have been bound read-write into the sandbox with TMPDIR and the build caches inside it", runtimeState.Root, target) + } + if !errors.Is(err, errRuntimeComponentAliased) { + t.Errorf("the linked root was not reported as a hostile alias: %v", err) + } + for _, name := range []string{"cache", "data", "tmp"} { + if _, statErr := os.Stat(filepath.Join(target, name)); statErr == nil { + t.Errorf("the runtime tree was created inside the link target at %s", filepath.Join(target, name)) + } + } +} diff --git a/internal/sandbox/runtime_root_guard_unix.go b/internal/sandbox/runtime_root_guard_unix.go new file mode 100644 index 000000000..68ed1cd19 --- /dev/null +++ b/internal/sandbox/runtime_root_guard_unix.go @@ -0,0 +1,67 @@ +//go:build !windows + +package sandbox + +import ( + "fmt" + "os" + "syscall" +) + +// refuseForeignRuntimeComponent rejects a component owned by somebody else. +// +// The link check above stops the redirection; this stops the quieter half. /tmp +// is world-writable and sticky, so another local user can create the components +// Zero owns BEFORE Zero ever runs. A directory they own but Zero writes into is +// a place they can read the sandbox's caches out of, and the sticky bit does not +// help because they own it. os.MkdirAll accepts it silently, since the path +// already exists as a directory. +// +// Ownership rather than mode, because a 0777 directory belonging to this user is +// the user's own business while a 0700 directory belonging to another user is +// not something Zero should adopt. +func refuseForeignRuntimeComponent(component string, info os.FileInfo) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + // No ownership information on this filesystem. The link check already ran. + return nil + } + if uid := os.Getuid(); uid >= 0 && int(stat.Uid) != uid { + return fmt.Errorf("%w: refusing to use the sandbox runtime directory %s: it belongs to uid %d, not to this user (uid %d)", + errRuntimeComponentAliased, component, stat.Uid, uid) + } + return nil +} + +// sandboxRuntimeUserScope isolates the derived runtime tree per user. +// +// Two users on one host derive the same digest for the same workspace path, so +// without this they name one directory in shared temp and the first one there +// owns it. A uid is not a secret and is not meant to be: it removes the +// collision, and refuseAliasedRuntimeComponents handles the case where somebody +// got there first. +func sandboxRuntimeUserScope() string { + return fmt.Sprintf("u%d", os.Getuid()) +} + +// sandboxRuntimeFallbackOwnedNames are the components the temp-derived runtime +// root is built from. +// +// THE USER BOUNDARY COMES FIRST, ABOVE EVERY PRIVATE COMPONENT. On Unix +// os.TempDir() is a SHARED directory whenever TMPDIR is unset, and runtime +// preparation creates and ownership-checks each of these components at 0700. A +// fixed first component therefore meant the first account to use the fallback +// created a private directory that every other account was then refused at: +// traversal fails on the mode, and relaxing the mode fails the ownership guard +// instead. The per-workspace digest is the leaf, so it never got the chance to +// separate them, and the fallback became first-user-wins on a shared host. +// +// Scoping the FIRST component keeps every ownership-checked ancestor inside a +// namespace that already belongs to one user, which is the property the guards +// below assume. The workspace digest stays the leaf so setup and the command +// still derive the same path for the same workspace. +func sandboxRuntimeFallbackOwnedNames() []string { + names := append([]string(nil), windowsSandboxRuntimeOwnedNames...) + names[0] = names[0] + "-" + sandboxRuntimeUserScope() + return names +} diff --git a/internal/sandbox/runtime_root_guard_windows.go b/internal/sandbox/runtime_root_guard_windows.go new file mode 100644 index 000000000..ecbd4c27c --- /dev/null +++ b/internal/sandbox/runtime_root_guard_windows.go @@ -0,0 +1,41 @@ +//go:build windows + +package sandbox + +import ( + "os" + "strings" +) + +// refuseForeignRuntimeComponent has no ownership check on Windows. +// +// The derived root lives under the per-user cache directory or the per-session +// TEMP, both of which are already user-private, and the elevated setup path +// applies its own capability ACL. The link refusal in the shared guard is the +// part that matters here. +func refuseForeignRuntimeComponent(string, os.FileInfo) error { + return nil +} + +// sandboxRuntimeUserScope names the account the tree belongs to. +// +// Windows TEMP is already per-user, so this is belt and braces rather than the +// load-bearing separation it is on Unix. Kept so the derived path has the same +// shape on every platform and one code path builds it. +func sandboxRuntimeUserScope() string { + name := strings.TrimSpace(os.Getenv("USERNAME")) + if name == "" { + return "u" + } + return "u" + strings.ToLower(name) +} + +// sandboxRuntimeFallbackOwnedNames are the components the temp-derived runtime +// root is built from. Unscoped on Windows: os.TempDir() already resolves inside +// the user's own profile, so the shared-temp collision the Unix build guards +// against cannot arise, and the fixed names keep windowsSandboxRuntimeOwnedTail +// able to recognise a root built by an elevated setup running as another +// account. +func sandboxRuntimeFallbackOwnedNames() []string { + return windowsSandboxRuntimeOwnedNames +} diff --git a/internal/sandbox/runtime_root_stale_test.go b/internal/sandbox/runtime_root_stale_test.go new file mode 100644 index 000000000..02055fc7c --- /dev/null +++ b/internal/sandbox/runtime_root_stale_test.go @@ -0,0 +1,80 @@ +package sandbox + +import ( + "testing" +) + +// A RECORDED ROOT IS HISTORY, NOT PROOF THAT A COMMAND WOULD STILL SELECT IT. +// +// Doctor pins the marker's runtime root so it can check the stamp without +// taking a lease. A command does not pin blindly: it derives the current cache +// and fallback candidates and honours the marker only when its root is one of +// them. Run setup with the cache at A, relocate the cache so commands derive B, +// and the stamped A tree stays behind. Pinning A reported a healthy machine +// immediately before every real command rejected A and failed on the marker. +// +// This records a marker with the cache resolver pointed at A, flips the +// resolver to B, and asks the same question a command asks. The seam is the +// production resolver, so the derivation under test is the command's own. +func TestRecordedRuntimeRootIsNotCurrentOnceTheCacheMoves(t *testing.T) { + cacheA := t.TempDir() + cacheB := t.TempDir() + workspace := t.TempDir() + + previous := sandboxUserCacheDir + t.Cleanup(func() { sandboxUserCacheDir = previous }) + sandboxUserCacheDir = func() (string, error) { return cacheA, nil } + + config := WindowsSandboxSetupConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + } + // The marker records profile.Runtime.Root, which setup sets from the root + // it SELECTS. Select it here the way a command under cache A would, and + // put it on the profile the way doctor does, so the recorded root is the + // genuine A-derived one rather than a value this test invented. + rootA, err := sandboxRuntimeRootFor(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheA)) + if err != nil { + t.Fatalf("derive the runtime root under cache A: %v", err) + } + config.PermissionProfile = PermissionProfileWithRuntimeRoot( + WindowsSandboxProfileWithRuntimeRoots(config.PermissionProfile, config.WorkspaceRoots), + rootA, + ) + if _, err := WriteWindowsSandboxSetupMarker(config); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + + recorded, current, err := WindowsSandboxRecordedRuntimeRootIsCurrent(config.SandboxHome, workspace) + if err != nil { + t.Fatalf("with the cache still at A: %v", err) + } + if recorded == "" { + t.Fatal("SETUP INVALID: the marker recorded no runtime root, so the comparison proves nothing") + } + if !current { + t.Fatalf("the root setup just recorded (%s) is reported stale while the cache has not moved", recorded) + } + + // The cache moves. Commands now derive under B; the stamped tree is under A. + sandboxUserCacheDir = func() (string, error) { return cacheB, nil } + + recordedAfter, currentAfter, err := WindowsSandboxRecordedRuntimeRootIsCurrent(config.SandboxHome, workspace) + if err != nil { + t.Fatalf("with the cache at B: %v", err) + } + if recordedAfter != recorded { + t.Errorf("the recorded root changed on read: %q then %q", recorded, recordedAfter) + } + if currentAfter { + t.Fatalf("the marker's root %s is under cache A, commands derive under B, and it was still reported current", recorded) + } +} diff --git a/internal/sandbox/runtime_snapshot_other.go b/internal/sandbox/runtime_snapshot_other.go new file mode 100644 index 000000000..33c0c3c41 --- /dev/null +++ b/internal/sandbox/runtime_snapshot_other.go @@ -0,0 +1,31 @@ +//go:build !windows + +package sandbox + +import ( + "errors" + "fmt" + "io/fs" + "os" +) + +// snapshotRuntimeStampBound keeps the pathname form off Windows. The split it +// closes there is specific to an elevated installer racing an unelevated +// renamer; the same eager identity capture still applies. +// +// The three-state result is NOT Windows-specific, though: "read it and there was +// nothing" and "could not read it" are different facts on every platform, and +// only the first may authorize a compensating delete. A permission or I/O error +// here stops setup rather than being recorded as proven absence. +func snapshotRuntimeStampBound(root string) (identity string, identified bool, prior []byte, state runtimeStampState, err error) { + identity, identified = runtimeDirIdentity(root) + path := windowsSandboxRuntimeStampPath(root) + data, readErr := os.ReadFile(path) + if readErr != nil { + if errors.Is(readErr, fs.ErrNotExist) { + return identity, identified, nil, runtimeStampAbsent, nil + } + return identity, identified, nil, runtimeStampUnknown, fmt.Errorf("read the sandbox runtime stamp at %s: %w", path, readErr) + } + return identity, identified, data, runtimeStampPresent, nil +} diff --git a/internal/sandbox/runtime_snapshot_windows.go b/internal/sandbox/runtime_snapshot_windows.go new file mode 100644 index 000000000..1985e9975 --- /dev/null +++ b/internal/sandbox/runtime_snapshot_windows.go @@ -0,0 +1,98 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "fmt" + "io" + "os" + + "golang.org/x/sys/windows" +) + +// snapshotRuntimeStampBound reads the runtime root's identity and its existing +// stamp through ONE handle. +// +// The two used to be taken separately: runtimeDirIdentity opened the root, read +// its volume and file ID, and closed the handle, and then os.ReadFile resolved +// the pathname again. A rename and substitution in that interval pairs A's +// identity with B's stamp bytes, and a rollback that correctly proves it holds A +// then writes B's bytes into it, corrupting an attestation that predates this +// run. The lease stops cleanup selecting the root; it does not stop the parent's +// owner renaming it. +// +// Reading the child relative to the identified handle removes the second +// resolution, so both facts describe the same object by construction. +// +// EVERY FAILURE IS AN ERROR, NOT AN ABSENCE. This used to collapse an encoding +// failure, a directory that would not open, an identity that could not be read, +// a denied child open and a short read all into the same "there was no stamp" +// answer that a genuine ERROR_FILE_NOT_FOUND produces. The stamp writer uses +// FILE_OVERWRITE_IF and can replace an existing stamp even where the read was +// denied, so that lie let a FAILED setup delete an attestation it had no record +// of, leaving the previous run's marker pointing at an unusable runtime root. +// Only a positive not-found produces runtimeStampAbsent. +func snapshotRuntimeStampBound(root string) (identity string, identified bool, prior []byte, state runtimeStampState, err error) { + utf16Root, err := windows.UTF16PtrFromString(root) + if err != nil { + return "", false, nil, runtimeStampUnknown, fmt.Errorf("encode sandbox runtime root %s: %w", root, err) + } + directory, err := windows.CreateFile( + utf16Root, + windows.FILE_TRAVERSE|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + // A root that is simply not there yet is the ordinary first-run case: the + // created-directory rollback owns it and there is no prior stamp to lose. + if isWindowsNotFound(err) { + return "", false, nil, runtimeStampAbsent, nil + } + return "", false, nil, runtimeStampUnknown, fmt.Errorf("open sandbox runtime root %s: %w", root, err) + } + defer windows.CloseHandle(directory) + + identity, idErr := handleRuntimeIdentity(directory) + if idErr != nil { + return "", false, nil, runtimeStampUnknown, fmt.Errorf("identify sandbox runtime root %s: %w", root, idErr) + } + + stamp, err := openWindowsChildNoFollow(directory, windowsSandboxRuntimeStampName, + windows.GENERIC_READ|windows.FILE_READ_ATTRIBUTES, windows.FILE_NON_DIRECTORY_FILE) + if err != nil { + if isWindowsNotFound(err) { + // Proven absent. The identity still stands: it came from the handle + // above, not from this. + return identity, true, nil, runtimeStampAbsent, nil + } + return identity, true, nil, runtimeStampUnknown, fmt.Errorf("open the sandbox runtime stamp in %s: %w", root, err) + } + file := os.NewFile(uintptr(stamp), windowsSandboxRuntimeStampName) + defer file.Close() + data, readErr := io.ReadAll(file) + if readErr != nil { + return identity, true, nil, runtimeStampUnknown, fmt.Errorf("read the sandbox runtime stamp in %s: %w", root, readErr) + } + return identity, true, data, runtimeStampPresent, nil +} + +// isWindowsNotFound reports the two statuses that mean the object genuinely is +// not there, as opposed to the many that mean it could not be looked at. +// +// openWindowsChildNoFollow wraps its NTSTATUS, and CreateFile returns the Win32 +// errno, so both spellings are checked rather than assuming one layer. +func isWindowsNotFound(err error) bool { + if err == nil { + return false + } + return errors.Is(err, os.ErrNotExist) || + errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || + errors.Is(err, windows.ERROR_PATH_NOT_FOUND) || + errors.Is(err, windows.STATUS_OBJECT_NAME_NOT_FOUND) || + errors.Is(err, windows.STATUS_OBJECT_PATH_NOT_FOUND) +} diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index 4a5fdfc9a..1b1b62923 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -21,11 +21,6 @@ const ( sandboxRuntimeMaxRoots = 64 ) -var fallbackSandboxRuntimes = struct { - sync.Mutex - roots map[string]string -}{roots: make(map[string]string)} - type SandboxRuntime struct { Root string `json:"root,omitempty"` Cache string `json:"cache,omitempty"` @@ -33,37 +28,106 @@ type SandboxRuntime struct { Temp string `json:"temp,omitempty"` } -func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) { - workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot)) - if workspaceRoot == "" || workspaceRoot == "." { - return SandboxRuntime{}, nil, errors.New("sandbox runtime requires a workspace root") - } - cacheRoot, err := sandboxUserCacheDir() - if err != nil { - return SandboxRuntime{}, nil, fmt.Errorf("resolve user cache directory: %w", err) - } - cacheRoot = filepath.Clean(strings.TrimSpace(cacheRoot)) - if cacheRoot == "" || cacheRoot == "." { - return SandboxRuntime{}, nil, errors.New("user cache directory is unavailable") +// sandboxRuntimeRootFor derives the per-workspace runtime root. It is separated +// from prepareSandboxRuntime because the elevated Windows setup path needs the +// same answer WITHOUT taking a lease or creating anything: a sandbox principal +// is a separate account with no inherited rights under the user cache, so setup +// has to grant it write access to this tree before any command runs. +// +// Both callers must agree exactly. If they ever drift, setup grants the ACE on +// one directory while commands write to another, and the failure is a bare +// ACCESS_DENIED from npm or go build with nothing pointing at the sandbox. +func sandboxRuntimeRootFor(workspaceRoot string, cacheRoot string) (string, error) { + if root, ok := deterministicSandboxRuntimeRoot(workspaceRoot, cacheRoot); ok { + return root, nil } + return fallbackSandboxRuntimeRoot(workspaceRoot) +} + +// deterministicSandboxRuntimeRoot returns the cache-derived runtime root and +// whether it is usable, meaning it lands outside the workspace. +// +// Neither this nor the fallback creates anything now, so a caller that only +// needs to NAME the tree can safely go through sandboxRuntimeRootFor and get the +// answer commands will actually use. This remains separate for callers that need +// to distinguish the cache-derived root from the temp-derived one. +func deterministicSandboxRuntimeRoot(workspaceRoot string, cacheRoot string) (string, bool) { digest := sha256.Sum256([]byte(workspaceRoot)) - root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8])) + // The same inventory the rooted traversal recognizes a runtime root by. See + // windowsSandboxRuntimeOwnedNames: two spellings of this list is how a real + // runtime root stops being recognized as owned, and that failure opens by + // name instead of by handle. + root := filepath.Join(append(append([]string{cacheRoot}, windowsSandboxRuntimeOwnedNames...), hex.EncodeToString(digest[:8]))...) + return root, !runtimeRootWithinWorkspace(workspaceRoot, root) +} + +// runtimeRootWithinWorkspace reports whether root lands inside workspaceRoot. +// +// pathWithinRoot compares SPELLINGS, and canonicalSandboxWorkspaceRoot folds only +// the aliases filepath.EvalSymlinks folds. Two get through it. Case: filepath.Rel +// folds case on Windows via sameWord but not elsewhere, so on a case-insensitive +// macOS volume /var/folders/x and /VAR/FOLDERS/X are one directory that every +// string comparison here keeps apart. Junctions: EvalSymlinks returns a Windows +// directory junction unchanged, so a TEMP that reaches the workspace through one +// measures as outside it. +// +// Three checks, each of which can only ADD a containment answer. That asymmetry +// is the safety argument: the failure that matters is the runtime tree living +// inside the workspace, so a missed alias is the expensive direction and an extra +// relocation is the cheap one. +// +// 1. the spellings as given; +// 2. the spellings resolved to physical paths, which on Windows follows +// junctions at any depth via GetFinalPathNameByHandle; +// 3. filesystem identity across root's existing ancestors, which catches a case +// alias on a case-insensitive volume where step 2 has no API to call. +// +// Step 3 only sees an alias whose target IS the workspace root, because it walks +// a SPELLING upward and a junction has no spelling chain back into its target's +// parent. That shape is covered by step 2 on Windows. It remains open off Windows +// for a bind mount, which the kernel presents as a real path with no way to ask +// where it came from; closing that needs mountinfo parsing, not a path API. +func runtimeRootWithinWorkspace(workspaceRoot string, root string) bool { if pathWithinRoot(workspaceRoot, root) { - root, err = fallbackSandboxRuntimeRoot(workspaceRoot) - if err != nil { - return SandboxRuntime{}, nil, err + return true + } + if physicalWorkspace := physicalSandboxPath(workspaceRoot); physicalWorkspace != "" { + if pathWithinRoot(physicalWorkspace, physicalSandboxPath(root)) { + return true } } - lease, err := prepareSandboxRuntimeLease(root) + workspaceInfo, err := os.Stat(workspaceRoot) if err != nil { - root, err = fallbackSandboxRuntimeRoot(workspaceRoot) - if err != nil { - return SandboxRuntime{}, nil, err + // An unresolvable workspace leaves nothing to compare against. The + // spelling checks above already returned their answer. + return false + } + // root itself usually does not exist yet, which is the point: start at the + // deepest component and walk up, so the first directory that does exist gets + // compared and every ancestor above it after that. + current := filepath.Clean(root) + for { + if info, err := os.Stat(current); err == nil && os.SameFile(workspaceInfo, info) { + return true } - lease, err = prepareSandboxRuntimeLease(root) - if err != nil { - return SandboxRuntime{}, nil, err + parent := filepath.Dir(current) + if parent == current { + return false } + current = parent + } +} + +func prepareSandboxRuntime(workspaceRoot string, sandboxHome string) (SandboxRuntime, func(), error) { + // One selection function, shared with setup. See selectSandboxRuntimeRoot. + // + // The ledger is deliberately unused here. A COMMAND is not a transaction: it + // does not publish a marker and has nothing to roll back to, and a component + // created on this path is the runtime tree the command is about to use. Only + // setup owns an undo. + root, lease, _, err := selectSandboxRuntimeRoot(workspaceRoot, true, sandboxHome) + if err != nil { + return SandboxRuntime{}, nil, err } prepared := false defer func() { @@ -90,6 +154,15 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) filepath.Join(runtimeState.Data, "go-mod"), filepath.Join(runtimeState.Data, "cargo"), } + // BEFORE ANYTHING IS CREATED. os.MkdirAll returns nil when Stat says the path + // is already a directory, and Stat follows links, so a link planted at an + // owned component is silently accepted and the whole tree is built inside + // whatever it points at. Chmod and Chtimes below follow it too, and the root + // then becomes a write root the backend binds read-write with TMPDIR and the + // build caches pointed inside it. + if err := refuseAliasedRuntimeComponents(runtimeState.Root); err != nil { + return SandboxRuntime{}, nil, err + } for _, directory := range directories { if err := os.MkdirAll(directory, 0o700); err != nil { return SandboxRuntime{}, nil, fmt.Errorf("create sandbox runtime directory %s: %w", directory, err) @@ -98,6 +171,12 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) return SandboxRuntime{}, nil, fmt.Errorf("secure sandbox runtime directory %s: %w", directory, err) } } + // AND AGAIN AFTER, because the check above is a check-then-use on its own: a + // component swapped during creation would still redirect the tree. Pairing the + // two narrows the window to the creation itself. + if err := refuseAliasedRuntimeComponents(runtimeState.Root); err != nil { + return SandboxRuntime{}, nil, err + } now := sandboxRuntimeNow() if err := os.Chtimes(runtimeState.Root, now, now); err != nil { return SandboxRuntime{}, nil, fmt.Errorf("touch sandbox runtime root: %w", err) @@ -108,10 +187,19 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) } func prepareSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) { - if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil { - return nil, fmt.Errorf("create sandbox runtime parent: %w", err) - } - return acquireSandboxRuntimeLease(root) + lease, _, err := prepareSandboxRuntimeLeaseRecording(root) + return lease, err +} + +// prepareSandboxRuntimeLeaseRecording also reports the owned directories it +// created, so a caller that can roll back knows what it owns. +// +// Nothing recorded here is created by the leaf's owner. Provisioning creates and +// records the leaf itself; these are the components above it, which used to be +// produced by an os.MkdirAll that nobody accounted for. Setup could therefore +// fail after the lease and leave a tree behind with no record that it made it. +func prepareSandboxRuntimeLeaseRecording(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) { + return acquireRuntimeLeaseForPlatform(root) } // cleanupSandboxRuntimeRoots applies a conservative age/count policy. Cleanup @@ -174,22 +262,60 @@ func combineSandboxCleanups(cleanups ...func()) func() { } } +// fallbackSandboxRuntimeRoot returns the runtime root for a workspace whose +// cache-derived root would land inside itself. +// +// DERIVED, not minted, and that is the whole of the fix. It used to call +// os.MkdirTemp and remember the answer in a process-global map, which made the +// result private to whichever process asked first. Elevated Windows setup +// granted the sandbox principal write access to the directory IT created, then +// every later __windows-command-runner process created a DIFFERENT one and +// pointed TMP, GOCACHE, npm and the rest at it. Those directories are created by +// the calling user and carry no ACE for the principal, so ordinary cache and +// temp writes failed with a bare ACCESS_DENIED and nothing naming the sandbox. +// +// sandboxRuntimeRootFor already documents that both callers must agree exactly. +// Hashing the workspace, the same way the cache-derived root does, is what makes +// that true for this branch too: every process reaches the same path without +// having to share any state. +// +// It creates nothing, so deterministicSandboxRuntimeRoot's promise about naming +// a tree without materializing it now holds for the fallback as well. func fallbackSandboxRuntimeRoot(workspaceRoot string) (string, error) { - fallbackSandboxRuntimes.Lock() - defer fallbackSandboxRuntimes.Unlock() - if root := fallbackSandboxRuntimes.roots[workspaceRoot]; root != "" { - return root, nil + // Canonicalized for the same reason prepareSandboxRuntime canonicalizes the + // workspace and cache roots: pathWithinRoot compares SPELLINGS, so a raw + // os.TempDir() measured against a canonical workspace root compares two + // different names for one directory and the containment check misses. Both + // callers resolve this in the operator's environment, so the derived path is + // identical on the setup and command sides and the plan hashes still agree. + // + // This closes the 8.3 short-name and symlink spellings, not every alias: a + // Windows directory JUNCTION comes back from EvalSymlinks unchanged, and case + // survives it on a case-insensitive macOS volume. The containment check below + // therefore does not rely on canonicalization alone; runtimeRootWithinWorkspace + // falls through to filesystem identity for exactly those aliases. + tempRoot := canonicalSandboxWorkspaceRoot(os.TempDir()) + if tempRoot == "" || tempRoot == "." { + return "", errors.New("temp directory is unavailable") } - parent, err := os.MkdirTemp("", "zero-runtime-") - if err != nil { - return "", fmt.Errorf("create fallback sandbox runtime: %w", err) - } - root := filepath.Join(parent, "runtime") - if pathWithinRoot(workspaceRoot, root) { - _ = os.RemoveAll(parent) - return "", fmt.Errorf("fallback sandbox runtime root %q must be outside workspace %q", root, workspaceRoot) + // SCOPED TO THIS USER, unlike the cache-derived root, because this one lives + // in shared temp. On Linux os.TempDir() is the world-writable /tmp whenever + // TMPDIR is unset, and a digest of the workspace path alone is the same string + // for every user on the host: two accounts working on the same path would name + // one directory and the first one there would own it. The uid is not a secret + // and is not doing secrecy work; it removes the collision, and + // refuseAliasedRuntimeComponents handles somebody having got there first. + digest := sha256.Sum256([]byte(workspaceRoot + "\x00" + sandboxRuntimeUserScope())) + root := filepath.Join(append(append([]string{tempRoot}, sandboxRuntimeFallbackOwnedNames()...), hex.EncodeToString(digest[:8]))...) + if runtimeRootWithinWorkspace(workspaceRoot, root) { + // Both candidates land inside the workspace, so there is nowhere left to + // put a runtime tree the workspace's own policy does not govern. Refused + // rather than pointed somewhere arbitrary: a runtime root inside the + // workspace makes the sandbox's own cache writes indistinguishable from + // the work it is meant to be confining. + return "", fmt.Errorf("sandbox runtime root %q would fall inside workspace %q; "+ + "open the workspace somewhere other than the cache or temp directory", root, workspaceRoot) } - fallbackSandboxRuntimes.roots[workspaceRoot] = root return root, nil } @@ -226,3 +352,282 @@ func permissionProfileWithRuntime(profile PermissionProfile, runtimeState Sandbo profile.FileSystem.WriteRoots = append(profile.FileSystem.WriteRoots, WritableRoot{Root: runtimeState.Root}) return profile } + +// canonicalSandboxWorkspaceRoot normalizes a workspace root the way +// Engine.resolveCommandDir already does — clean, absolutize, then resolve +// symlinks — so every derivation keyed to a workspace agrees on the string. +// +// The runtime root is a hash of this, and the elevated Windows setup grants the +// principal that tree while commands derive it again. Cleaning alone was not +// enough for the two to agree, and it does not take a symlink for them to +// differ: a path opened in different casing, or through an 8.3 short name (what +// a Windows CI runner's TEMP looks like), resolves to a different spelling. +// Setup then granted one tree and every command used another, so the grant that +// makes npm/go/pip caches writable landed where nothing reads and surfaced as a +// bare ACCESS_DENIED. +// +// Resolution failing is not an error: an unresolvable root still needs a stable +// key, and falling back to the cleaned absolute path is what the command path +// does too. +func canonicalSandboxWorkspaceRoot(root string) string { + cleaned := filepath.Clean(strings.TrimSpace(root)) + if cleaned == "" || cleaned == "." { + return "" + } + if !filepath.IsAbs(cleaned) { + if absolute, err := filepath.Abs(cleaned); err == nil { + cleaned = absolute + } + } + // EvalSymlinks fails outright when the LEAF does not exist, which is the + // normal case for a cache or runtime root that has not been created yet. A + // plain call therefore resolved an existing workspace while leaving a + // not-yet-created cache root unresolved, and the two were compared against + // each other — the containment check that decides whether the runtime tree + // must move out of the workspace then ran on /private/var/... versus + // /var/..., missed, and left the tree inside the workspace. + // + // Resolve the longest existing ancestor and re-append the rest, so a path + // normalizes the same way whether or not its final segments exist yet. + remainder := "" + current := cleaned + for { + if resolved, err := filepath.EvalSymlinks(current); err == nil { + if remainder == "" { + return resolved + } + return filepath.Join(resolved, remainder) + } + parent := filepath.Dir(current) + if parent == current { + // Nothing along the path resolved; the cleaned absolute form is the + // best stable key available. + return cleaned + } + remainder = filepath.Join(filepath.Base(current), remainder) + current = parent + } +} + +// selectSandboxRuntimeRoot picks the runtime root a command will actually use, +// and holds a lease on it while the caller decides what to do with it. +// +// SETUP AND THE COMMAND HAVE TO SELECT THE SAME WAY, or they disagree about +// which tree exists. Setup used to derive the cache-based root and fingerprint a +// plan naming it, while a command derived the same root, failed to lease it, and +// silently relocated to the temp fallback. The command's plan then named the +// fallback and the marker rejected it: +// +// windows sandbox setup is out of date: permission roots or deny lists changed +// +// which blames permissions for a runtime-root disagreement, and re-running setup +// could not recover because setup deterministically chose the same unleasable +// root again. That is a permanent brick, not a retry. +// +// Extracted from prepareSandboxRuntime so both sides run this one function. The +// caller must release the returned lease. +// pinnedSandboxRuntimeRoot returns the root a previous setup recorded, when +// that root is one this workspace could actually select. +// +// The candidate check is what keeps this honest. One sandbox home serves +// whichever workspace ran setup last, so a recorded root can belong to a +// different workspace entirely; pinning to that would point this command's +// runtime at another workspace's tree. A recorded root is only honoured when it +// matches one of the two roots THIS workspace derives, which is also the only +// pair the selections could ever have disagreed about. +// sandboxHome is the home THIS command asked for, not the one the parent +// process happens to be pointed at. +// +// TWO ENVIRONMENT AUTHORITIES IS ONE TOO MANY. Runtime preparation ran before +// Windows platform planning and resolved the home from the ambient environment, +// while the planner resolves ZERO_WINDOWS_SANDBOX_HOME out of the command's own +// spec.Env and hands THAT to the runner for marker validation. A request that +// selects home B while the parent still points at home A pinned A's recorded +// root into the profile, and the runner then loaded B's marker and rejected the +// command as out of date even though setup for B was perfectly valid. The two +// homes do not need different derivation rules to disagree, only different valid +// selections from the same preferred/fallback pair. +// +// Empty means no command context, so the ambient environment is the only +// authority there is and resolving it here is correct. +func pinnedSandboxRuntimeRoot(workspaceRoot, preferred, fallback, sandboxHome string) string { + // No GOOS gate. The marker only exists where setup wrote one, so this is + // already Windows-only in practice, and keeping the code path platform-neutral + // means the setup-to-command contract is exercised on every CI runner instead + // of only the Windows one. + home := strings.TrimSpace(sandboxHome) + if home == "" { + resolved, err := ResolveWindowsSandboxHome(nil) + if err != nil { + return "" + } + home = resolved + } + recorded := WindowsSandboxRecordedRuntimeRoot(home) + if recorded == "" { + return "" + } + for _, candidate := range []string{preferred, fallback} { + if candidate != "" && sameWindowsRuntimeRootPath(recorded, candidate) { + return candidate + } + } + // A RECORD IS HISTORY, NOT SOMETHING TO RE-DERIVE. + // + // The candidates above are both computed from THIS process's environment, and + // the fallback one is computed from its TEMP. Setup can legitimately land on + // the fallback when the preferred cache root cannot be leased, and it records + // that concrete root. If a later IDE, service or terminal runs with a + // different TEMP, the recorded root matches neither of today's candidates, the + // record is ignored, and selection picks a tree setup never provisioned. The + // runner then rejects the marker as out of date, and re-running setup from the + // original environment does not make the other environment converge. + // + // So the recorded root is recognised by its OWN shape instead: it must be a + // runtime root Zero owns, its first component must be the fallback's, and its + // leaf must be this workspace's fallback digest. That answers "does this + // record belong to this workspace and home" without asking "what would I + // choose from scratch right now", which is the question that reintroduced the + // second selection. + // + // The cache-derived digest is deliberately NOT accepted here. A moved user + // cache stays stale, which is a different failure with a different remedy. + if ownedFallbackRuntimeRecord(workspaceRoot, recorded) { + return recorded + } + return "" +} + +// ownedFallbackRuntimeRecord reports whether recorded is a fallback runtime root +// this workspace would own, judged on the record's own shape. +func ownedFallbackRuntimeRecord(workspaceRoot, recorded string) bool { + workspaceRoot = canonicalSandboxWorkspaceRoot(workspaceRoot) + if workspaceRoot == "" || strings.TrimSpace(recorded) == "" { + return false + } + base, components, owned := windowsSandboxRuntimeOwnedTail(recorded) + if !owned || base == "" || len(components) == 0 { + return false + } + names := sandboxRuntimeFallbackOwnedNames() + if len(names) == 0 || !strings.EqualFold(components[0], names[0]) { + return false + } + digest := sha256.Sum256([]byte(workspaceRoot + "\x00" + sandboxRuntimeUserScope())) + return strings.EqualFold(components[len(components)-1], hex.EncodeToString(digest[:8])) +} + +// selectSandboxRuntimeRoot picks the root for a command. honorRecorded is true +// on the command side and false during setup: setup is making the choice, so it +// must not consult a record it is about to overwrite, or a single unlucky +// relocation to the temp fallback would pin every future setup to temp. +// WindowsSandboxRecordedRuntimeRootIsCurrent answers the question a diagnostic +// has to ask before trusting the marker: would a command run NOW still select +// the runtime root that setup recorded? +// +// The marker's root is a historical fact, needed to check the stamp without +// mutating anything. It is not proof that the root is still selectable. Setup +// can run with the user cache at A, the cache can then move so commands derive +// B, and the stamped A tree stays behind. A diagnostic that pins A reports a +// healthy machine while every real command rejects A as not a current +// candidate and fails on the out-of-date marker. +// +// This derives the same candidates a command derives, through the same +// resolver, and applies the same equality, by calling the same function the +// command path calls. It takes no lease and creates nothing. +func WindowsSandboxRecordedRuntimeRootIsCurrent(sandboxHome, workspaceRoot string) (recorded string, current bool, err error) { + recorded = WindowsSandboxRecordedRuntimeRoot(sandboxHome) + if recorded == "" { + return "", false, nil + } + workspaceRoot = canonicalSandboxWorkspaceRoot(workspaceRoot) + if workspaceRoot == "" || workspaceRoot == "." { + return recorded, false, errors.New("sandbox runtime requires a workspace root") + } + cacheRoot, err := sandboxUserCacheDir() + if err != nil { + return recorded, false, fmt.Errorf("resolve user cache directory: %w", err) + } + cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot) + preferred, err := sandboxRuntimeRootFor(workspaceRoot, cacheRoot) + if err != nil { + return recorded, false, err + } + fallback, _ := fallbackSandboxRuntimeRoot(workspaceRoot) + return recorded, pinnedSandboxRuntimeRoot(workspaceRoot, preferred, fallback, sandboxHome) != "", nil +} + +// selectSandboxRuntimeRoot picks the runtime root and returns the lease on it +// together with the directories acquiring that lease had to create. +// +// THE LEDGER IS PART OF THE ANSWER, NOT A DETAIL OF IT. Acquiring the lease +// creates zero/runtime/v1 when they are not there, and this was the two-result +// wrapper that dropped that fact on the floor. Setup then provisioned the leaf, +// recorded only the leaf for rollback, and any failure before the marker left +// the parents behind with nothing that knew it had made them. An error carries +// the partial ledger out for the same reason: acquisition can create one +// component and fail on the next. +func selectSandboxRuntimeRoot(workspaceRoot string, honorRecorded bool, sandboxHome string) (string, *sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) { + workspaceRoot = canonicalSandboxWorkspaceRoot(workspaceRoot) + if workspaceRoot == "" || workspaceRoot == "." { + return "", nil, nil, errors.New("sandbox runtime requires a workspace root") + } + cacheRoot, err := sandboxUserCacheDir() + if err != nil { + return "", nil, nil, fmt.Errorf("resolve user cache directory: %w", err) + } + cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot) + if cacheRoot == "" || cacheRoot == "." { + return "", nil, nil, errors.New("user cache directory is unavailable") + } + root, err := sandboxRuntimeRootFor(workspaceRoot, cacheRoot) + if err != nil { + return "", nil, nil, err + } + // CONSUME SETUP'S CHOICE, do not re-make it. Everything below is a fresh + // selection whose answer depends on whether a lease can be taken right now, + // and a command reaching a different answer than setup did is the outage this + // contract exists to prevent: the tree the command names was never + // provisioned, so its ACL plan hash cannot match and no amount of re-running + // setup fixes it. + if honorRecorded { + fallbackRoot, _ := fallbackSandboxRuntimeRoot(workspaceRoot) + if pinned := pinnedSandboxRuntimeRoot(workspaceRoot, root, fallbackRoot, sandboxHome); pinned != "" { + lease, pinnedCreated, leaseErr := prepareSandboxRuntimeLeaseRecording(pinned) + if leaseErr != nil { + // NOT relocated. Relocating is what produced the permanent brick: + // the other root has no capability ACL, so the command would be + // rejected anyway, with a message about permissions. Failing here + // says the true thing and points at the action that fixes it. + return "", nil, pinnedCreated, fmt.Errorf("sandbox runtime root %s was provisioned by setup but cannot be used now (%w); "+ + "re-run `zero sandbox setup` from an elevated (Administrator) terminal", pinned, leaseErr) + } + return pinned, lease, pinnedCreated, nil + } + } + lease, created, err := prepareSandboxRuntimeLeaseRecording(root) + if err == nil { + return root, lease, created, nil + } + // AN ALIASED COMPONENT IS NOT A REASON TO RELOCATE. Falling back here would + // leave the link in place, report nothing, and move to the next predictable + // name, which the same attacker can take as well. Relocating is for a root + // that is merely unusable. + if errors.Is(err, errRuntimeComponentAliased) { + return "", nil, created, err + } + // The preferred root could not be leased. Relocating is right, and it is what + // commands already did; the defect was that setup never learned about it. + root, err = fallbackSandboxRuntimeRoot(workspaceRoot) + if err != nil { + return "", nil, created, err + } + // APPENDED, not replaced. The preferred attempt may have created components + // before it failed to lease, and those are this invocation's too. + fallbackLease, fallbackCreated, err := prepareSandboxRuntimeLeaseRecording(root) + created = append(created, fallbackCreated...) + if err != nil { + return "", nil, created, err + } + return root, fallbackLease, created, nil +} diff --git a/internal/sandbox/runtime_state_test.go b/internal/sandbox/runtime_state_test.go index b707f67f2..f7883bef9 100644 --- a/internal/sandbox/runtime_state_test.go +++ b/internal/sandbox/runtime_state_test.go @@ -16,7 +16,7 @@ func TestPrepareSandboxRuntimeStaysOutsideWorkspace(t *testing.T) { sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } t.Cleanup(func() { sandboxUserCacheDir = original }) - runtimeState, release, err := prepareSandboxRuntime(workspace) + runtimeState, release, err := prepareSandboxRuntime(workspace, "") if err != nil { t.Fatalf("prepareSandboxRuntime: %v", err) } @@ -59,7 +59,7 @@ func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) { if err := os.Chtimes(expired, old, old); err != nil { t.Fatal(err) } - _, release, err := prepareSandboxRuntime(workspace) + _, release, err := prepareSandboxRuntime(workspace, "") if err != nil { t.Fatalf("prepareSandboxRuntime: %v", err) } @@ -71,15 +71,34 @@ func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) { func TestPrepareSandboxRuntimeFallsBackWhenUserCacheIsInsideWorkspace(t *testing.T) { workspace := t.TempDir() + // BOTH derivation inputs belong to the test, not just the cache one. The + // fallback root is derived from os.TempDir(), which reads TMPDIR on Unix and + // TMP/TEMP on Windows, and its name is deterministic, so stubbing only the + // cache left this test creating a persistent tree in the developer real temp + // directory that later runs then found already present. + tempHome := t.TempDir() + t.Setenv("TMPDIR", tempHome) + t.Setenv("TMP", tempHome) + t.Setenv("TEMP", tempHome) + original := sandboxUserCacheDir sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil } t.Cleanup(func() { sandboxUserCacheDir = original }) - runtimeState, release, err := prepareSandboxRuntime(workspace) + runtimeState, release, err := prepareSandboxRuntime(workspace, "") if err != nil { t.Fatalf("prepareSandboxRuntime: %v", err) } - defer release() + defer func() { + release() + // Only the root this test created. + _ = os.RemoveAll(runtimeState.Root) + }() + // And the redirect actually took, or the cleanup above removes one tree while + // the real one is still left behind somewhere else. + if !pathWithinRoot(canonicalSandboxWorkspaceRoot(tempHome), runtimeState.Root) { + t.Fatalf("fallback runtime root %q is outside the test-owned temp directory %q", runtimeState.Root, tempHome) + } if pathWithinRoot(workspace, runtimeState.Root) { t.Fatalf("fallback runtime root %q must stay outside workspace %q", runtimeState.Root, workspace) } @@ -101,7 +120,7 @@ func TestCleanupSandboxRuntimeSkipsActiveLease(t *testing.T) { sandboxRuntimeNow = originalNow }) - runtimeState, release, err := prepareSandboxRuntime(workspace) + runtimeState, release, err := prepareSandboxRuntime(workspace, "") if err != nil { t.Fatalf("prepareSandboxRuntime: %v", err) } diff --git a/internal/sandbox/setup_consumer_sid_other.go b/internal/sandbox/setup_consumer_sid_other.go new file mode 100644 index 000000000..fe976ae43 --- /dev/null +++ b/internal/sandbox/setup_consumer_sid_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package sandbox + +// currentProcessSID has no meaning off Windows. Windows sandbox setup args are +// only ever built on Windows; this exists so the shared builder compiles. +func currentProcessSID() (string, error) { + return "", nil +} diff --git a/internal/sandbox/setup_consumer_sid_windows.go b/internal/sandbox/setup_consumer_sid_windows.go new file mode 100644 index 000000000..c251b193e --- /dev/null +++ b/internal/sandbox/setup_consumer_sid_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package sandbox + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +// currentProcessSID is the SID of the token running this process. +// +// Called from BuildWindowsSandboxSetupArgs, which runs in the OPERATOR'S shell +// before elevation, so it answers "who will read the stamp afterwards" rather +// than "who is provisioning it". Those are different principals whenever setup +// elevates, and the difference is the whole point: the elevated helper creates +// the runtime leaf when it is absent, so anything inferred from that leaf +// describes the installer and not the consumer. +func currentProcessSID() (string, error) { + token := windows.GetCurrentProcessToken() + user, err := token.GetTokenUser() + if err != nil { + return "", fmt.Errorf("resolve the calling user SID for sandbox setup: %w", err) + } + sid := user.User.Sid + if sid == nil { + return "", fmt.Errorf("resolve the calling user SID for sandbox setup: the token carried none") + } + return sid.String(), nil +} diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index c666aa9ef..d32a140f2 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -24,13 +24,147 @@ type windowsACLSnapshot struct { Path string Descriptor *windows.SECURITY_DESCRIPTOR Materialized bool + // Identity is the object the forward apply actually modified, captured from + // the open handle. Compensation reopens BY NAME, and a name is not an + // object: see rollbackWindowsACLSnapshots. + Identity windowsObjectIdentity +} + +// windowsObjectIdentity identifies a filesystem object independently of the +// name it currently answers to. +type windowsObjectIdentity struct { + volume uint32 + high uint32 + low uint32 + valid bool +} + +func windowsIdentityFromHandle(handle windows.Handle) windowsObjectIdentity { + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return windowsObjectIdentity{} + } + return windowsObjectIdentity{ + volume: info.VolumeSerialNumber, + high: info.FileIndexHigh, + low: info.FileIndexLow, + valid: true, + } +} + +// matches is deliberately false when either side is unknown. A compensation +// that cannot prove it is acting on the object it changed must not act. +func (id windowsObjectIdentity) matches(other windowsObjectIdentity) bool { + return id.valid && other.valid && + id.volume == other.volume && id.high == other.high && id.low == other.low +} + +// windowsACLStampRequest asks the apply to write the runtime setup stamp THROUGH +// THE SAME HANDLE it just applied the capability ACE through. +// +// The stamp exists to prove that the directory a command later uses is the +// object setup granted the ACE to. Writing it afterwards by pathname cannot +// prove that, however carefully the second open is done: the ACE goes on +// through a rooted handle, that handle closes, network setup runs, and only then +// does the marker write re-open the name. A local process that can reach the +// user-owned runtime tree can remove the predictable root in that window and +// put an ordinary directory in its place. The re-open correctly rejects a +// junction, but an ordinary replacement is not a reparse point and is not the +// ACL-bearing object either, so it collects a valid-looking stamp. Marker +// validation then passes over a directory with no capability ACE, and the next +// WRITE_RESTRICTED command fails its cache and TMP writes with setup insisting +// it is current. +// +// Closing that means never naming the target again after the ACE lands. The +// hash is known before the apply, so the stamp can simply ride along. +type windowsACLStampRequest struct { + Root string + PlanHash string + // RootIdentity is the file identity of the runtime directory the SNAPSHOT + // read, and RootIdentified says whether that capture actually succeeded. + // + // TWO OPENS OF ONE NAME ARE NOT ONE OBJECT. The snapshot proved "these prior + // bytes belong to B" through its own handle and then closed it; the apply + // resolved the same pathname again and mutated whatever answered, without + // either of them ever proving B and that object are the same. The root owner + // can rename the prior root aside, put an ordinary directory at the + // predictable name for the snapshot, and restore the original before the + // apply. The setup lease is a sibling and does not bind the root entry. + // + // The consequence was not merely a wrong ACL. On a later failure, stamp + // compensation compared the object it found against the snapshot's identity, + // refused to restore, and left this run's stamp on a directory whose marker + // still described the previous successful setup: a failed setup invalidating a + // good one. + RootIdentity string + RootIdentified bool +} + +// windowsACLStampSwapHook fires in the exact window this design closes: after +// the capability ACE is on the object and before the stamp is written. Nil in +// production; a test uses it to replace the runtime root with an ordinary +// directory, which is what a local process would do. +var windowsACLStampSwapHook func(path string) + +// windowsACLStampWriteHook replaces the ride-along stamp write. Nil in +// production; a test uses it to reach the post-commit failure path, which no +// ordinary input produces once the bound handle is already open. +var windowsACLStampWriteHook func(path string) error + +// verifyStampRootIdentity refuses when the object this apply holds is not the one +// the snapshot read. +// +// windowsACLStampIdentitySwapHook exists so a test can substitute the directory +// in exactly the interval between the two opens, which is otherwise unreachable +// from any ordinary input. +func verifyStampRootIdentity(handle windows.Handle, path string, stamp *windowsACLStampRequest) error { + if !stamp.RootIdentified { + return fmt.Errorf("the sandbox runtime root %s could not be identified when its prior state was recorded, so this setup cannot prove it is about to change the same directory", path) + } + identity, err := handleRuntimeIdentity(handle) + if err != nil { + return fmt.Errorf("identify the sandbox runtime root %s before applying its ACL: %w", path, err) + } + if identity != stamp.RootIdentity { + return fmt.Errorf("the sandbox runtime root %s is no longer the directory this run recorded the prior state of, so applying the ACL and stamp here would attest to an object nobody inspected", path) + } + return nil +} + +// windowsACLStampIdentitySwapHook fires in the interval between the snapshot's +// close and the apply's open, which is where a substitution can actually land. +// Nil in production. +var windowsACLStampIdentitySwapHook func(path string) + +// writeRidingStamp writes the stamp through the handle the capability ACE was +// applied on, or through the test hook when one is installed. +func writeRidingStamp(handle windows.Handle, path string, planHash string) error { + if windowsACLStampWriteHook != nil { + return windowsACLStampWriteHook(path) + } + return writeWindowsRuntimeStampToDirectoryHandle(handle, planHash) +} + +// restoreWindowsACLThroughHandle puts a captured DACL back on the object the +// handle names, by handle rather than by pathname for the same reason the stamp +// rides along: after the apply, the name is no longer proof of the object. +func restoreWindowsACLThroughHandle(handle windows.Handle, descriptor *windows.SECURITY_DESCRIPTOR) error { + dacl, _, err := descriptor.DACL() + if err != nil { + return fmt.Errorf("read the captured windows DACL: %w", err) + } + return windows.SetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil) } func applyWindowsACLPlan(plan WindowsACLPlan) (func() error, error) { + return applyWindowsACLPlanWithStamp(plan, nil) +} + +func applyWindowsACLPlanWithStamp(plan WindowsACLPlan, stamp *windowsACLStampRequest) (func() error, error) { groups := groupWindowsACLPlanByPath(plan) snapshots := make([]windowsACLSnapshot, 0, len(groups)) for _, group := range groups { - snapshot, applied, err := applyWindowsACLPathGroup(group) + snapshot, applied, err := applyWindowsACLPathGroupWithStamp(group, stamp) if err != nil { rollbackErr := rollbackWindowsACLSnapshots(snapshots) if rollbackErr != nil { @@ -73,6 +207,10 @@ func groupWindowsACLPlanByPath(plan WindowsACLPlan) []windowsACLPathGroup { } func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bool, error) { + return applyWindowsACLPathGroupWithStamp(group, nil) +} + +func applyWindowsACLPathGroupWithStamp(group windowsACLPathGroup, stamp *windowsACLStampRequest) (windowsACLSnapshot, bool, error) { path := strings.TrimSpace(group.Path) if path == "" || len(group.Entries) == 0 { return windowsACLSnapshot{}, false, nil @@ -84,6 +222,13 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo // elevated setup a lower-privileged local user could swap the target for a // symlink/junction between operations and redirect the ACL change onto a // system object it never validated (issue #728, a TOCTOU privilege boundary). + // THE INTERVAL IS HERE, BEFORE THIS OPEN. The snapshot read its object through + // its own handle and closed it; a handle already open cannot be renamed out + // from under itself, so the only place a substitution can land is between that + // close and this open. The hook exists so a test can put it exactly there. + if windowsACLStampIdentitySwapHook != nil && stamp != nil && windowsSameRuntimeRootPath(stamp.Root, path) { + windowsACLStampIdentitySwapHook(path) + } materialized := false handle, isDir, err := openWindowsACLTarget(path) if err != nil { @@ -131,6 +276,18 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo if err != nil { return fail(fmt.Errorf("build windows ACL for %s: %w", path, err)) } + // BEFORE THE FIRST MUTATION, NOT BEFORE THE STAMP. Checking later would leave + // the ACL already applied to the wrong object, which is the change that + // matters most. + // + // Fails CLOSED. An unestablished identity refuses rather than passing, because + // "we could not tell" is exactly the case this exists for; treating it as + // permission would make the guard a no-op precisely when it is needed. + if stamp != nil && windowsSameRuntimeRootPath(stamp.Root, path) { + if err := verifyStampRootIdentity(handle, path, stamp); err != nil { + return fail(err) + } + } if err := windows.SetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, nextDACL, nil); err != nil { return fail(fmt.Errorf("apply windows ACL for %s: %w", path, err)) } @@ -138,8 +295,33 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo // The handle has served its purpose (read+write bound to one object) and is // closed now — rollback re-opens no-follow rather than holding a handle for // the whole sandbox lifetime, since one caller discards the rollback closure. + // BEFORE THE HANDLE CLOSES, and only for the target the stamp names. This is + // the whole point: the ACE and the stamp land on one kernel object with no + // pathname resolution in between. + if stamp != nil && windowsSameRuntimeRootPath(stamp.Root, path) { + if windowsACLStampSwapHook != nil { + windowsACLStampSwapHook(path) + } + if err := writeRidingStamp(handle, path, stamp.PlanHash); err != nil { + // THE ACE AND ITS STAMP ARE ONE TRANSACTION. + // + // SetSecurityInfo above has already committed, and this function + // returns no rollback closure on its error paths, so the caller has + // nothing to compensate with. Without this restore a failed setup + // reports failure while leaving the capability grant on a pre-existing + // runtime root: the tree stays writable by the restricted token and + // nothing on disk records that it should not be. + if restoreErr := restoreWindowsACLThroughHandle(handle, descriptor); restoreErr != nil { + return fail(fmt.Errorf("stamp windows ACL target %s: %w (the committed ACL could not be restored either: %v)", path, err, restoreErr)) + } + return fail(fmt.Errorf("stamp windows ACL target %s: %w", path, err)) + } + } + // Captured while the handle is still open, because this is the last moment + // the object and the name are known to be the same thing. + identity := windowsIdentityFromHandle(handle) _ = windows.CloseHandle(handle) - return windowsACLSnapshot{Path: path, Descriptor: descriptor, Materialized: materialized}, true, nil + return windowsACLSnapshot{Path: path, Descriptor: descriptor, Materialized: materialized, Identity: identity}, true, nil } // openWindowsACLTarget opens path for reading and rewriting its DACL without @@ -151,6 +333,24 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo // is exactly the redirection this guard exists to prevent. A missing target is // surfaced as os.ErrNotExist so the caller's materialize path still fires. func openWindowsACLTarget(path string) (windows.Handle, bool, error) { + // A RUNTIME ROOT IS OPENED BY HANDLE, NOT BY NAME. + // + // FILE_FLAG_OPEN_REPARSE_POINT below protects only the FINAL component; every + // ancestor in the pathname is resolved normally. The runtime tail is the one + // part of the tree Zero creates and therefore the one part an unprivileged + // local user can predict and pre-empt, and junctions need no privilege, so a + // swap at an owned ancestor between the last check and this open redirects the + // elevated capability ACL into a directory of their choosing. + // + // Everything else here is the user's own tree, where an ancestor reparse point + // is ordinary configuration and following it is correct. + if _, _, owned := windowsSandboxRuntimeOwnedTail(path); owned { + handle, err := openWindowsRuntimeTailDirectory(path, windows.READ_CONTROL|windows.WRITE_DAC|windows.FILE_TRAVERSE) + if err != nil { + return 0, false, err + } + return handle, true, nil + } utf16Path, err := windows.UTF16PtrFromString(path) if err != nil { return 0, false, fmt.Errorf("encode windows ACL target %s: %w", path, err) @@ -237,25 +437,61 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error { var errs []error for index := len(snapshots) - 1; index >= 0; index-- { snapshot := snapshots[index] + // A NAME IS NOT AN OBJECT ONCE THE APPLY HANDLE HAS CLOSED. + // + // The forward apply and its stamp go through one handle, so they are + // provably about one object. Compensation runs later, after a network or + // marker failure, and resolves these names again. Opening no-follow stops + // a reparse point but accepts an ORDINARY directory moved into the name + // since: the original is renamed aside, a substitute is created, and + // rollback then restores the pre-apply DACL onto the substitute, strips a + // stamp there, and reports success, while the moved original keeps this + // run's capability ACE and a valid stamp. Setup would claim a completed + // rollback with the modified object still reachable elsewhere, having + // also mutated something it never touched going forward. + // + // So every compensation proves it holds the object it changed, and + // otherwise leaves the substitute alone and says plainly what was left + // behind. if snapshot.Materialized { + // NOT identity-checked, and the reason is a real limit rather than an + // oversight. A materialized target is one this run created, and its + // plan routinely denies Everyone read (that is what a protected + // metadata carve-out IS), so the attributes identity needs cannot be + // read back even by the owner: the check turned rollback of every + // materialized directory into "Access is denied". Establishing + // identity here would mean holding the apply handle open until the + // last failure point, which is a larger change than this one. if err := os.RemoveAll(snapshot.Path); err != nil { errs = append(errs, fmt.Errorf("remove materialized windows ACL target %s: %w", snapshot.Path, err)) } continue } - dacl, _, err := snapshot.Descriptor.DACL() + // ONE OPEN, AND THE IDENTITY COMES FROM THE HANDLE THAT GETS MUTATED. + // + // Checking identity through a separate open and then resolving the name + // again for the restore proves nothing about the second handle: the two + // opens are a check-then-use, and the fact established (this NAME resolved + // to the object we changed) is not the fact the write depends on (this + // HANDLE is that object). Opening once and asking the handle who it is + // removes the window rather than narrowing it. + handle, _, err := openWindowsACLTarget(snapshot.Path) if err != nil { - errs = append(errs, fmt.Errorf("read rollback windows DACL for %s: %w", snapshot.Path, err)) + errs = append(errs, fmt.Errorf("re-open windows ACL target %s for rollback: %w", snapshot.Path, err)) continue } - // Re-open no-follow rather than restoring by pathname: the restore must - // land on the real object, not a reparse point swapped in since apply. The - // residual window is small because the target is ACL-restricted by now, but - // a handle keeps the restore honest. On a materialized-target rollback we - // remove it above, so only the restore-existing path opens here. - handle, _, err := openWindowsACLTarget(snapshot.Path) + if !snapshot.Identity.matches(windowsIdentityFromHandle(handle)) { + _ = windows.CloseHandle(handle) + errs = append(errs, fmt.Errorf( + "windows ACL target %s is no longer the object this setup modified; "+ + "leaving the replacement untouched, and the original still carries this run's grant", + snapshot.Path)) + continue + } + dacl, _, err := snapshot.Descriptor.DACL() if err != nil { - errs = append(errs, fmt.Errorf("re-open windows ACL target %s for rollback: %w", snapshot.Path, err)) + _ = windows.CloseHandle(handle) + errs = append(errs, fmt.Errorf("read rollback windows DACL for %s: %w", snapshot.Path, err)) continue } if err := windows.SetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil { diff --git a/internal/sandbox/windows_acl_attest_other.go b/internal/sandbox/windows_acl_attest_other.go new file mode 100644 index 000000000..ed17f0c51 --- /dev/null +++ b/internal/sandbox/windows_acl_attest_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package sandbox + +// windowsACLPlanApplied reports whether the objects a plan names still carry the +// grants it describes. +// +// Off Windows there is no DACL to read and nothing that consumes one, so the +// marker's own comparisons are the whole answer. Declared here rather than +// guarded at the call site so the setup-to-command contract keeps one shape on +// every platform. +var windowsACLPlanApplied = func(WindowsACLPlan) bool { return true } diff --git a/internal/sandbox/windows_acl_attest_seam_windows.go b/internal/sandbox/windows_acl_attest_seam_windows.go new file mode 100644 index 000000000..3773eb391 --- /dev/null +++ b/internal/sandbox/windows_acl_attest_seam_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package sandbox + +// windowsACLPlanApplied reads the real security descriptors. See +// windowsACLPlanStillApplied. +var windowsACLPlanApplied = windowsACLPlanStillApplied diff --git a/internal/sandbox/windows_acl_attest_windows.go b/internal/sandbox/windows_acl_attest_windows.go new file mode 100644 index 000000000..542920774 --- /dev/null +++ b/internal/sandbox/windows_acl_attest_windows.go @@ -0,0 +1,128 @@ +//go:build windows + +package sandbox + +import ( + "os" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +// windowsACLPlanStillApplied reports whether the objects a plan names still +// carry the grants it describes. +// +// THE MARKER FINGERPRINTS A PLAN, NOT AN OBJECT. Its hash is computed from +// pathnames and entries, so it records what SHOULD be granted and can never +// establish that whatever directory currently answers to that name has received +// it. The runtime root makes the difference reachable rather than theoretical: +// it is deterministic and disposable, so cleanup removes the tree, the next +// command's parent recreates the same pathname with ordinary inherited +// permissions, and the plan hash is unchanged. The fast path then skipped the +// apply entirely and the WRITE_RESTRICTED child could not write TMP or its +// language and package caches, with nothing failing to say why. +// +// PRESENCE OF THE SID IS NOT THE CONTRACT. What the child needs is the grant +// windowsACLAccess actually creates, and on a directory it needs to reach +// descendants. An ACE that still names the capability SID but has been reduced +// to a metadata or read-only mask, or that no longer propagates, leaves a +// runtime root that attests as healthy and returns ACCESS_DENIED on the first +// write into TMP or a cache: the same silent unusable runtime the attestation +// exists to eliminate. So the check compares the effective grant. +// +// Attesting costs one security-descriptor read per allow entry, on a path that +// is about to create a process, and it covers every reason a grant can be +// missing or insufficient rather than only recreation by the parent. +// +// Anything unprovable reads as "not applied". Re-applying an adequate grant is +// idempotent and cheap; skipping an inadequate one produces a sandbox that +// silently cannot write. +func windowsACLPlanStillApplied(plan WindowsACLPlan) bool { + for _, entry := range plan.Entries { + if entry.Action != WindowsACLAllowWrite { + // Only the allow grants decide whether the child can run. A missing + // DENY is a weaker boundary rather than a broken one, and re-applying + // the whole plan is what fixes either. + continue + } + if strings.TrimSpace(entry.Path) == "" || strings.TrimSpace(entry.Capability) == "" { + continue + } + _, required, err := windowsACLAccess(entry.Action) + if err != nil { + return false + } + if !windowsPathCarriesGrant(entry.Path, entry.Capability, required) { + return false + } + } + return true +} + +// windowsPathCarriesGrant reports whether path's DACL gives trustee at least +// required, and whether that grant reaches descendants when path is a directory. +func windowsPathCarriesGrant(path, trustee string, required windows.ACCESS_MASK) bool { + wanted, err := windows.StringToSid(trustee) + if err != nil { + return false + } + info, err := os.Stat(path) + if err != nil { + return false + } + // windowsExplicitAccessEntries sets SUB_CONTAINERS_AND_OBJECTS_INHERIT on a + // directory, and that is what lets the child create TMP and cache entries + // underneath. A grant on the directory alone would not. + needInherit := uint8(0) + if info.IsDir() { + needInherit = windows.CONTAINER_INHERIT_ACE | windows.OBJECT_INHERIT_ACE + } + + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + return false + } + dacl, _, err := descriptor.DACL() + if err != nil || dacl == nil { + return false + } + + var granted windows.ACCESS_MASK + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var header *windows.ACE_HEADER + if err := windows.GetAce(dacl, index, (**windows.ACCESS_ALLOWED_ACE)(unsafe.Pointer(&header))); err != nil { + return false + } + ace := (*windows.ACCESS_ALLOWED_ACE)(unsafe.Pointer(header)) + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if !sid.Equals(wanted) { + continue + } + switch ace.Header.AceType { + case windows.ACCESS_DENIED_ACE_TYPE: + // A deny naming the capability itself takes precedence over any allow, + // so the grant cannot be proven adequate. Fail closed and re-apply. + if ace.Mask&required != 0 { + return false + } + case windows.ACCESS_ALLOWED_ACE_TYPE: + // INHERIT_ONLY DOES NOT APPLY TO THE OBJECT ITSELF. An ACE carrying it + // grants descendants and grants the directory nothing, so the child can + // still be refused FILE_ADD_FILE on the runtime root while every other + // bit here looks satisfied. Checking the inherit flags alone would take + // the propagation half of the grant as evidence for the whole of it, + // which is the substitution this attestation exists to stop making. + if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 { + continue + } + // And only ACEs that propagate count towards a directory's grant, since a + // non-inheriting one leaves descendants ungranted. + if ace.Header.AceFlags&needInherit != needInherit { + continue + } + granted |= ace.Mask + } + } + return granted&required == required +} diff --git a/internal/sandbox/windows_acl_attest_windows_test.go b/internal/sandbox/windows_acl_attest_windows_test.go new file mode 100644 index 000000000..74a079289 --- /dev/null +++ b/internal/sandbox/windows_acl_attest_windows_test.go @@ -0,0 +1,154 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +// A PLAN HASH ATTESTS A PATHNAME, NOT THE DIRECTORY THAT ANSWERS TO IT. +// +// The unelevated marker records the plan hash and its entry count, both derived +// from pathnames and entries. The runtime root is deterministic and disposable, +// so cleanup can remove the tree and the next command's parent recreates the +// same pathname with ordinary inherited permissions. The hash is unchanged, the +// marker still claims the plan was applied, and the replacement never received +// the capability ACE, leaving the WRITE_RESTRICTED child unable to write TMP or +// its caches with nothing failing to say why. +func TestPlanAttestationFailsAfterTheRootIsRecreated(t *testing.T) { + root := filepath.Join(t.TempDir(), "runtime") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + // Guests: a well-known SID that no ordinary object carries, standing in for + // the sandbox capability SID. + const capability = "S-1-5-32-546" + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: capability}, + }} + + if windowsACLPlanStillApplied(plan) { + t.Fatal("SETUP INVALID: the grant is reported as present before it was applied") + } + rollback, err := applyWindowsACLPlan(plan) + if err != nil { + t.Fatalf("applyWindowsACLPlan: %v", err) + } + t.Cleanup(func() { _ = rollback() }) + + if !windowsACLPlanStillApplied(plan) { + t.Fatal("the grant was just applied and the attestation does not see it") + } + + // Exactly what cleanup plus the next command's parent does: same pathname, + // new directory object, ordinary inherited permissions. + if err := os.RemoveAll(root); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + if windowsACLPlanStillApplied(plan) { + t.Error("a recreated root is reported as still carrying the grant, so the apply would be skipped") + } +} + +// A missing path is not a grant either, and must not be read as one. +func TestPlanAttestationFailsWhenThePathIsGone(t *testing.T) { + root := filepath.Join(t.TempDir(), "absent") + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: "S-1-5-32-546"}, + }} + if windowsACLPlanStillApplied(plan) { + t.Error("a path that does not exist was reported as carrying its grant") + } +} + +// Deny entries are not load-bearing for the child's ability to run, so their +// absence must not force a re-apply on every command. +func TestPlanAttestationIgnoresDenyEntries(t *testing.T) { + root := t.TempDir() + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLDenyWrite, Path: root, Capability: "S-1-5-32-546"}, + }} + if !windowsACLPlanStillApplied(plan) { + t.Error("a plan of deny entries alone reported as unapplied, which would re-apply on every command") + } +} + +// setCapabilityACE replaces path's DACL with a single allow entry for trustee, +// so a test can weaken a grant without removing the SID that names it. +func setCapabilityACE(t *testing.T, path, trustee string, mask windows.ACCESS_MASK, inheritance uint32) { + t.Helper() + sid, err := windows.StringToSid(trustee) + if err != nil { + t.Fatal(err) + } + dacl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{ + AccessPermissions: mask, + AccessMode: windows.GRANT_ACCESS, + Inheritance: inheritance, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(sid), + }, + }}, nil) + if err != nil { + t.Fatal(err) + } + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil { + t.Fatal(err) + } +} + +// PRESENCE OF THE SID IS NOT THE CONTRACT. +// +// What the restricted child needs is the grant windowsACLAccess creates, and on +// a directory it needs to reach descendants. An ACE that still names the +// capability but has been reduced to a read-only mask, or that no longer +// propagates, leaves a runtime root that attests as healthy and then returns +// ACCESS_DENIED on the first write into TMP or a cache: exactly the silent +// unusable runtime the attestation exists to eliminate. +func TestPlanAttestationRejectsAWeakenedCapabilityACE(t *testing.T) { + const capability = "S-1-5-32-546" + full := windows.ACCESS_MASK(windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE) + + for _, testCase := range []struct { + name string + mask windows.ACCESS_MASK + inheritance uint32 + want bool + }{ + {"the grant the plan describes", full, windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, true}, + {"reduced to read and execute", windows.ACCESS_MASK(windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE), windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, false}, + {"reduced to metadata only", windows.FILE_READ_ATTRIBUTES, windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, false}, + {"full mask that does not propagate", full, windows.NO_INHERITANCE, false}, + {"containers only, so files are ungranted", full, windows.SUB_CONTAINERS_ONLY_INHERIT, false}, + // INHERIT_ONLY grants descendants and grants the directory nothing, so the + // child is refused FILE_ADD_FILE on the runtime root while every inherit + // flag the check looks for is present. + {"granted to descendants but not to the directory", full, windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT | windows.INHERIT_ONLY_ACE, false}, + } { + t.Run(testCase.name, func(t *testing.T) { + root := filepath.Join(t.TempDir(), "runtime") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: capability}, + }} + setCapabilityACE(t, root, capability, testCase.mask, testCase.inheritance) + + if got := windowsACLPlanStillApplied(plan); got != testCase.want { + t.Errorf("attestation = %v, want %v; a capability SID is present either way, so only the effective grant separates these", + got, testCase.want) + } + }) + } +} diff --git a/internal/sandbox/windows_acl_rollback_identity_windows_test.go b/internal/sandbox/windows_acl_rollback_identity_windows_test.go new file mode 100644 index 000000000..541c57d70 --- /dev/null +++ b/internal/sandbox/windows_acl_rollback_identity_windows_test.go @@ -0,0 +1,109 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// COMPENSATION MUST HOLD THE OBJECT IT CHANGED, NOT THE NAME IT USED. +// +// The forward apply and its stamp go through one handle, so they are provably +// about one object. Compensation runs later, after a network or marker failure, +// and resolves those names again. Opening no-follow refuses a reparse point but +// accepts an ORDINARY directory moved into the name since the handle closed. So +// a rename plus a substitute made rollback restore the pre-apply DACL onto the +// substitute and report success, while the moved original kept this run's +// capability ACE: a completed rollback with the modified object still reachable +// elsewhere, and a directory mutated that the forward operation never touched. +func TestRollbackRefusesASubstituteAndReportsTheOriginal(t *testing.T) { + base := t.TempDir() + root := filepath.Join(base, "target") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + const capability = "S-1-5-32-546" + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: capability}, + }} + + rollback, err := applyWindowsACLPlanWithStamp(plan, nil) + if err != nil { + t.Fatalf("applyWindowsACLPlanWithStamp: %v", err) + } + if !windowsACLPlanStillApplied(plan) { + t.Fatal("SETUP INVALID: the grant is not present after a successful apply") + } + + // Exactly the swap the compensation cannot see by name: move the object that + // was modified aside, and put an ordinary directory where it was. + moved := filepath.Join(base, "moved") + if err := os.Rename(root, moved); err != nil { + t.Skipf("cannot rename the applied target here: %v", err) + } + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + substituteBefore := daclOf(t, root) + + err = rollback() + if err == nil { + t.Fatal("rollback reported success against a substitute directory") + } + if !strings.Contains(err.Error(), root) { + t.Errorf("the failure does not name the path left in an unrestored state: %v", err) + } + + // The substitute must be byte-for-byte the directory the test created. + if after := daclOf(t, root); !equalACEs(after, substituteBefore) { + t.Errorf("rollback mutated a directory it never modified:\nbefore %v\nafter %v", substituteBefore, after) + } + + // And the original is honestly residual: it still carries this run's grant, + // which is what the error is telling the operator. + movedPlan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: moved, Capability: capability}, + }} + if !windowsACLPlanStillApplied(movedPlan) { + t.Error("the moved original lost its grant, so the error over-reported what was left behind") + } +} + +// And an unswapped rollback still restores, or the guard would have disabled +// compensation rather than bounding it. +func TestRollbackStillRestoresTheObjectItModified(t *testing.T) { + root := filepath.Join(t.TempDir(), "target") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: "S-1-5-32-546"}, + }} + before := daclOf(t, root) + + rollback, err := applyWindowsACLPlanWithStamp(plan, nil) + if err != nil { + t.Fatalf("applyWindowsACLPlanWithStamp: %v", err) + } + if err := rollback(); err != nil { + t.Fatalf("rollback of an unswapped target failed: %v", err) + } + if after := daclOf(t, root); !equalACEs(after, before) { + t.Errorf("rollback did not restore the original DACL:\nbefore %v\nafter %v", before, after) + } +} + +func equalACEs(a, b []string) bool { + if len(a) != len(b) { + return false + } + for index := range a { + if a[index] != b[index] { + return false + } + } + return true +} diff --git a/internal/sandbox/windows_acl_stamp_rollback_windows_test.go b/internal/sandbox/windows_acl_stamp_rollback_windows_test.go new file mode 100644 index 000000000..f5adb5105 --- /dev/null +++ b/internal/sandbox/windows_acl_stamp_rollback_windows_test.go @@ -0,0 +1,94 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// daclOf returns the DACL of path as one line per access-control entry. +// +// NOT the SDDL of the whole descriptor. That string also carries the owner, the +// group and the control flags, and none of those are what this test is about: +// SetSecurityInfo sets SE_DACL_AUTO_INHERITED when it writes a DACL, and +// GetNamedSecurityInfo does not report owner and group identically on every +// machine, so comparing full SDDL fails on a difference that grants nobody +// anything. The entries are the access, so the entries are what gets compared. +func daclOf(t *testing.T, path string) []string { + t.Helper() + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("read the DACL of %s: %v", path, err) + } + dacl, _, err := descriptor.DACL() + if err != nil { + t.Fatalf("read the DACL entries of %s: %v", path, err) + } + if dacl == nil { + return nil + } + entries := make([]string, 0, dacl.AceCount) + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, index, &ace); err != nil { + t.Fatalf("read ACE %d of %s: %v", index, path, err) + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + entries = append(entries, fmt.Sprintf("type=%d flags=%#x mask=%#x sid=%s", + ace.Header.AceType, ace.Header.AceFlags, ace.Mask, sid.String())) + } + return entries +} + +// THE ACE AND ITS STAMP ARE ONE TRANSACTION. +// +// SetSecurityInfo commits before the stamp is written, and the apply returns no +// rollback closure on its error paths, so the caller's compensations have +// nothing to undo. A failed setup therefore reported failure while leaving the +// capability grant on a pre-existing runtime root: the tree stays writable by +// the restricted token and nothing on disk records that it should not be. +func TestAStampFailureRestoresTheCommittedACL(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + before := daclOf(t, root) + + previous := windowsACLStampWriteHook + windowsACLStampWriteHook = func(string) error { return errors.New("disk full") } + t.Cleanup(func() { windowsACLStampWriteHook = previous }) + + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: "S-1-5-32-546"}, + }} + rollback, err := applyWindowsACLPlanWithStamp(plan, stampRequestFor(t, root, "planhash")) + if err == nil { + if rollback != nil { + _ = rollback() + } + t.Fatal("the apply reported success even though the stamp could not be written") + } + if rollback != nil { + // A rollback closure here would be the other acceptable shape, but the + // caller only receives one on success, so it must not be relied on. + _ = rollback() + } + + if after := daclOf(t, root); !slices.Equal(after, before) { + t.Errorf("the committed capability grant survived a failed setup:\nbefore %s\nafter %s", + strings.Join(before, " | "), strings.Join(after, " | ")) + } + if _, err := os.Stat(filepath.Join(root, windowsSandboxRuntimeStampName)); err == nil { + t.Error("a stamp exists even though the stamp step failed") + } +} diff --git a/internal/sandbox/windows_acl_stamp_swap_windows_test.go b/internal/sandbox/windows_acl_stamp_swap_windows_test.go new file mode 100644 index 000000000..3e884aeb9 --- /dev/null +++ b/internal/sandbox/windows_acl_stamp_swap_windows_test.go @@ -0,0 +1,77 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// THROUGH THE REAL APPLY, with the swap in the window that matters. +// +// The direct-handle test cannot distinguish handle from pathname, because any +// name derived from the handle resolves back to the same object. What the old +// code did was different: it re-opened the ORIGINAL root string after the ACL +// step, so a directory swapped in under that name collected the stamp. This +// drives applyWindowsACLPlanWithStamp and performs the swap between the ACE +// landing and the stamp write. +func TestTheStampSkipsADirectorySwappedInAfterTheACE(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + + moved := root + "-original" + previous := windowsACLStampSwapHook + windowsACLStampSwapHook = func(path string) { + // Ordinary directories throughout. Nothing here is a reparse point, which + // is why a no-follow re-open does not catch it. + if err := os.Rename(path, moved); err != nil { + t.Skipf("cannot rename the runtime root here: %v", err) + } + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatalf("plant the replacement: %v", err) + } + } + t.Cleanup(func() { windowsACLStampSwapHook = previous }) + + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: "S-1-5-32-546"}, + }} + rollback, err := applyWindowsACLPlanWithStamp(plan, stampRequestFor(t, root, "planhash")) + if err != nil { + t.Fatalf("applyWindowsACLPlanWithStamp: %v", err) + } + t.Cleanup(func() { _ = rollback() }) + + if _, err := os.Stat(filepath.Join(root, windowsSandboxRuntimeStampName)); err == nil { + t.Error("the swapped-in directory collected the stamp; it carries no capability ACE and would still validate as set up") + } + recorded, err := os.ReadFile(filepath.Join(moved, windowsSandboxRuntimeStampName)) + if err != nil { + t.Fatalf("the stamp did not land on the object the ACE was applied to: %v", err) + } + if string(recorded) != "planhash" { + t.Errorf("stamp contents = %q, want the plan hash", recorded) + } +} + +// stampRequestFor builds the request the way runWindowsSandboxSetup does, with +// the snapshot's identity carried on it. Building one by hand without that +// identity is refused now, and rightly: the apply cannot prove it holds the +// object the snapshot read. +func stampRequestFor(t *testing.T, root string, planHash string) *windowsACLStampRequest { + t.Helper() + snapshot, err := snapshotWindowsSandboxRuntimeStamp(root) + if err != nil { + t.Fatalf("snapshot the runtime stamp for %s: %v", root, err) + } + return &windowsACLStampRequest{ + Root: root, + PlanHash: planHash, + RootIdentity: snapshot.rootIdentity, + RootIdentified: snapshot.rootIdentified, + } +} diff --git a/internal/sandbox/windows_acl_stamp_windows_test.go b/internal/sandbox/windows_acl_stamp_windows_test.go new file mode 100644 index 000000000..b7fb2986b --- /dev/null +++ b/internal/sandbox/windows_acl_stamp_windows_test.go @@ -0,0 +1,42 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +// The positive half only. The swap case that proves the ACE and the stamp are +// about ONE OBJECT lives in windows_acl_stamp_swap_windows_test.go, where it can +// drive the real apply. +// +// A direct-handle test cannot prove it: any name derived from the retained +// handle resolves back to the same object, so a pathname write derived that way +// lands correctly too and the test passes either way. The distinction only shows +// through the call site, where the old code re-opened the ORIGINAL root string. +// And the ordinary path still works, or the assertion above would be satisfied +// by a writer that never writes anything. +func TestTheStampWritesThroughAnOpenDirectoryHandle(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + handle, err := openWindowsRuntimeTailDirectory(root, windows.READ_CONTROL|windowsFileAddFile|windows.FILE_TRAVERSE) + if err != nil { + t.Fatalf("open the runtime root: %v", err) + } + defer windows.CloseHandle(handle) + + if err := writeWindowsRuntimeStampToDirectoryHandle(handle, "planhash"); err != nil { + t.Fatalf("write the stamp: %v", err) + } + recorded, err := os.ReadFile(windowsSandboxRuntimeStampPath(root)) + if err != nil || string(recorded) != "planhash" { + t.Fatalf("the stamp did not land in the runtime root (%q, err %v)", recorded, err) + } +} diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 0b9e8f64e..f1bebb4d1 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -10,7 +10,18 @@ import ( func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writer) int { switch config.SandboxLevel { case WindowsSandboxLevelRestrictedToken: - if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(config)); err != nil { + setupConfig := WindowsSandboxSetupConfigFromCommand(config) + if err := ValidateWindowsSandboxSetupMarker(setupConfig); err != nil { + fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) + return 1 + } + // The marker and the stamp are about intent and about the pathname. Whether + // the objects still carry the grant is a third question, and it is the one + // that decides whether this child can write its temp and cache directories. + // The unelevated tier below answers it by reading the descriptors and + // re-applying; this tier cannot repeat an elevated provisioning, so it + // refuses and names the action. + if err := ValidateWindowsSandboxLaunchGrants(setupConfig); err != nil { fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) return 1 } @@ -108,7 +119,11 @@ func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error { if err != nil { return err } - if marker.contains(applied) { + // The marker says this plan was applied; the object has to agree. See + // windowsACLPlanStillApplied: a deterministic runtime root can be removed and + // recreated between commands under the same pathname, which leaves the plan + // hash identical and the capability grant gone. + if marker.contains(applied) && windowsACLPlanStillApplied(plan) { return nil } if _, err := applyWindowsACLPlan(plan); err != nil { diff --git a/internal/sandbox/windows_consumer_reader_windows_test.go b/internal/sandbox/windows_consumer_reader_windows_test.go new file mode 100644 index 000000000..1559510a6 --- /dev/null +++ b/internal/sandbox/windows_consumer_reader_windows_test.go @@ -0,0 +1,76 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +// THE READER MUST BE THE TOKEN THAT VALIDATES, NOT THE ONE THAT INSTALLS. +// +// Deriving the reader from the runtime leaf's owner looked right and is wrong +// across the elevation boundary: the elevated helper CREATES that leaf when it +// is absent, so the owner is commonly BUILTIN\Administrators. A later +// UAC-filtered administrator carries that group deny-only, and a standard user +// given alternate administrator credentials is not in it at all, so the +// protected stamp ends up with no enabled allow ACE for the token that has to +// read it. Setup reports success and every restricted command then stops before +// launch. +// +// The consumer is therefore resolved in the operator's shell and carried in. +// This test pins that the carried identity wins over the leaf owner, which is +// the whole difference between the two designs. +func TestCarriedConsumerSIDOutranksTheLeafOwner(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + + // A consumer that is deliberately NOT this process and NOT a repair identity, + // so it cannot be confused with the leaf owner or folded into the + // SYSTEM/Administrators grants. + consumer, err := windows.StringToSid("S-1-5-21-1111111111-2222222222-3333333333-1001") + if err != nil { + t.Fatalf("build the stand-in consumer SID: %v", err) + } + restore := setWindowsSetupConsumerSID(consumer) + t.Cleanup(restore) + + if err := writeWindowsRuntimeStampThroughHandle(root, "planhash"); err != nil { + t.Fatalf("write the stamp: %v", err) + } + stamp := windowsSandboxRuntimeStampPath(root) + + mask, present := stampACEMask(t, stamp, consumer) + if !present { + t.Fatal("the carried consumer has no ACE; the stamp still names whoever owns the leaf") + } + if mask&windows.FILE_READ_DATA == 0 { + t.Errorf("the carried consumer cannot read the stamp (mask 0x%08x)", mask) + } + // It is not the owner of the leaf, so this also proves the owner fallback did + // not silently win. + owner := ownerOfDirectory(t, root) + if owner.Equals(consumer) { + t.Skip("the leaf owner happens to equal the stand-in consumer; this cannot distinguish the two") + } + if _, ownerPresent := stampACEMask(t, stamp, owner); ownerPresent && !isRepairIdentity(t, owner) { + t.Error("the leaf owner was granted alongside the carried consumer") + } + + // Repair must still work, and the capability SID must still be absent. + for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{windows.WinLocalSystemSid, windows.WinBuiltinAdministratorsSid} { + sid, err := windows.CreateWellKnownSid(wellKnown) + if err != nil { + t.Fatalf("resolve well-known SID: %v", err) + } + if m, ok := stampACEMask(t, stamp, sid); !ok || m&windows.FILE_WRITE_DATA == 0 { + t.Errorf("repair identity %s lost write (present=%v mask 0x%08x)", sid, ok, m) + } + } +} diff --git a/internal/sandbox/windows_elevated_grant_attest_test.go b/internal/sandbox/windows_elevated_grant_attest_test.go new file mode 100644 index 000000000..565bb89f0 --- /dev/null +++ b/internal/sandbox/windows_elevated_grant_attest_test.go @@ -0,0 +1,90 @@ +package sandbox + +import ( + "strings" + "testing" +) + +// THE STAMP IS NOT THE GRANT. +// +// The elevated tier attested its runtime root with the marker comparisons and a +// stamp file. Those prove that setup's intent matches this command's, and that +// the directory was not removed and recreated under the same pathname. An +// ordinary file answers the second by existing, and it survives an ACL edit +// untouched, so an `icacls /reset`, an inheritance change on a parent, or a +// security product rewriting the DACL all leave a valid stamp over a runtime +// root the WRITE_RESTRICTED child cannot write. The marker agrees, the stamp +// agrees, and the first write into TMP or a package cache returns ACCESS_DENIED +// with nothing having said why. +// +// This is the function runWindowsSandboxCommand calls for the restricted-token +// tier, beside the marker validation. The unelevated tier answers the same +// question by reading the descriptors and re-applying; this one cannot repeat an +// elevated provisioning, so it refuses and names the action. +func TestTheLaunchGateRefusesAnUnappliedGrant(t *testing.T) { + config := WindowsSandboxSetupConfig{ + SandboxHome: t.TempDir(), + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: `C:\workspace`}}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + } + + previous := windowsACLPlanApplied + t.Cleanup(func() { windowsACLPlanApplied = previous }) + + windowsACLPlanApplied = func(WindowsACLPlan) bool { return true } + if err := ValidateWindowsSandboxLaunchGrants(config); err != nil { + t.Fatalf("SETUP INVALID: the gate refuses even with the grants intact: %v", err) + } + + windowsACLPlanApplied = func(WindowsACLPlan) bool { return false } + err := ValidateWindowsSandboxLaunchGrants(config) + if err == nil { + t.Fatal("a runtime root that no longer carries its grants passed the launch gate, so the command starts into a sandbox it cannot write") + } + // Actionable, and about the right thing: an operator told "permission roots + // changed" goes looking at their policy for a problem that is not there. + for _, want := range []string{"permissions", "zero sandbox setup"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not mention %q: %v", want, err) + } + } + if strings.Contains(err.Error(), "out of date") { + t.Errorf("the refusal reads as a policy edit, which sends the operator to the wrong place: %v", err) + } +} + +// And the marker comparison stays a marker comparison. Folding the grant check +// into it would have made every consumer of the marker, including `zero doctor`, +// depend on real security descriptors, which is a different question from the +// one that function's name asks. +func TestTheMarkerValidationDoesNotReadDescriptors(t *testing.T) { + config := WindowsSandboxSetupConfig{ + SandboxHome: t.TempDir(), + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: `C:\workspace`}}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + } + if _, err := WriteWindowsSandboxSetupMarker(config); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + previous := windowsACLPlanApplied + t.Cleanup(func() { windowsACLPlanApplied = previous }) + windowsACLPlanApplied = func(WindowsACLPlan) bool { return false } + + if err := ValidateWindowsSandboxSetupMarker(config); err != nil { + t.Errorf("the marker comparison consulted the descriptors: %v", err) + } +} diff --git a/internal/sandbox/windows_launch_gate_windows_test.go b/internal/sandbox/windows_launch_gate_windows_test.go new file mode 100644 index 000000000..2d7125fa1 --- /dev/null +++ b/internal/sandbox/windows_launch_gate_windows_test.go @@ -0,0 +1,59 @@ +//go:build windows + +package sandbox + +import ( + "bytes" + "strings" + "testing" +) + +// THE GATE HAS TO BE ON THE PATH THAT LAUNCHES, NOT ONLY IN A FUNCTION. +// +// windows_elevated_grant_attest_test.go proves ValidateWindowsSandboxLaunchGrants +// answers correctly. It calls it directly, so it stays green if the call in +// runWindowsSandboxCommand is deleted, which is exactly how a check that reads +// correct becomes a check that never runs. This drives the runner. +func TestTheRestrictedTokenTierRefusesBeforeCreatingAToken(t *testing.T) { + home := t.TempDir() + config := WindowsSandboxCommandConfig{ + SandboxHome: home, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + SandboxLevel: WindowsSandboxLevelRestrictedToken, + Command: []string{"cmd.exe", "/c", "echo hi"}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: `C:\workspace`}}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + } + if _, err := WriteWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(config)); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + + previous := windowsACLPlanApplied + t.Cleanup(func() { windowsACLPlanApplied = previous }) + + // With the grants intact the tier gets PAST both attestations. It fails later, + // on this machine, for reasons that have nothing to do with the gate, so the + // assertion is only that the refusal below is not what stopped it. + windowsACLPlanApplied = func(WindowsACLPlan) bool { return true } + var healthy bytes.Buffer + runWindowsSandboxCommand(config, &healthy) + if strings.Contains(healthy.String(), "no longer carry the permissions") { + t.Fatalf("SETUP INVALID: the gate refused with the grants intact: %s", healthy.String()) + } + + windowsACLPlanApplied = func(WindowsACLPlan) bool { return false } + var stderr bytes.Buffer + code := runWindowsSandboxCommand(config, &stderr) + if code == 0 { + t.Fatal("the runner launched into a sandbox whose directories no longer carry their grants") + } + if !strings.Contains(stderr.String(), "no longer carry the permissions") { + t.Errorf("the runner did not refuse for the missing grant; it stopped for something else: %s", stderr.String()) + } +} diff --git a/internal/sandbox/windows_runner.go b/internal/sandbox/windows_runner.go index 032f2a844..a0de8e14e 100644 --- a/internal/sandbox/windows_runner.go +++ b/internal/sandbox/windows_runner.go @@ -335,11 +335,33 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli if execRequest.EnforcementLevel == EnforcementUnelevated { level = WindowsSandboxLevelUnelevated } + // Derived AND created here, in the caller's shell. The unelevated tier applies + // this plan itself, and applyWindowsACLPlan fails the whole run on an AllowWrite + // target that does not exist; prepareSandboxRuntime creates only the candidate + // this process selects, so the other one has to be created explicitly. The + // runner cannot do it, for the same reason it cannot derive them (below). + runtimeProfile, err := windowsSandboxProfileWithProvisionedRuntime( + execRequest.PermissionProfile, + []string{execRequest.WorkspaceRoot}, + ) + if err != nil { + return CommandPlan{}, err + } args, err := BuildWindowsSandboxCommandArgs(WindowsSandboxCommandArgsOptions{ - SandboxHome: sandboxHome, - CommandCWD: spec.Dir, - WorkspaceRoots: []string{execRequest.WorkspaceRoot}, - PermissionProfile: execRequest.PermissionProfile, + SandboxHome: sandboxHome, + CommandCWD: spec.Dir, + WorkspaceRoots: []string{execRequest.WorkspaceRoot}, + // The SAME augmentation setup applied, so the marker's plan and this + // command's plan describe the same roots. + // + // The runner cannot derive the candidates itself: it runs re-exec'd with + // TEMP and TMP already pointed at the sandbox runtime temp, so os.TempDir() + // inside it returns the redirected value and the temp-derived candidate + // comes out rooted under the runtime tree instead of under the real temp. + // Setup, whose TEMP is untouched, derived the other spelling, and every + // command then died on "permission roots or deny lists changed" with two + // plans that had the same number of entries and different paths. + PermissionProfile: runtimeProfile, Env: childEnv, SandboxLevel: level, Command: append([]string{spec.Name}, spec.Args...), diff --git a/internal/sandbox/windows_runner_marker_windows_test.go b/internal/sandbox/windows_runner_marker_windows_test.go new file mode 100644 index 000000000..729677bcb --- /dev/null +++ b/internal/sandbox/windows_runner_marker_windows_test.go @@ -0,0 +1,134 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// THE AUGMENTATION HAS TO HAPPEN ON THE COMMAND PATH, NOT ONLY IN A HELPER. +// +// windows_setup_runtime_root_test.go proves the pieces compose: given a profile +// put through WindowsSandboxProfileWithRuntimeRoots on both sides, the marker +// validates. It calls that function directly, so it stays green even when the +// production call site in BuildCommandPlan is deleted, which is exactly the +// shape of the bug being fixed here. Reverting the runner call and watching that +// test still pass is how this gap was found. +// +// So this drives the real path and asserts on what the runner is actually handed. +// The config is serialized into the runner's argv, so the argv is where to look: +// recomputing the profile in the test would just be the helper test again. +func TestBuildCommandPlanCarriesTheRuntimeRootsIntoTheRunnerArgs(t *testing.T) { + workspace := t.TempDir() + cacheRoot := t.TempDir() + original := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = original }) + + engine := NewEngine(EngineOptions{ + WorkspaceRoot: workspace, + Policy: DefaultPolicy(), + Backend: Backend{ + Name: BackendWindowsRestrictedToken, + Available: true, + Platform: "windows", + Executable: filepath.Join(t.TempDir(), WindowsSandboxCommandRunnerName), + CommandWrapping: true, + NativeIsolation: true, + }, + }) + + plan, err := engine.BuildCommandPlan(CommandSpec{ + Name: "cmd.exe", + Args: WindowsShellArgs("echo hi"), + Dir: workspace, + }) + if err != nil { + t.Fatalf("BuildCommandPlan: %v", err) + } + defer plan.Cleanup() + + candidates := windowsSandboxRuntimeRoots(PermissionProfile{}, []string{workspace}) + if len(candidates) == 0 { + t.Fatal("no runtime candidates derived, so this test would pass vacuously") + } + + // The profile reaches the runner as JSON inside one of these arguments. + argv := strings.Join(plan.Args, "\x00") + for _, candidate := range candidates { + // JSON escapes the backslashes in a Windows path, so compare in the same + // spelling the encoder produced rather than the raw path. + encoded := strings.ReplaceAll(candidate, `\`, `\\`) + if !strings.Contains(argv, encoded) && !strings.Contains(argv, candidate) { + t.Errorf("the runner argv does not carry runtime root %s; setup grants it, so the plans disagree and every command dies on \"permission roots or deny lists changed\"", candidate) + } + } +} + +// GRANTING A ROOT IS NOT THE SAME AS PROVISIONING IT. +// +// The unelevated tier applies this plan itself, and applyWindowsACLPlan +// materializes only DenyRead targets: an AllowWrite target that does not exist +// aborts the run with "windows ACL target does not exist". prepareSandboxRuntime +// creates only the candidate the process SELECTS, so the other one has to be +// created explicitly, and it has to happen in the parent because the runner runs +// with TEMP redirected into the runtime tree and derives a different temp-side +// spelling. +func TestBuildCommandPlanProvisionsTheRuntimeRootsItGrants(t *testing.T) { + workspace := t.TempDir() + cacheRoot := t.TempDir() + original := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = original }) + + tempRoot := t.TempDir() + t.Setenv("TMP", tempRoot) + t.Setenv("TEMP", tempRoot) + + candidates := windowsSandboxRuntimeRoots(PermissionProfile{}, []string{workspace}) + if len(candidates) == 0 { + t.Fatal("no runtime candidates derived, so this test would pass vacuously") + } + for _, candidate := range candidates { + if err := os.RemoveAll(candidate); err != nil { + t.Fatalf("clear candidate %s: %v", candidate, err) + } + } + + engine := NewEngine(EngineOptions{ + WorkspaceRoot: workspace, + Policy: DefaultPolicy(), + Backend: Backend{ + Name: BackendWindowsRestrictedToken, + Available: true, + Platform: "windows", + Executable: filepath.Join(t.TempDir(), WindowsSandboxCommandRunnerName), + CommandWrapping: true, + NativeIsolation: true, + }, + }) + + plan, err := engine.BuildCommandPlan(CommandSpec{ + Name: "cmd.exe", + Args: WindowsShellArgs("echo hi"), + Dir: workspace, + }) + if err != nil { + t.Fatalf("BuildCommandPlan: %v", err) + } + defer plan.Cleanup() + + for _, candidate := range candidates { + info, err := os.Stat(candidate) + if err != nil { + t.Errorf("runtime root %s is granted by the plan but was not created: %v; the unelevated tier aborts on \"windows ACL target does not exist\"", candidate, err) + continue + } + if !info.IsDir() { + t.Errorf("runtime root %s exists but is not a directory", candidate) + } + } +} diff --git a/internal/sandbox/windows_runtime_ancestor_test.go b/internal/sandbox/windows_runtime_ancestor_test.go new file mode 100644 index 000000000..8edb780d3 --- /dev/null +++ b/internal/sandbox/windows_runtime_ancestor_test.go @@ -0,0 +1,109 @@ +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// AN ELEVATED ACL MUST NOT BE WRITTEN THROUGH A LINK. +// +// The runtime root is a predictable path under the user cache, and every +// component below the cache root is created by us. A local user needs no +// privilege to create a junction, so they can plant one at "zero", "runtime" or +// "v1" and have provisioning follow it: the leaf is created in their tree +// instead of ours, openWindowsACLTarget opens that leaf, sees no reparse point +// ON THE LEAF, and elevated setup grants the sandbox capability write access to +// a directory the attacker controls. +// +// The variant that matters is the one where the attacker ALSO creates the +// components below the junction. A check that looks only at the deepest existing +// component then finds an ordinary directory and passes. +func TestProvisioningRefusesAReparsePointAtAnOwnedAncestor(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("junctions are a Windows construct; the guard is only reachable there") + } + + // The owned tail, exactly as deterministicSandboxRuntimeRoot joins it. + for _, ancestor := range []string{"zero", filepath.Join("zero", "runtime"), filepath.Join("zero", "runtime", "v1")} { + t.Run(ancestor, func(t *testing.T) { + base := t.TempDir() + cache := filepath.Join(base, "cache") + decoy := filepath.Join(base, "attacker-owned") + root := filepath.Join(cache, "zero", "runtime", "v1", "abc123def456") + + link := filepath.Join(cache, ancestor) + if err := os.MkdirAll(filepath.Dir(link), 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(decoy, 0o700); err != nil { + t.Fatal(err) + } + out, err := exec.Command("cmd", "/c", "mklink", "/J", link, decoy).CombinedOutput() + if err != nil { + t.Skipf("cannot create a junction here: %v %s", err, out) + } + + // The attacker fills in everything below the junction, so the deepest + // EXISTING component is an ordinary directory and a check that looks only + // there is satisfied. + below := strings.TrimPrefix(strings.TrimPrefix(filepath.Dir(root), filepath.Join(cache, ancestor)), string(filepath.Separator)) + if below != "" { + if err := os.MkdirAll(filepath.Join(decoy, below), 0o700); err != nil { + t.Fatal(err) + } + } + + created, err := createRuntimeDirRecording(root) + if err == nil { + physical := physicalSandboxPath(root) + t.Fatalf("provisioning followed a junction at %s and created %v (physically %s); an elevated ACL applied to that leaf lands on a directory the attacker controls", ancestor, created, physical) + } + if !strings.Contains(err.Error(), "link") { + t.Errorf("the refusal does not explain that a link was in the way: %v", err) + } + }) + } +} + +// The ordinary case must still provision. A guard that refuses everything would +// satisfy the test above and break every real machine. +func TestProvisioningStillCreatesAnOrdinaryRuntimeRoot(t *testing.T) { + root := filepath.Join(t.TempDir(), "cache", "zero", "runtime", "v1", "abc123def456") + created, err := createRuntimeDirRecording(root) + if err != nil { + t.Fatalf("createRuntimeDirRecording on a clean tree: %v", err) + } + if len(created) == 0 { + t.Fatal("nothing was recorded as created") + } + if info, err := os.Stat(root); err != nil || !info.IsDir() { + t.Fatalf("the runtime root was not created: %v", err) + } +} + +// The CACHE ROOT above the owned components is the user's, and a redirected +// LOCALAPPDATA legitimately makes it a reparse point. Refusing there would break +// ordinary machines, so the guard must stop at the components Zero creates. +func TestProvisioningAllowsAReparsePointAboveTheOwnedComponents(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("junctions are a Windows construct") + } + base := t.TempDir() + real := filepath.Join(base, "real-cache") + link := filepath.Join(base, "redirected-cache") + if err := os.MkdirAll(real, 0o700); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, real).CombinedOutput(); err != nil { + t.Skipf("cannot create a junction here: %v %s", err, out) + } + + root := filepath.Join(link, "zero", "runtime", "v1", "abc123def456") + if _, err := createRuntimeDirRecording(root); err != nil { + t.Errorf("provisioning refused a redirected cache root, which is an ordinary Windows configuration: %v", err) + } +} diff --git a/internal/sandbox/windows_runtime_contract_test.go b/internal/sandbox/windows_runtime_contract_test.go new file mode 100644 index 000000000..ce3c7f3a6 --- /dev/null +++ b/internal/sandbox/windows_runtime_contract_test.go @@ -0,0 +1,110 @@ +package sandbox + +import ( + "os" + "strings" + "testing" +) + +// A LEASE FALLBACK MUST NOT BRICK THE WORKSPACE. +// +// Setup used to fingerprint a plan built from the cache-derived root, while a +// command derived the same root, failed to LEASE it, and silently relocated to +// the temp fallback. The command's plan then named a tree setup had never +// provisioned, and the marker rejected it with "permission roots or deny lists +// changed", which blames permissions for a runtime-root disagreement. +// +// Re-running setup could not recover: sandboxRuntimeRootFor rejects a candidate +// only for landing inside the workspace, never for being unusable, so setup +// chose the same unleasable root again. The only escapes were deleting the +// marker, which silently drops WFP network enforcement, or turning the sandbox +// off. Neither is mentioned by the error. +// +// Setup and the command select through one function now, so a fallback is +// something they agree on rather than something that splits them. +func TestSetupAndCommandSelectTheSameRuntimeRoot(t *testing.T) { + config := runtimeRootTestConfig(t) + + setupRoot, setupLease, _, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true, "") + if err != nil { + t.Fatalf("selectSandboxRuntimeRoot (setup side): %v", err) + } + setupLease.release() + + commandRoot, commandLease, _, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true, "") + if err != nil { + t.Fatalf("selectSandboxRuntimeRoot (command side): %v", err) + } + commandLease.release() + + if setupRoot != commandRoot { + t.Fatalf("setup selected %s and the command selected %s; the marker cannot validate across that", setupRoot, commandRoot) + } +} + +// THE MARKER MUST BE ABOUT AN OBJECT, NOT ABOUT A PATHNAME. +// +// cleanupSandboxRuntimeRoots evicts inactive roots with os.RemoveAll on an age +// and count policy, and the next run for that workspace recreates the same +// deterministic pathname with ordinary inherited permissions. The plan hash is +// over pathnames, so it was unchanged, and both the elevated and the unelevated +// marker checks reported setup as current while the recreated directory carried +// no capability ACE at all: a WRITE_RESTRICTED token could not write TMP, +// GOCACHE or anything else beneath it, with nothing saying why. +func TestAnEvictedRuntimeRootInvalidatesTheMarker(t *testing.T) { + config := runtimeRootTestConfig(t) + + selected, lease, _, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true, "") + if err != nil { + t.Fatalf("selectSandboxRuntimeRoot: %v", err) + } + lease.release() + + setup := WindowsSandboxSetupConfigFromCommand(config) + setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(setup.PermissionProfile, SandboxRuntime{Root: selected}), + config.WorkspaceRoots, + ) + if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + + command := config + command.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(config.PermissionProfile, SandboxRuntime{Root: selected}), + config.WorkspaceRoots, + ) + // The premise: it validates while the provisioned tree is intact. Without + // this the eviction assertion below could pass for the wrong reason. + if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command)); err != nil { + t.Fatalf("SETUP INVALID: the marker did not validate before eviction: %v", err) + } + + // Eviction, exactly as cleanupSandboxRuntimeRoots performs it. + if err := os.RemoveAll(selected); err != nil { + t.Fatalf("evict the runtime root: %v", err) + } + // And the pathname comes back, as prepareSandboxRuntime recreates it, with + // ordinary permissions and no capability ACE. This is the state that used to + // validate. + if err := os.MkdirAll(selected, 0o700); err != nil { + t.Fatalf("recreate the runtime root: %v", err) + } + + err = ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command)) + if err == nil { + t.Fatal("the marker still validates after the provisioned tree was evicted and recreated, so the command runs with no capability ACE and nothing reports it") + } + if !strings.Contains(err.Error(), "removed since setup ran") { + t.Errorf("the error does not explain that the runtime tree was removed, so the operator cannot act on it: %v", err) + } +} + +// A profile carrying no runtime root has nothing to check, which is the setup +// side itself and every non-restricted profile. The stamp must not become a +// requirement where there is no tree. +func TestRuntimeStampIsNotRequiredWithoutARuntimeRoot(t *testing.T) { + if err := validateWindowsSandboxRuntimeStamp(PermissionProfile{}, "somehash"); err != nil { + t.Errorf("a profile carrying no runtime root was rejected: %v", err) + } +} diff --git a/internal/sandbox/windows_runtime_recorded_root_test.go b/internal/sandbox/windows_runtime_recorded_root_test.go new file mode 100644 index 000000000..f3e980667 --- /dev/null +++ b/internal/sandbox/windows_runtime_recorded_root_test.go @@ -0,0 +1,170 @@ +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// blockCacheRuntimeRoot makes the cache-derived runtime root unleasable, and +// returns the function that frees it again. +// +// A file where prepareSandboxRuntimeLease wants a directory: MkdirAll on the +// parent fails, the lease attempt fails with it, and selection relocates to the +// temp fallback. It stands in for any reason the preferred root is unavailable +// for a moment, which is the whole point -- the defect never depended on which +// reason it was. +func blockCacheRuntimeRoot(t *testing.T, workspaceRoot string) (string, func()) { + t.Helper() + cacheRoot, err := sandboxUserCacheDir() + if err != nil { + t.Skipf("no user cache directory in this environment: %v", err) + } + preferred, err := sandboxRuntimeRootFor(canonicalSandboxWorkspaceRoot(workspaceRoot), canonicalSandboxWorkspaceRoot(cacheRoot)) + if err != nil { + t.Skipf("no cache-derived runtime root in this environment: %v", err) + } + blocker := filepath.Dir(preferred) + if err := os.MkdirAll(filepath.Dir(blocker), 0o700); err != nil { + t.Fatalf("create the blocker's parent: %v", err) + } + _ = os.RemoveAll(blocker) + if err := os.WriteFile(blocker, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("block the cache runtime root: %v", err) + } + return preferred, func() { _ = os.Remove(blocker) } +} + +// A TRANSIENT SELECTION IS NOT A DURABLE CONFIGURATION. +// +// Setup selected a runtime root, released the lease, and recorded a plan hash +// over pathnames. A command later ran the SAME selector, and the selector +// consults a lease: with the cache root briefly unusable setup provisioned and +// recorded the temp fallback, and once it freed up the command selected the +// cache root instead. Its plan hash and stamp path then named a tree setup had +// never provisioned, so the marker rejected every command with "permission roots +// or deny lists changed" -- and re-running setup did not help, because setup was +// equally free to pick the other root. +// +// Setup's choice is recorded now and the command consumes it, so the two cannot +// disagree no matter what changes in between. +func TestTheCommandHonoursTheRootSetupActuallyProvisioned(t *testing.T) { + config := runtimeRootTestConfig(t) + workspace := config.WorkspaceRoots[0] + t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", config.SandboxHome) + + preferred, unblock := blockCacheRuntimeRoot(t, workspace) + + // Setup, with the cache root unusable. honorRecorded is false because setup + // is the one making the choice. + setupRoot, setupLease, _, err := selectSandboxRuntimeRoot(workspace, false, "") + if err != nil { + t.Fatalf("selectSandboxRuntimeRoot (setup): %v", err) + } + setupLease.release() + if sameWindowsRuntimeRootPath(setupRoot, preferred) { + t.Fatalf("setup selected the cache root %s even though it was blocked; this case is not being exercised", setupRoot) + } + + setup := WindowsSandboxSetupConfigFromCommand(config) + setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(setup.PermissionProfile, SandboxRuntime{Root: setupRoot}), + config.WorkspaceRoots, + ) + if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + + // And now the cache root frees up, which is exactly when the old code + // diverged. + unblock() + + commandRoot, commandLease, _, err := selectSandboxRuntimeRoot(workspace, true, "") + if err != nil { + t.Fatalf("selectSandboxRuntimeRoot (command): %v", err) + } + commandLease.release() + if !sameWindowsRuntimeRootPath(commandRoot, setupRoot) { + t.Fatalf("setup provisioned %s and the command selected %s once the cache root freed up; the marker can never validate across that", setupRoot, commandRoot) + } + + command := config + command.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(config.PermissionProfile, SandboxRuntime{Root: commandRoot}), + config.WorkspaceRoots, + ) + if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command)); err != nil { + t.Fatalf("the first command after setup was rejected: %v", err) + } +} + +// The record belongs to the workspace setup ran for. One sandbox home serves +// whichever workspace ran setup last, so honouring a root recorded for a +// different workspace would point this command's runtime at somebody else's +// tree. +func TestARootRecordedForAnotherWorkspaceIsNotHonoured(t *testing.T) { + config := runtimeRootTestConfig(t) + t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", config.SandboxHome) + + foreign := filepath.Join(t.TempDir(), "somebody-elses-runtime") + setup := WindowsSandboxSetupConfigFromCommand(config) + setup.PermissionProfile = permissionProfileWithRuntime(setup.PermissionProfile, SandboxRuntime{Root: foreign}) + if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + + selected, lease, _, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true, "") + if err != nil { + t.Fatalf("selectSandboxRuntimeRoot: %v", err) + } + lease.release() + if sameWindowsRuntimeRootPath(selected, foreign) { + t.Fatalf("a root recorded for another workspace was honoured: %s", selected) + } +} + +// A recorded root that cannot be leased must FAIL, not relocate. +// +// Relocating is what produced the permanent brick: the other root carries no +// capability ACE, so the command is rejected anyway, with a message about +// permissions that sends the operator looking in the wrong place. +func TestAnUnusableRecordedRootFailsInsteadOfRelocating(t *testing.T) { + config := runtimeRootTestConfig(t) + workspace := config.WorkspaceRoots[0] + t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", config.SandboxHome) + + recorded, lease, _, err := selectSandboxRuntimeRoot(workspace, false, "") + if err != nil { + t.Fatalf("selectSandboxRuntimeRoot (setup): %v", err) + } + lease.release() + + setup := WindowsSandboxSetupConfigFromCommand(config) + setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(setup.PermissionProfile, SandboxRuntime{Root: recorded}), + config.WorkspaceRoots, + ) + if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + + // Make the provisioned root unusable after the fact. + blocker := filepath.Dir(recorded) + if err := os.RemoveAll(blocker); err != nil { + t.Skipf("cannot displace the recorded root in this environment: %v", err) + } + if err := os.WriteFile(blocker, []byte("not a directory"), 0o600); err != nil { + t.Skipf("cannot displace the recorded root in this environment: %v", err) + } + t.Cleanup(func() { _ = os.Remove(blocker) }) + + selected, selectedLease, _, err := selectSandboxRuntimeRoot(workspace, true, "") + if err == nil { + selectedLease.release() + t.Fatalf("selection relocated to %s instead of reporting that the provisioned root is unusable", selected) + } + if !strings.Contains(err.Error(), "provisioned by setup") || !strings.Contains(err.Error(), "zero sandbox setup") { + t.Errorf("the error does not name the situation or the action that fixes it: %v", err) + } +} diff --git a/internal/sandbox/windows_runtime_root_rollback_test.go b/internal/sandbox/windows_runtime_root_rollback_test.go new file mode 100644 index 000000000..7d975bbe3 --- /dev/null +++ b/internal/sandbox/windows_runtime_root_rollback_test.go @@ -0,0 +1,102 @@ +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// A FAILED SETUP LEAVES NO NEW PERSISTENT STATE. +// +// Runtime roots are materialized before the network plan, the ACL apply, the +// network apply and the marker write. Any of those can fail, and the rollback +// that existed restored only ACL snapshots, so a run that reported failure still +// left new runtime directories behind. It could not have cleaned them up even in +// principle, because provisioning returned nothing about what it had made. +func TestRuntimeRootProvisioningRecordsOnlyWhatItCreated(t *testing.T) { + base := t.TempDir() + // A pre-existing ancestor the user owns, and a leaf below it that does not + // exist yet. Only the latter may be recorded. + existing := filepath.Join(base, "cache", "zero") + if err := os.MkdirAll(existing, 0o700); err != nil { + t.Fatal(err) + } + target := filepath.Join(existing, "runtime", "v1", "abc123") + + created, err := createRuntimeDirRecording(target) + if err != nil { + t.Fatalf("createRuntimeDirRecording: %v", err) + } + if len(created) != 3 { + t.Fatalf("recorded %v, want the three components below the pre-existing ancestor", created) + } + // Outermost first, so the undo walking backwards removes the leaf before its + // parent and never meets a non-empty directory of its own making. + want := []string{ + filepath.Join(existing, "runtime"), + filepath.Join(existing, "runtime", "v1"), + target, + } + for index := range want { + if created[index].path != want[index] { + t.Fatalf("recorded %v, want %v", created, want) + } + } + if _, err := os.Stat(target); err != nil { + t.Fatalf("target was not created: %v", err) + } + + rollback := windowsRuntimeRootRollback{created: created} + if err := rollback.run(); err != nil { + t.Fatalf("rollback: %v", err) + } + if _, err := os.Stat(filepath.Join(existing, "runtime")); !os.IsNotExist(err) { + t.Errorf("rollback left the created tree behind: %v", err) + } + // The pre-existing ancestor is not ours and must survive. + if _, err := os.Stat(existing); err != nil { + t.Errorf("rollback removed a directory it did not create: %v", err) + } +} + +// Provisioning that finds everything already there records nothing, so a failed +// setup on a machine that was already set up removes none of it. +func TestRuntimeRootProvisioningRecordsNothingWhenAlreadyPresent(t *testing.T) { + target := filepath.Join(t.TempDir(), "zero", "runtime", "v1", "abc123") + if err := os.MkdirAll(target, 0o700); err != nil { + t.Fatal(err) + } + created, err := createRuntimeDirRecording(target) + if err != nil { + t.Fatalf("createRuntimeDirRecording: %v", err) + } + if len(created) != 0 { + t.Fatalf("recorded %v for a tree that already existed", created) + } + if err := (windowsRuntimeRootRollback{created: created}).run(); err != nil { + t.Fatalf("rollback: %v", err) + } + if _, err := os.Stat(target); err != nil { + t.Errorf("rollback removed a pre-existing tree: %v", err) + } +} + +// Rollback refuses rather than destroys. A directory that has gained content is +// holding something this run did not create, and RemoveAll there would turn a +// failed setup into data loss. +func TestRuntimeRootRollbackRefusesToRemoveANonEmptyDirectory(t *testing.T) { + target := filepath.Join(t.TempDir(), "zero", "runtime", "v1", "abc123") + created, err := createRuntimeDirRecording(target) + if err != nil { + t.Fatalf("createRuntimeDirRecording: %v", err) + } + if err := os.WriteFile(filepath.Join(target, "someone-elses.txt"), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + if err := (windowsRuntimeRootRollback{created: created}).run(); err == nil { + t.Error("rollback reported success while a non-empty directory remained") + } + if _, err := os.Stat(filepath.Join(target, "someone-elses.txt")); err != nil { + t.Errorf("rollback destroyed content it did not create: %v", err) + } +} diff --git a/internal/sandbox/windows_runtime_tail.go b/internal/sandbox/windows_runtime_tail.go new file mode 100644 index 000000000..cb36b1ed2 --- /dev/null +++ b/internal/sandbox/windows_runtime_tail.go @@ -0,0 +1,113 @@ +package sandbox + +import ( + "errors" + "fmt" + "path/filepath" + "runtime" + "strings" +) + +// windowsSandboxRuntimeOwnedNames are the fixed components Zero joins under the +// cache or temp root, above the per-workspace digest. +// +// ONE INVENTORY. deterministicSandboxRuntimeRoot builds a root from these and +// the traversal below recognizes one by them, and the two have to stay the same +// list or the traversal silently stops treating a real runtime root as owned: +// it would fall back to opening by name, which is exactly the unprotected path +// this file exists to replace. A wrong answer here fails open, so the two uses +// read from the same place. +var windowsSandboxRuntimeOwnedNames = []string{"zero", "runtime", "v1"} + +// windowsSandboxRuntimeOwnedDepth is how many trailing components of a runtime +// root Zero creates and therefore owns: the fixed names plus the digest. +var windowsSandboxRuntimeOwnedDepth = len(windowsSandboxRuntimeOwnedNames) + 1 + +// windowsSandboxRuntimeOwnedTail splits a runtime root into the ancestor that +// belongs to the user and the components Zero created. +// +// The base is deliberately not our business. On a machine with a redirected +// LOCALAPPDATA it is legitimately a reparse point, and refusing there would +// break ordinary setups. Everything below it was created by us and has no +// business being a link. +// +// ok is false when the path does not have the shape a runtime root has, which +// means the caller must not treat it as owned. +func windowsSandboxRuntimeOwnedTail(root string) (string, []string, bool) { + cleaned := filepath.Clean(strings.TrimSpace(root)) + if cleaned == "" || cleaned == "." { + return "", nil, false + } + components := make([]string, 0, windowsSandboxRuntimeOwnedDepth) + current := cleaned + for range windowsSandboxRuntimeOwnedDepth { + parent := filepath.Dir(current) + if parent == current { + return "", nil, false + } + components = append(components, filepath.Base(current)) + current = parent + } + // components came off the tail, deepest first. + for index := range windowsSandboxRuntimeOwnedNames { + if !windowsRuntimeOwnedNameMatches(index, components[len(components)-1-index]) { + return "", nil, false + } + } + ordered := make([]string, 0, len(components)) + for index := len(components) - 1; index >= 0; index-- { + ordered = append(ordered, components[index]) + } + return current, ordered, true +} + +// errRuntimeTailNotOwned reports a path the rooted traversal will not handle. +// Callers must fail rather than quietly opening it by name. +var errRuntimeTailNotOwned = errors.New("path is not a sandbox runtime root") + +// windowsSameRuntimeRootPath compares two runtime roots the way the filesystem +// does, so the stamp rides along with the right target regardless of spelling. +func windowsSameRuntimeRootPath(left, right string) bool { + left = filepath.Clean(strings.TrimSpace(left)) + right = filepath.Clean(strings.TrimSpace(right)) + if left == "" || right == "" { + return false + } + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func runtimeTailNotOwned(root string) error { + return fmt.Errorf("%w: %s", errRuntimeTailNotOwned, root) +} + +// windowsRuntimeOwnedNameMatches reports whether one component of a candidate +// tail is a name Zero owns at that position. +// +// The FIRST position has two accepted spellings. The cache-derived root uses the +// fixed name; the temp-derived fallback scopes that component to the user on +// platforms where the temp root is shared between accounts, because every +// private ownership-checked ancestor has to sit inside one user's namespace. +// Both spellings are Zero's own and everything below them is fixed. +// +// Accepting only the fixed name silently cost the fallback BOTH protections it +// depends on: the rooted no-follow traversal fell back to opening the tree by +// name, and the shape guard stopped recognising it. Neither failure is visible +// at the point it happens, which is why the shape has a test of its own. +func windowsRuntimeOwnedNameMatches(index int, component string) bool { + if strings.EqualFold(component, windowsSandboxRuntimeOwnedNames[index]) { + return true + } + if index != 0 { + return false + } + return strings.EqualFold(component, fallbackOwnedNamesForMatch()[0]) +} + +// fallbackOwnedNamesForMatch is the seam the shape tests use. The scoped +// spelling only exists on platforms whose temp root is shared, so without it the +// accepting branch cannot be exercised at all on Windows, and a break there +// would only surface on another platform's CI. +var fallbackOwnedNamesForMatch = sandboxRuntimeFallbackOwnedNames diff --git a/internal/sandbox/windows_runtime_tail_impl_windows.go b/internal/sandbox/windows_runtime_tail_impl_windows.go new file mode 100644 index 000000000..f6a73b6b8 --- /dev/null +++ b/internal/sandbox/windows_runtime_tail_impl_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package sandbox + +import "errors" + +var errNoRootedStampWriter = errors.New("no rooted stamp writer on this platform") + +func writeRuntimeStampThroughHandle(root string, planHash string) error { + return writeWindowsRuntimeStampThroughHandle(root, planHash) +} diff --git a/internal/sandbox/windows_runtime_tail_other.go b/internal/sandbox/windows_runtime_tail_other.go new file mode 100644 index 000000000..e0b3719a0 --- /dev/null +++ b/internal/sandbox/windows_runtime_tail_other.go @@ -0,0 +1,14 @@ +//go:build !windows + +package sandbox + +import "errors" + +// errNoRootedStampWriter marks the platforms with no rooted traversal. The +// runtime stamp is a Windows concept; the code that writes it is shared only so +// its tests run everywhere. +var errNoRootedStampWriter = errors.New("no rooted stamp writer on this platform") + +func writeRuntimeStampThroughHandle(string, string) error { + return errNoRootedStampWriter +} diff --git a/internal/sandbox/windows_runtime_tail_windows.go b/internal/sandbox/windows_runtime_tail_windows.go new file mode 100644 index 000000000..685af6156 --- /dev/null +++ b/internal/sandbox/windows_runtime_tail_windows.go @@ -0,0 +1,422 @@ +//go:build windows + +package sandbox + +import ( + "fmt" + "os" + "sync" + "unsafe" + + "golang.org/x/sys/windows" +) + +// windowsFileAddFile is FILE_ADD_FILE, the directory right to create a file in +// it. x/sys/windows does not export it. +const windowsFileAddFile = 0x00000002 + +// A PATHNAME IS NOT AN OBJECT. +// +// refuseReparsedRuntimeAncestors inspects the owned components before and after +// creation, and that is a check-then-use however many times it runs. Everything +// afterwards reopens the tree BY NAME: openWindowsACLTarget passes the whole +// path to CreateFile, and FILE_FLAG_OPEN_REPARSE_POINT governs only the final +// component, so every ancestor is resolved normally. A local user who plants a +// junction at an owned ancestor between the last check and the open gets the +// capability ACL written to an ordinary leaf inside a directory they chose. +// Windows junctions need no privilege to create, so this is not a theoretical +// attacker. +// +// A second Lstat narrows that window; it cannot close it. The only thing that +// closes it is never resolving the path again: open the base once, then descend +// one component at a time RELATIVE TO THE HANDLE ABOVE IT, refusing a reparse +// point at each step, and use the handle that comes out for everything that +// follows. NtCreateFile is what allows a relative open at all; Win32 CreateFile +// has no equivalent. + +// openWindowsRuntimeTailDirectory walks the components Zero owns and returns a +// handle to the runtime root itself. The caller closes it. +func openWindowsRuntimeTailDirectory(root string, access uint32) (windows.Handle, error) { + base, components, ok := windowsSandboxRuntimeOwnedTail(root) + if !ok { + // NOT a fallback to opening by name. A path that does not have a runtime + // root's shape is one this traversal cannot vouch for, and the whole point + // is to stop trusting a name. + return 0, runtimeTailNotOwned(root) + } + // The base belongs to the user, so it is opened by name and its own reparse + // points are followed: a redirected LOCALAPPDATA is an ordinary machine + // configuration, not an attack. + parent, err := openWindowsDirectoryByName(base) + if err != nil { + return 0, fmt.Errorf("open sandbox runtime base %s: %w", base, err) + } + for index, name := range components { + // FILE_READ_ATTRIBUTES on every component, including the intermediates: + // each open is followed by a GetFileInformationByHandle to decide whether + // it is a reparse point, and without that right the check itself fails with + // "Access is denied" and refuses the whole tree. + wanted := access | windows.FILE_READ_ATTRIBUTES + if index < len(components)-1 { + // Intermediate components are only traversed. + wanted = windows.FILE_TRAVERSE | windows.FILE_READ_ATTRIBUTES | windows.SYNCHRONIZE + } + child, err := openWindowsChildNoFollow(parent, name, wanted, windows.FILE_DIRECTORY_FILE) + _ = windows.CloseHandle(parent) + if err != nil { + return 0, err + } + parent = child + } + return parent, nil +} + +func openWindowsDirectoryByName(path string) (windows.Handle, error) { + utf16Path, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + return windows.CreateFile( + utf16Path, + windows.FILE_TRAVERSE|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS, + 0, + ) +} + +// openWindowsChildNoFollow opens exactly one component beneath parent. +// +// FILE_OPEN_REPARSE_POINT makes the open land on a link rather than following +// it, so a swapped component is opened as the link it is and then refused, +// instead of silently resolving into somebody else's tree. Since the name is +// relative to a handle, no ancestor is re-resolved and there is no interval for +// a swap to land in. +func openWindowsChildNoFollow(parent windows.Handle, name string, access uint32, options uint32) (windows.Handle, error) { + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return 0, fmt.Errorf("encode sandbox runtime component %s: %w", name, err) + } + attributes := windows.OBJECT_ATTRIBUTES{ + RootDirectory: parent, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + var handle windows.Handle + var iosb windows.IO_STATUS_BLOCK + err = windows.NtCreateFile( + &handle, + access|windows.SYNCHRONIZE, + &attributes, + &iosb, + nil, + 0, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_OPEN, + options|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, + ) + if err != nil { + return 0, fmt.Errorf("open sandbox runtime component %s: %w", name, err) + } + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + _ = windows.CloseHandle(handle) + return 0, fmt.Errorf("inspect sandbox runtime component %s: %w", name, err) + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + _ = windows.CloseHandle(handle) + return 0, fmt.Errorf("refusing to use the sandbox runtime through a link at %s: a reparse point here redirects the directory the sandbox is granted write access to", name) + } + return handle, nil +} + +// writeWindowsRuntimeStampThroughHandle writes the setup stamp INTO the object +// the traversal reached, not into whatever the pathname resolves to now. +// +// The old writer used MkdirAll and a pathname write, which left a second +// unbound interval: a tree replaced after the ACL apply could be recreated and +// stamped without ever carrying the capability grant, and marker validation +// still passed because it only reads the stamp's contents. The restricted +// process then got a marker-valid runtime path with no grant on it. +// writeWindowsRuntimeStampToDirectoryHandle writes the stamp into an ALREADY +// OPEN directory, naming nothing. The caller holds the handle the capability ACE +// was applied through, so the stamp cannot land anywhere else. +func writeWindowsRuntimeStampToDirectoryHandle(directory windows.Handle, planHash string) error { + objectName, err := windows.NewNTUnicodeString(windowsSandboxRuntimeStampName) + if err != nil { + return fmt.Errorf("encode sandbox runtime setup stamp name: %w", err) + } + attributes := windows.OBJECT_ATTRIBUTES{ + RootDirectory: directory, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + var handle windows.Handle + var iosb windows.IO_STATUS_BLOCK + err = windows.NtCreateFile( + &handle, + windows.GENERIC_WRITE|windows.READ_CONTROL|windows.WRITE_DAC|windows.SYNCHRONIZE, + &attributes, + &iosb, + nil, + windows.FILE_ATTRIBUTE_NORMAL, + windows.FILE_SHARE_READ, + windows.FILE_OVERWRITE_IF, + windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, + ) + if err != nil { + return fmt.Errorf("write sandbox runtime setup stamp: %w", err) + } + file := os.NewFile(uintptr(handle), windowsSandboxRuntimeStampName) + defer file.Close() + reader, err := windowsRuntimeStampReader(directory) + if err != nil { + return err + } + // PROTECTED BEFORE ANYTHING IS WRITTEN, because the stamp lives inside the + // tree it attests. See protectWindowsRuntimeStamp. + if err := protectWindowsRuntimeStamp(windows.Handle(file.Fd()), reader); err != nil { + return err + } + if _, err := file.WriteString(planHash); err != nil { + return fmt.Errorf("write sandbox runtime setup stamp: %w", err) + } + return nil +} + +// windowsRuntimeStampReader resolves the identity that has to read the stamp +// AFTER setup returns, from the runtime root the stamp is created in. +// +// Taken from the DIRECTORY HANDLE rather than the setup token, because the +// question is not who elevated but whose install this is. Setup runs elevated +// and may run as a different administrator account than the one that later runs +// the command or zero doctor; the runtime root lives under the ordinary user +// profile and its owner is stable across that boundary. +// windowsSetupConsumerSID is the ordinary reader carried across the elevation +// boundary by `zero sandbox setup`, resolved in the operator's shell. +// +// Guarded because tests set it; production writes it once, before setup touches +// anything, and clears it on the way out. +var ( + windowsSetupConsumerMu sync.Mutex + windowsSetupConsumerSID *windows.SID +) + +func setWindowsSetupConsumerSID(sid *windows.SID) func() { + windowsSetupConsumerMu.Lock() + previous := windowsSetupConsumerSID + windowsSetupConsumerSID = sid + windowsSetupConsumerMu.Unlock() + return func() { + windowsSetupConsumerMu.Lock() + windowsSetupConsumerSID = previous + windowsSetupConsumerMu.Unlock() + } +} + +func carriedWindowsSetupConsumerSID() *windows.SID { + windowsSetupConsumerMu.Lock() + defer windowsSetupConsumerMu.Unlock() + return windowsSetupConsumerSID +} + +// windowsRuntimeStampReader resolves the identity that must READ the stamp once +// setup has returned. +// +// The carried SID wins. It is the token that will actually run the commands, +// resolved before elevation, and it is the only source that survives the token +// boundary: the elevated helper CREATES the runtime leaf when it is absent, so +// deriving the reader from that leaf yields BUILTINAdministrators. A later +// UAC-filtered administrator carries that group deny-only and a standard user +// given alternate admin credentials is not in it at all, so the protected stamp +// would end up with no enabled allow ACE for the token that has to validate it, +// and every restricted command would stop before launch on a setup that had +// just reported success. +// +// The owner fallback stays for the paths that are not setup, notably rollback +// recreating a stamp it just removed, where the leaf already exists and belongs +// to whoever owns the install. +func windowsRuntimeStampReader(directory windows.Handle) (*windows.SID, error) { + if carried := carriedWindowsSetupConsumerSID(); carried != nil { + return carried, nil + } + descriptor, err := windows.GetSecurityInfo(directory, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + return nil, fmt.Errorf("read the sandbox runtime root owner: %w", err) + } + owner, _, err := descriptor.Owner() + if err != nil { + return nil, fmt.Errorf("read the sandbox runtime root owner: %w", err) + } + if owner == nil { + return nil, fmt.Errorf("read the sandbox runtime root owner: the descriptor carried none") + } + return owner, nil +} + +// protectWindowsRuntimeStamp gives the stamp its own DACL, excluding the +// capability SID the sandboxed command runs with. +// +// THE ATTESTATION CANNOT LIVE IN THE SUBJECT'S OWN WRITABLE NAMESPACE. The +// runtime root carries an AllowWrite entry for the capability SID with +// SUB_CONTAINERS_AND_OBJECTS_INHERIT, which is exactly what lets a sandboxed +// command write TMP, GOCACHE and the package-manager caches under it. A file +// created inside that root inherits the same grant, so the restricted command +// could open the stamp and overwrite it after passing its own pre-launch +// validation. Its current command would continue, and every later elevated +// command and zero doctor would then reject the altered plan hash until an +// Administrator re-ran setup: a sandboxed process bricking the sandbox. +// +// Moving the stamp outside the tree is the other way to fix it and is worse: +// the stamp works precisely because it dies with the tree, so eviction is +// detectable without reading an ACE. Keeping it inside with inheritance +// switched off preserves that and closes the hole. +// +// PROTECTED, not merely explicit: without SE_DACL_PROTECTED the inherited +// capability ACE stays in the DACL alongside whatever is set here. +func protectWindowsRuntimeStamp(handle windows.Handle, reader *windows.SID) error { + if reader == nil { + return fmt.Errorf("resolve the reader SID for the sandbox runtime stamp: no identity was supplied") + } + system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return fmt.Errorf("resolve LocalSystem SID for the sandbox runtime stamp: %w", err) + } + administrators, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return fmt.Errorf("resolve Administrators SID for the sandbox runtime stamp: %w", err) + } + // Setup writes it, doctor and the elevated command read it; nothing else + // needs to reach it, and the capability SID is deliberately absent. + // + // The reader is named EXPLICITLY and gets READ ONLY. It used to be + // WinCreatorOwnerSid at GENERIC_ALL. SetSecurityInfo does substitute that + // placeholder even in a NO_INHERITANCE ACE, so the resulting ACE did name a + // concrete SID, but the one it named was whoever happened to run setup. When + // setup is elevated by a different administrator account than the one that + // later runs the command or zero doctor, the reader matches no ACE and + // os.ReadFile on the stamp returns Access is denied, so a successful setup + // hands over an unreadable attestation. Resolving the identity from the + // runtime root the stamp lives in binds it to the install rather than to the + // elevation. + // + // Read and DELETE, not write. Withholding write from the SANDBOX is the real + // boundary and the capability SID has no ACE here at all. Withholding it from + // the root owner is not: they own the parent directory, so delete-then-create + // forges a stamp exactly as well as an overwrite would. What read-only would + // actually cost is rollback, which has to remove a stamp this run wrote under + // the same token that wrote it. + // + // So: no FILE_WRITE_DATA, no WRITE_DAC, no WRITE_OWNER, which keeps an + // accidental in-place rewrite off the table, and DELETE so compensation can + // undo its own work. The write below still succeeds either way: the handle was + // opened GENERIC_WRITE before this DACL was applied, and Windows checks access + // at open time. + // The reader can BE one of the repair identities. A runtime root created by + // an elevated process is commonly owned by BUILTINAdministrators rather than + // by the invoking user, which is what CI runners do. Naming the same SID + // twice let the narrower read-only entry win and left repair unable to rewrite + // the stamp, so skip the reader entry when the broader grant already covers it. + entries := make([]windows.EXPLICIT_ACCESS, 0, 3) + if !reader.Equals(system) && !reader.Equals(administrators) { + entries = append(entries, windows.EXPLICIT_ACCESS{ + AccessPermissions: windows.FILE_GENERIC_READ | windows.DELETE, + AccessMode: windows.SET_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_USER, + TrusteeValue: windows.TrusteeValueFromSID(reader), + }, + }) + } + for _, sid := range []*windows.SID{system, administrators} { + entries = append(entries, windows.EXPLICIT_ACCESS{ + AccessPermissions: windows.GENERIC_ALL, + AccessMode: windows.SET_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(sid), + }, + }) + } + dacl, err := windows.ACLFromEntries(entries, nil) + if err != nil { + return fmt.Errorf("build the sandbox runtime stamp DACL: %w", err) + } + if err := windows.SetSecurityInfo( + handle, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, nil, dacl, nil, + ); err != nil { + return fmt.Errorf("protect the sandbox runtime setup stamp: %w", err) + } + return nil +} + +func writeWindowsRuntimeStampThroughHandle(root string, planHash string) error { + directory, err := openWindowsRuntimeTailDirectory(root, windows.FILE_TRAVERSE|windowsFileAddFile|windows.READ_CONTROL|windows.SYNCHRONIZE) + if err != nil { + return err + } + defer windows.CloseHandle(directory) + + objectName, err := windows.NewNTUnicodeString(windowsSandboxRuntimeStampName) + if err != nil { + return fmt.Errorf("encode sandbox runtime setup stamp name: %w", err) + } + attributes := windows.OBJECT_ATTRIBUTES{ + RootDirectory: directory, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + var handle windows.Handle + var iosb windows.IO_STATUS_BLOCK + err = windows.NtCreateFile( + &handle, + windows.GENERIC_WRITE|windows.READ_CONTROL|windows.WRITE_DAC|windows.SYNCHRONIZE, + &attributes, + &iosb, + nil, + windows.FILE_ATTRIBUTE_NORMAL, + windows.FILE_SHARE_READ, + windows.FILE_OVERWRITE_IF, + windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, + ) + if err != nil { + return fmt.Errorf("write sandbox runtime setup stamp: %w", err) + } + file := os.NewFile(uintptr(handle), windowsSandboxRuntimeStampName) + defer file.Close() + reader, err := windowsRuntimeStampReader(directory) + if err != nil { + return err + } + // Both writers protect. This one is the fallback path, and a stamp written + // here would inherit the same capability grant as one written through the + // ACL handle. + if err := protectWindowsRuntimeStamp(windows.Handle(file.Fd()), reader); err != nil { + return err + } + if _, err := file.WriteString(planHash); err != nil { + return fmt.Errorf("write sandbox runtime setup stamp: %w", err) + } + return nil +} diff --git a/internal/sandbox/windows_runtime_tail_windows_test.go b/internal/sandbox/windows_runtime_tail_windows_test.go new file mode 100644 index 000000000..a06753869 --- /dev/null +++ b/internal/sandbox/windows_runtime_tail_windows_test.go @@ -0,0 +1,173 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +// makeJunction creates a real directory junction. +// +// mklink /J, not os.Symlink, on purpose. A junction needs NO privilege, which is +// what makes this an attack an ordinary local user can mount against elevated +// setup, and it is a different reparse tag from a symlink: os.Lstat reports a +// junction as ModeIrregular rather than ModeSymlink, so a guard written against +// symlinks is inert against the thing that is actually reachable here. +func makeJunction(t *testing.T, link, target string) { + t.Helper() + if err := os.MkdirAll(target, 0o700); err != nil { + t.Fatalf("create the junction target: %v", err) + } + output, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput() + if err != nil { + t.Skipf("cannot create a junction in this environment: %v (%s)", err, output) + } +} + +func runtimeTailRoot(t *testing.T) (string, string) { + t.Helper() + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + return base, root +} + +// A SWAP AT ANY OWNED ANCESTOR MUST BE REFUSED, at the moment the tree is used. +// +// The pre-creation and post-creation checks are check-then-use however many +// times they run: an ancestor replaced afterwards is followed by the next open, +// because FILE_FLAG_OPEN_REPARSE_POINT governs only the final component of a +// pathname. Every owned component is covered here, not just the deepest one: +// a junction at "zero" with ordinary directories created below it leaves the +// leaf looking perfectly normal. +func TestTheRootedTraversalRefusesAJunctionAtEveryOwnedComponent(t *testing.T) { + for depth := range windowsSandboxRuntimeOwnedDepth { + t.Run("swap "+string(rune('0'+depth))+" levels above the leaf", func(t *testing.T) { + base, root := runtimeTailRoot(t) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + + // Replace one owned component with a junction pointing somewhere else, + // and recreate the components below it inside the attacker's target so + // the leaf itself is an ordinary directory. + swapped := root + for range depth { + swapped = filepath.Dir(swapped) + } + tail, err := filepath.Rel(swapped, root) + if err != nil { + t.Fatalf("relate the swapped component to the root: %v", err) + } + if err := os.RemoveAll(swapped); err != nil { + t.Fatalf("clear the component to swap: %v", err) + } + target := filepath.Join(t.TempDir(), "attacker") + makeJunction(t, swapped, target) + if tail != "." { + if err := os.MkdirAll(filepath.Join(target, tail), 0o700); err != nil { + t.Fatalf("recreate the components below the junction: %v", err) + } + } + // Above the leaf, the leaf itself is now an ORDINARY directory, which is + // exactly why a check that looks only there passes while the open lands + // inside the attacker's tree. Asserted so the subtest cannot quietly + // degenerate into the easy leaf-swap case. + if depth > 0 { + if info, err := os.Lstat(root); err != nil || info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 { + t.Fatalf("the leaf is not an ordinary directory, so this case is not being exercised (err %v)", err) + } + } + + handle, err := openWindowsRuntimeTailDirectory(root, windows.READ_CONTROL|windows.WRITE_DAC|windows.FILE_TRAVERSE) + if err == nil { + _ = windows.CloseHandle(handle) + t.Fatalf("the traversal followed a junction at an owned component and would have applied the elevated ACL inside %s", target) + } + // A distinctive phrase, not the word "link": t.TempDir() names its + // directory after the test, so a subtest name can put the word the + // assertion looks for into every path in the error. + if !strings.Contains(err.Error(), "redirects the directory") { + t.Errorf("the refusal does not name the reason: %v", err) + } + _ = base + }) + } +} + +// And the ordinary tree still opens, or the guard above would be satisfied by a +// traversal that refuses everything. +func TestTheRootedTraversalOpensAnOrdinaryRuntimeRoot(t *testing.T) { + _, root := runtimeTailRoot(t) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + handle, err := openWindowsRuntimeTailDirectory(root, windows.READ_CONTROL|windows.FILE_TRAVERSE) + if err != nil { + t.Fatalf("an ordinary runtime root was refused: %v", err) + } + _ = windows.CloseHandle(handle) +} + +// A REDIRECTED LOCALAPPDATA IS NOT AN ATTACK. The base above the owned +// components belongs to the user, and on a machine with a redirected cache +// directory it is legitimately a reparse point. Refusing there would break +// ordinary setups on ordinary machines. +func TestTheRootedTraversalFollowsAJunctionAboveTheOwnedComponents(t *testing.T) { + real := t.TempDir() + base := filepath.Join(t.TempDir(), "redirected-localappdata") + makeJunction(t, base, real) + + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree through the redirected base: %v", err) + } + handle, err := openWindowsRuntimeTailDirectory(root, windows.READ_CONTROL|windows.FILE_TRAVERSE) + if err != nil { + t.Fatalf("a redirected cache directory was refused: %v", err) + } + _ = windows.CloseHandle(handle) +} + +// The stamp goes into the object the traversal reached. Writing it by pathname +// left a second unbound interval after the ACL apply: a replaced tree could be +// recreated and stamped without ever carrying the capability grant, and marker +// validation still passed because it only compares the stamp's contents. +func TestTheStampIsWrittenThroughTheTraversalAndRefusesASwappedTree(t *testing.T) { + _, root := runtimeTailRoot(t) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + if err := writeWindowsRuntimeStampThroughHandle(root, "planhash"); err != nil { + t.Fatalf("write the stamp through the traversal: %v", err) + } + recorded, err := os.ReadFile(windowsSandboxRuntimeStampPath(root)) + if err != nil || string(recorded) != "planhash" { + t.Fatalf("the stamp did not land in the runtime root (%q, err %v)", recorded, err) + } + + // Now replace an owned ancestor, as an attacker would between the ACL apply + // and the stamp. + parent := filepath.Dir(root) + leaf := filepath.Base(root) + if err := os.RemoveAll(parent); err != nil { + t.Fatalf("clear the component to swap: %v", err) + } + target := filepath.Join(t.TempDir(), "attacker") + makeJunction(t, parent, target) + if err := os.MkdirAll(filepath.Join(target, leaf), 0o700); err != nil { + t.Fatalf("recreate the leaf inside the attacker's tree: %v", err) + } + + if err := writeWindowsRuntimeStampThroughHandle(root, "planhash"); err == nil { + t.Fatal("the stamp was written through a junction, marking an unprovisioned tree as set up") + } + if _, err := os.Stat(filepath.Join(target, leaf, windowsSandboxRuntimeStampName)); err == nil { + t.Errorf("a stamp was written inside the attacker's tree at %s", target) + } +} diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 3fc9634e8..30d0b2162 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -9,13 +9,25 @@ import ( "io" "os" "path/filepath" + "runtime" "sort" "strings" ) const WindowsSandboxSetupName = "zero-windows-sandbox-setup.exe" -const windowsSandboxSetupMarkerSchemaVersion = 4 +// Bumped to 6 when the SELECTED RUNTIME ROOT itself became part of the marker, +// so a command consumes setup's choice instead of re-deriving one. A marker +// written by the previous version records no root, and honouring it would pin +// commands to whatever this build happens to select first. +// +// Bumped to 5 when setup began recording the CONCRETE runtime root it +// provisioned, and stamping that tree, instead of fingerprinting a plan built +// from a root it merely derived. A marker written by the previous version has no +// stamp, and requiring one without a bump would report every already-set-up +// machine as broken rather than as out of date. Bumping says the true thing: the +// setup protocol changed, run it once more. +const windowsSandboxSetupMarkerSchemaVersion = 6 type WindowsSandboxSetupArgsOptions struct { SandboxHome string @@ -29,6 +41,12 @@ type WindowsSandboxSetupConfig struct { CommandCWD string WorkspaceRoots []string PermissionProfile PermissionProfile + // ConsumerSID is the ordinary token that will READ the setup stamp after + // elevation returns. Resolved in the operator's shell and carried across the + // elevation boundary, never inferred here: the elevated helper creates the + // runtime leaf when it is absent, so that leaf's owner describes the + // installer rather than the consumer. + ConsumerSID string } type WindowsSandboxSetupMarker struct { @@ -43,42 +61,204 @@ type WindowsSandboxSetupMarker struct { NetworkInfraHash string `json:"networkInfraHash"` OfflineFilterSID string `json:"offlineFilterSid"` NetworkFilters int `json:"networkFilters"` + // RuntimeRoot is the runtime tree setup ACTUALLY PROVISIONED, recorded rather + // than re-derived. + // + // Selection consults a lease, and a lease is a fact about one moment. Setup + // took the lease only to learn which root won and released it immediately, so + // a command ran the same selector later and was free to reach a different + // answer: setup relocating to the temp fallback while the cache root was + // briefly unavailable, then a command taking the cache root once it freed up. + // Two selections, two roots, and a marker that can never validate again -- + // re-running setup does not help, because setup is equally free to pick the + // other one. + // + // Recording the choice removes the disagreement instead of trying to make two + // independent selections agree. See pinnedSandboxRuntimeRoot for the consuming + // side. + RuntimeRoot string `json:"runtimeRoot,omitempty"` } func WindowsSandboxSetupMarkerPath(sandboxHome string) string { return filepath.Join(sandboxHome, "windows-setup.json") } -func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]string, error) { +// setupConsumerSID is the process SID lookup, behind a variable because the +// interval between creating the runtime tree and returning the args is the whole +// reason the rollback exists, and this is the only failure that can happen in it. +// Without a seam here that interval is unreachable from a test, and an untestable +// compensation path is one nobody finds out is broken. +var setupConsumerSID = currentProcessSID + +// WindowsSandboxSetupPlan is the helper invocation plus the undo for what +// building it created. +// +// THE TRANSACTION STARTS AT THE FIRST MUTATING LEASE OPERATION, not when the +// helper starts. Selecting the runtime root takes a lease, and taking a lease +// creates zero/runtime/v1 and the lease file when they are not there. Those are +// this invocation's writes, made in this process, before the helper exists. The +// helper reacquires an already-existing tree and records no creation, and +// provisioning records only the leaf, so nothing downstream could undo them. +// +// Rollback is safe to call once the caller knows setup did not complete, and +// must not be called after it did: the tree it removes is the tree the marker +// now attests to. +type WindowsSandboxSetupPlan struct { + Args []string + // Rollback removes all and only what building these args created, in reverse + // creation order, and reports what it could not remove rather than claiming a + // clean undo. Never nil. + Rollback func() error +} + +// BuildWindowsSandboxSetupArgs prepares the elevated helper invocation. +// +// It returns a plan rather than bare args so the creation ledger cannot be +// dropped by a caller that only wanted the command line. That is exactly what +// happened while the lease helper had a recording form nobody in production +// called. +func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) (WindowsSandboxSetupPlan, error) { commandCWD := strings.TrimSpace(options.CommandCWD) if commandCWD == "" { - return nil, errors.New("windows sandbox setup requires command cwd") + return WindowsSandboxSetupPlan{}, errors.New("windows sandbox setup requires command cwd") } sandboxHome := strings.TrimSpace(options.SandboxHome) if sandboxHome == "" { var err error sandboxHome, err = ResolveWindowsSandboxHome(nil) if err != nil { - return nil, err + return WindowsSandboxSetupPlan{}, err } } workspaceRoots := trimNonEmptyStrings(options.WorkspaceRoots) if len(workspaceRoots) == 0 { workspaceRoots = []string{commandCWD} } + // SELECTED, NOT DERIVED, and selected here in the operator's shell because a + // command runs in that same environment and will reach the same answer. + // + // This used to derive the cache-based root and fingerprint a plan naming it. + // A command derived the same root, failed to LEASE it, and silently relocated + // to the temp fallback, so its plan named a tree setup had never provisioned + // and the marker rejected the command with "permission roots or deny lists + // changed" -- which blames permissions for a runtime-root disagreement. + // Re-running setup could not recover, because setup chose the same unleasable + // root again. That is a permanent brick rather than a retry. + // + // selectSandboxRuntimeRoot is the function commands use, lease attempt and + // fallback included, so setup provisions the tree a command will actually + // select. The lease is released straight away: it is taken here only to learn + // which root wins, and the command acquires its own. + // + // A SELECTION FAILURE IS FATAL HERE, and continuing was the bug. + // + // Continuing left the profile with no runtime root while setup went on to + // provision a derived tree, apply the capability ACLs and write the marker. + // That marker records an empty runtime root, so it attests nothing about the + // tree. A later command makes its own concrete selection, finds no stamp on + // what it selected and refuses, and re-running setup reaches this same branch + // and records nothing again: the permanent brick described above, reached by + // the one path that was allowed to skip the fix. + // + // Failing now is failing before any ACL or marker state is persisted, so the + // operator is left in a state a retry can get out of, and the message names + // the step that actually failed. + selected, lease, created, selectErr := selectSandboxRuntimeRoot(firstNonEmpty(workspaceRoots...), false, sandboxHome) + // UNDO BEFORE RETURNING, because a selection that failed part-way still made + // the components it got through. The old shape returned the error and left + // them, and nothing later had a record they existed. + if selectErr != nil { + undo := windowsRuntimeRootRollback{created: created}.run() + selectErr = fmt.Errorf("select the sandbox runtime root for setup: %w", selectErr) + if undo != nil { + selectErr = fmt.Errorf("%w; and the partial runtime tree could not be removed: %w", selectErr, undo) + } + return WindowsSandboxSetupPlan{}, selectErr + } + // The lease file is one of this invocation's artifacts when this acquisition + // created it, and rollback refuses a non-empty directory, so the undo has to + // own it or v1 can never be removed. It is released first: compensation + // retakes it exclusively, which is what proves no other process is using the + // tree it is about to delete. + leasePath, ownsLease := lease.createdLeaseFile() + lease.release() + rollback := func() error { + return undoWindowsSetupRuntimeCreation(selected, created, leasePath, ownsLease) + } + failed := func(err error) (WindowsSandboxSetupPlan, error) { + if undo := rollback(); undo != nil { + return WindowsSandboxSetupPlan{}, fmt.Errorf("%w; and the partial runtime tree could not be removed: %w", err, undo) + } + return WindowsSandboxSetupPlan{}, err + } + options.PermissionProfile = permissionProfileWithRuntime(options.PermissionProfile, SandboxRuntime{Root: selected}) + options.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(options.PermissionProfile, workspaceRoots) profileJSON, err := json.Marshal(options.PermissionProfile) if err != nil { - return nil, fmt.Errorf("marshal windows sandbox setup permission profile: %w", err) + return failed(fmt.Errorf("marshal windows sandbox setup permission profile: %w", err)) + } + // RESOLVED HERE, IN THE SAME PROCESS, and that is the whole supported model. + // + // Zero never elevates: the operator elevates the terminal, and runSandboxSetup + // launches the helper with a plain exec.Command. So this runs in a process that + // is already elevated and this SID is the installer's. It is also the consumer's + // only because the documented contract is an elevated terminal belonging to the + // account that will run Zero, which same-account UAC satisfies. The helper + // re-checks that the carried identity matches its own token and refuses + // otherwise, so the unsupported alternate-account shape fails loudly instead of + // provisioning a stamp for a token that can never read it. It creates the runtime leaf when it is missing, so a + // reader derived from that leaf is BUILTINAdministrators, and a later + // UAC-filtered administrator carries that group deny-only while a standard + // user given alternate admin credentials is not in it at all. Either way the + // protected stamp ends up with no enabled allow ACE for the token that has to + // validate it, and every restricted command stops before launch. + consumerSID, sidErr := setupConsumerSID() + if sidErr != nil { + return failed(sidErr) } args := []string{ "--sandbox-home", sandboxHome, "--command-cwd", commandCWD, "--permission-profile", string(profileJSON), } + if consumerSID != "" { + args = append(args, "--consumer-sid", consumerSID) + } for _, root := range workspaceRoots { args = append(args, "--workspace-root", root) } - return args, nil + return WindowsSandboxSetupPlan{Args: args, Rollback: rollback}, nil +} + +// undoWindowsSetupRuntimeCreation removes what building the setup args created. +// +// The lease goes first and only under an EXCLUSIVE acquisition. That is not +// tidiness: taking it exclusively is the proof that no other process is holding +// the runtime root this is about to delete. If somebody is, the lease and the +// tree both stay and the caller is told, which is the honest answer rather than +// a rollback that reports success while removing a live tree. +func undoWindowsSetupRuntimeCreation(root string, created []windowsCreatedRuntimeDir, leasePath string, ownsLease bool) error { + var errs []error + if ownsLease { + lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root) + switch { + case err != nil: + errs = append(errs, fmt.Errorf("take the sandbox runtime lease before removing it: %w", err)) + case inUse: + errs = append(errs, fmt.Errorf("the sandbox runtime root %s is in use by another process, so %s and the tree it protects were left in place", root, leasePath)) + default: + // Released before the remove: a file cannot be deleted on Windows while + // this process still holds it open. + lease.release() + if err := os.Remove(leasePath); err != nil && !os.IsNotExist(err) { + errs = append(errs, fmt.Errorf("remove the sandbox runtime lease %s: %w", leasePath, err)) + } + } + } + if err := (windowsRuntimeRootRollback{created: created}).run(); err != nil { + errs = append(errs, err) + } + return errors.Join(errs...) } func ParseWindowsSandboxSetupArgs(args []string) (WindowsSandboxSetupConfig, error) { @@ -110,6 +290,13 @@ func ParseWindowsSandboxSetupArgs(args []string) (WindowsSandboxSetupConfig, err config.WorkspaceRoots = append(config.WorkspaceRoots, root) } index = next + case "--consumer-sid": + value, next, err := nextWindowsSandboxFlagValue(args, index) + if err != nil { + return WindowsSandboxSetupConfig{}, err + } + config.ConsumerSID = strings.TrimSpace(value) + index = next case "--permission-profile": value, next, err := nextWindowsSandboxFlagValue(args, index) if err != nil { @@ -198,41 +385,67 @@ func BuildWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSa NetworkInfraHash: infraHash, OfflineFilterSID: offlineSID, NetworkFilters: len(infraPlan.Filters), + RuntimeRoot: windowsSandboxSelectedRuntimeRoot(config.PermissionProfile), }, nil } +// WriteWindowsSandboxSetupMarker builds the marker, stamps the runtime tree and +// records the marker file. +// +// The elevated setup path does NOT use this. It splits the two, because the +// stamp has to ride along with the capability ACE through one handle rather than +// re-open the runtime root by name afterwards. See windowsACLStampRequest. This +// entry point remains for callers that record a marker without applying an ACL +// plan, where there is no handle to ride. func WriteWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSandboxSetupMarker, error) { marker, err := BuildWindowsSandboxSetupMarker(config) if err != nil { return WindowsSandboxSetupMarker{}, err } + // Stamped alongside the marker, because this is the one place setup records + // that it completed and the two have to be recorded together: a marker whose + // stamp is missing reports setup as current when the tree it provisioned is + // gone, which is the whole failure. Written FIRST so a marker never outlives + // its stamp if the process dies between the two. + if err := writeWindowsSandboxRuntimeStamp(windowsSandboxSelectedRuntimeRoot(config.PermissionProfile), marker.ACLPlanHash); err != nil { + return WindowsSandboxSetupMarker{}, err + } + if err := writeWindowsSandboxSetupMarkerFile(config, marker); err != nil { + return WindowsSandboxSetupMarker{}, err + } + return marker, nil +} + +// writeWindowsSandboxSetupMarkerFile records an already-built marker and touches +// nothing else. It never names the runtime tree. +func writeWindowsSandboxSetupMarkerFile(config WindowsSandboxSetupConfig, marker WindowsSandboxSetupMarker) error { path := WindowsSandboxSetupMarkerPath(config.SandboxHome) if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return WindowsSandboxSetupMarker{}, fmt.Errorf("create windows sandbox setup marker dir: %w", err) + return fmt.Errorf("create windows sandbox setup marker dir: %w", err) } bytes, err := json.MarshalIndent(marker, "", " ") if err != nil { - return WindowsSandboxSetupMarker{}, fmt.Errorf("marshal windows sandbox setup marker: %w", err) + return fmt.Errorf("marshal windows sandbox setup marker: %w", err) } tmp, err := os.CreateTemp(filepath.Dir(path), ".windows-setup-*.tmp") if err != nil { - return WindowsSandboxSetupMarker{}, fmt.Errorf("create windows sandbox setup marker temp file: %w", err) + return fmt.Errorf("create windows sandbox setup marker temp file: %w", err) } tmpPath := tmp.Name() if _, err := tmp.Write(bytes); err != nil { _ = tmp.Close() _ = os.Remove(tmpPath) - return WindowsSandboxSetupMarker{}, fmt.Errorf("write windows sandbox setup marker temp file: %w", err) + return fmt.Errorf("write windows sandbox setup marker temp file: %w", err) } if err := tmp.Close(); err != nil { _ = os.Remove(tmpPath) - return WindowsSandboxSetupMarker{}, fmt.Errorf("close windows sandbox setup marker temp file: %w", err) + return fmt.Errorf("close windows sandbox setup marker temp file: %w", err) } if err := os.Rename(tmpPath, path); err != nil { _ = os.Remove(tmpPath) - return WindowsSandboxSetupMarker{}, fmt.Errorf("replace windows sandbox setup marker: %w", err) + return fmt.Errorf("replace windows sandbox setup marker: %w", err) } - return marker, nil + return nil } func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error { @@ -256,7 +469,12 @@ func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error { return fmt.Errorf("windows sandbox setup is out of date: schema %d, want %d", actual.SchemaVersion, expected.SchemaVersion) } if actual.ACLPlanHash != expected.ACLPlanHash || actual.ACLPlanEntries != expected.ACLPlanEntries { - return errors.New("windows sandbox setup is out of date: permission roots or deny lists changed") + // Name both sides. This message fires when setup and the command derived + // different runtime roots, and without the hashes the operator cannot tell + // that case apart from a genuine policy edit. + return fmt.Errorf("windows sandbox setup is out of date: permission roots or deny lists changed (marker plan %s, %d entries; this command wants %s, %d entries)", + shortWindowsACLPlanHash(actual.ACLPlanHash), actual.ACLPlanEntries, + shortWindowsACLPlanHash(expected.ACLPlanHash), expected.ACLPlanEntries) } // Mode-agnostic: validate the provisioned infrastructure, never the // per-command network mode — so an approved (allow) network command and an @@ -267,12 +485,75 @@ func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error { if actual.OfflineFilterSID != expected.OfflineFilterSID { return errors.New("windows sandbox setup is out of date: offline network identity changed") } + // THE PLAN HASH IS ABOUT PATHNAMES, NOT ABOUT OBJECTS. Everything above + // compares what setup INTENDED with what this command wants, and both sides + // agree as long as the same paths are named. Whether the directory those paths + // resolve to is still the one setup provisioned is a different question, and + // nothing here was asking it: cleanupSandboxRuntimeRoots evicts inactive roots + // and the next run recreates the pathname with ordinary permissions, so the + // hashes still matched while the capability ACE was gone and a + // WRITE_RESTRICTED token could not write anything under it. + if err := validateWindowsSandboxRuntimeStamp(config.PermissionProfile, expected.ACLPlanHash); err != nil { + return err + } if actual.NetworkFilters != expected.NetworkFilters { return errors.New("windows sandbox setup is out of date: network enforcement plan changed") } + // Named explicitly, and last, because the checks above cannot tell this case + // apart from a policy edit. A runtime-root disagreement used to surface as + // "permission roots or deny lists changed", which sends the operator looking + // at permissions for a problem that is nothing to do with them. + // + // With the root recorded this should not be reachable, since the command + // consumes what setup wrote. It stays as the assertion that the contract held. + if recorded := strings.TrimSpace(actual.RuntimeRoot); recorded != "" { + if selected := strings.TrimSpace(expected.RuntimeRoot); selected != "" && !sameWindowsRuntimeRootPath(recorded, selected) { + return fmt.Errorf("windows sandbox setup is out of date: setup provisioned runtime root %s, this command selected %s -- run `zero sandbox setup` from an elevated (Administrator) terminal", recorded, selected) + } + } return nil } +// sameWindowsRuntimeRootPath compares two runtime roots the way the filesystem +// does on this platform. Windows paths are case-insensitive, and the recorded +// root and the selected root can differ only in spelling. +func sameWindowsRuntimeRootPath(left, right string) bool { + left = filepath.Clean(left) + right = filepath.Clean(right) + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +// WindowsSandboxRecordedRuntimeRoot returns the runtime root a previous setup +// provisioned, or "" when there is no usable marker. +// +// Deliberately silent on every failure. A missing, unreadable or malformed +// marker means there is nothing to honour, and the caller's job is to select +// normally rather than to report on marker health -- validation does that, with +// a much better message than a selector could produce. +func WindowsSandboxRecordedRuntimeRoot(sandboxHome string) string { + sandboxHome = strings.TrimSpace(sandboxHome) + if sandboxHome == "" { + return "" + } + bytes, err := os.ReadFile(WindowsSandboxSetupMarkerPath(sandboxHome)) + if err != nil { + return "" + } + var marker WindowsSandboxSetupMarker + if err := json.Unmarshal(bytes, &marker); err != nil { + return "" + } + // A root recorded by an older schema describes a tree provisioned under + // different rules, so it is not a root this build may pin to. + if marker.SchemaVersion != windowsSandboxSetupMarkerSchemaVersion { + return "" + } + return strings.TrimSpace(marker.RuntimeRoot) +} + func WindowsACLPlanHash(plan WindowsACLPlan) (string, error) { entries := canonicalWindowsACLEntries(plan.Entries) bytes, err := json.Marshal(entries) @@ -306,3 +587,660 @@ func canonicalWindowsACLEntries(entries []WindowsACLEntry) []WindowsACLEntry { }) return out } + +// windowsSandboxRuntimeRoots returns the runtime root the plan must name. +// +// PINNED to the profile's own runtime when it has one, rather than derived a +// second time. A command's profile has already been through +// permissionProfileWithRuntime, so its runtime tree is the one this process +// chose, created and took a lease on. Asking a separate function to work out +// which tree that "should" be is how the plan comes to name one directory while +// the command writes to another, which is the whole of issue #881. +// +// Deriving BOTH candidates was the previous answer to that problem. It bought +// agreement at the price of putting os.TempDir() into a machine-wide +// fingerprint: setup run under one TEMP recorded a plan that a later parent +// process with a different TEMP could not reproduce, so every command failed the +// equality check even though the cache runtime was untouched and healthy. +// Pinning removes the second derivation instead of trying to keep two in step. +// +// The derive branch below serves callers that have no runtime yet (elevated +// setup, doctor) and goes through sandboxRuntimeRootFor, THE selector +// prepareSandboxRuntime uses, so the two cannot drift. +// +// The FIRST root only, and that is deliberate rather than an oversight. The +// marker compares plan hashes for EQUALITY, and a command presents exactly one +// workspace root, so setup has to derive its candidates from the same single root +// the command will. Deriving them for every root instead would put candidates in +// the marker that no single command reproduces, and every command would fail with +// the same "permission roots or deny lists changed" this pairing exists to fix. +// Nothing passes more than one root today: every construction site is a +// one-element slice. Whoever adds multi-root support has to change the marker to a +// per-root or subset comparison FIRST; widening this function on its own would +// reintroduce the outage. +func windowsSandboxRuntimeRoots(profile PermissionProfile, workspaceRoots []string) []string { + // The pin. Nothing is derived when the profile already carries the answer, + // including after prepareSandboxRuntime relocated on a lease failure: the plan + // names whatever tree the command actually holds. + if profile.Runtime != nil { + if root := strings.TrimSpace(profile.Runtime.Root); root != "" { + return []string{root} + } + } + workspaceRoot := "" + for _, candidate := range workspaceRoots { + if trimmed := strings.TrimSpace(candidate); trimmed != "" { + workspaceRoot = canonicalSandboxWorkspaceRoot(trimmed) + break + } + } + if workspaceRoot == "" || workspaceRoot == "." { + return nil + } + cacheRoot, err := sandboxUserCacheDir() + if err != nil { + return nil + } + // Canonicalized exactly as prepareSandboxRuntime canonicalizes it, because + // sandboxRuntimeRootFor compares this against the workspace root to decide + // whether the cache-derived tree lands inside it. + if cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot); cacheRoot == "" || cacheRoot == "." { + return nil + } + root, err := sandboxRuntimeRootFor(workspaceRoot, cacheRoot) + if err != nil { + return nil + } + return []string{root} +} + +// windowsSandboxProfileWithRuntime adds the runtime candidates as write roots. +// +// Applied on BOTH sides of the setup protocol, which is the whole point. The +// marker fingerprints the capability ACL plan built from this profile, while +// every command reaches the Windows runner having already had +// permissionProfileWithRuntime append the root it selected. Setup fingerprinted +// the bare profile and the command presented an augmented one, so a marker +// written seconds earlier was rejected with "permission roots or deny lists +// changed" and no command could run at all. +// +// Naming the SAME root on both sides makes the two hashes agree, and it puts the +// runtime root into the CAPABILITY plan as well. That second part matters since +// the principal command runs on a WRITE_RESTRICTED token restricted to the +// capability SIDs: a runtime root carrying only the principal ACE satisfies the +// normal token and fails the restricted check, so cache and temp writes were +// denied even once the marker agreed. +func windowsSandboxProfileWithRuntime(profile PermissionProfile, workspaceRoots []string) PermissionProfile { + candidates := windowsSandboxRuntimeRoots(profile, workspaceRoots) + if len(candidates) == 0 { + return profile + } + existing := make(map[string]struct{}, len(profile.FileSystem.WriteRoots)) + for _, root := range profile.FileSystem.WriteRoots { + existing[windowsCapabilityPathKey(root.Root)] = struct{}{} + } + writeRoots := append([]WritableRoot{}, profile.FileSystem.WriteRoots...) + for _, candidate := range candidates { + if _, ok := existing[windowsCapabilityPathKey(candidate)]; ok { + continue + } + existing[windowsCapabilityPathKey(candidate)] = struct{}{} + writeRoots = append(writeRoots, WritableRoot{Root: candidate}) + } + profile.FileSystem.WriteRoots = writeRoots + return profile +} + +// ensureWindowsSandboxRuntimeRoots creates every runtime root the plan grants. +// +// Paired with windowsSandboxProfileWithRuntime: that function puts the root into +// the ACL plan, and this one makes it exist. Splitting the two is what broke +// elevated setup once already, because the capability plan refuses to +// materialize a write root and fails the whole run on a path that is merely +// absent. Both now go through windowsSandboxRuntimeRoots, so they cannot name +// different trees. +// +// Called by WHOEVER APPLIES THE PLAN, which is both tiers rather than only the +// elevated one. Setup applies it under Administrator; the unelevated tier applies +// its own workspace ACLs per command by design, since capability grants on trees +// the user already owns need no privilege. A command creating a runtime root under +// its own cache or temp grants itself nothing it could not create anyway, and the +// tier that skips this is the tier that dies on "windows ACL target does not +// exist". +// windowsRuntimeRootRollback removes the runtime directories one provisioning +// call actually created, and only those. +// +// Setup materializes runtime roots before the network plan, the ACL apply, the +// network apply and the marker write. Every one of those can fail, and the +// existing rollback only restored ACL snapshots, so a failed `zero sandbox +// setup` reported failure and left new persistent state behind. It could not +// clean up even in principle, because provisioning returned nothing about what +// it had made. +type windowsRuntimeRootRollback struct { + // created is in creation order, outermost first, so undo walks it backwards. + // + // Identity-bound, not pathname-bound. Compensation runs after the apply + // handles have closed, so resolving these names again can reach a different + // object: rename the original aside, drop an ordinary directory in its place, + // and a pathname-only undo removes the substitute while the original keeps + // this run's grant and stamp. + created []windowsCreatedRuntimeDir + // stamp is the runtime stamp's state before this run touched it. + // + // The stamp is the one artifact setup writes INSIDE the runtime root, and it + // is written before the marker. A marker write that failed therefore left a + // root this run had created holding a file this run had written, and the + // rollback below refuses a non-empty directory on purpose, so the failed setup + // kept its own residue forever. Owning the stamp is what makes the root empty + // again and the whole transaction complete. + stamp windowsSandboxStampSnapshot +} + +// windowsCreatedRuntimeDir is one directory this run made, remembered by the +// object it was rather than by the name it had. +type windowsCreatedRuntimeDir struct { + path string + identity string + // identified separates "no identity" from "identity not established", for the + // same reason as the stamp snapshot above. + identified bool +} + +// windowsSandboxStampSnapshot records the runtime stamp as it was before setup +// overwrote it, so a failed run restores rather than deletes. +// +// Restoring matters where a previous setup had succeeded. Deleting the stamp +// would leave that machine's still-valid marker pointing at a tree with no +// stamp, which reads as "the runtime directory was removed since setup ran" -- +// a healthy machine reporting itself broken because an unrelated later setup +// failed. +// runtimeStampState is what the snapshot could actually establish about the +// stamp that was there BEFORE this run. +// +// Two booleans could not say it. "Read it, and there was nothing" and "could not +// read it" both arrived as existed=false, so compensation could not tell an undo +// from destruction: it deleted the current stamp and returned immediately with +// nothing to put back, and a setup attempt that REPORTED FAILURE had destroyed +// the attestation belonging to the previous successful setup. +type runtimeStampState int + +const ( + // runtimeStampUnknown is the zero value on purpose: a snapshot nobody filled + // in must never read as proven absence. + runtimeStampUnknown runtimeStampState = iota + // runtimeStampAbsent is a POSITIVE observation of nothing there, and only + // ERROR_FILE_NOT_FOUND or ERROR_PATH_NOT_FOUND produces it. + runtimeStampAbsent + // runtimeStampPresent means the prior bytes were read completely. + runtimeStampPresent +) + +type windowsSandboxStampSnapshot struct { + path string + prior []byte + // priorState is what was actually observed. See runtimeStampState: an + // encoding, directory-open, identity, child-open or read failure stays + // UNKNOWN and must stop the forward mutation rather than authorizing a + // delete-without-restore later. + priorState runtimeStampState + // root and rootIdentity identify the DIRECTORY the stamp lives in, captured + // when the snapshot was taken. The stamp file itself may not exist yet, so the + // directory is the object whose replacement this has to detect. + root string + rootIdentity string + // rootIdentified records whether the identity above was ESTABLISHED, as + // opposed to being empty because the capture failed. The two were + // indistinguishable, and restore treated the empty case as permission to + // mutate the pathname: exactly the case where compensation cannot prove what + // it is undoing. + rootIdentified bool +} + +// snapshotWindowsSandboxRuntimeStamp captures what compensation will need, and +// FAILS rather than guessing. An error here must stop setup before the ACL and +// stamp are applied: the writer can replace an existing stamp even when the +// earlier read was denied, and compensation would then delete it with nothing +// recorded to restore. +func snapshotWindowsSandboxRuntimeStamp(root string) (windowsSandboxStampSnapshot, error) { + root = strings.TrimSpace(root) + if root == "" { + return windowsSandboxStampSnapshot{}, nil + } + path := windowsSandboxRuntimeStampPath(root) + // ONE HANDLE FOR BOTH FACTS. Taking the identity and then re-resolving the + // pathname to read the stamp let a rename in between pair one directory's + // identity with another's bytes, so a rollback could verify the right object + // and then write the wrong contents into it. + rootIdentity, rootIdentified, prior, state, err := snapshotRuntimeStampBound(root) + if err != nil { + return windowsSandboxStampSnapshot{}, fmt.Errorf("record the sandbox runtime stamp at %s before changing it: %w", path, err) + } + return windowsSandboxStampSnapshot{ + path: path, + prior: prior, + priorState: state, + root: root, + rootIdentity: rootIdentity, + rootIdentified: rootIdentified, + }, nil +} + +func (snapshot windowsSandboxStampSnapshot) restore() error { + if snapshot.path == "" { + return nil + } + // THE NAME IS NOT THE OBJECT ONCE THE APPLY HANDLES HAVE CLOSED. Removing a + // stamp from, or writing one onto, whatever now answers to this path can + // mutate a directory this run never touched, while the original keeps the + // grant and the stamp. Leave the substitute alone and say what was left + // behind. + if !snapshot.rootIdentified { + // AN IDENTITY THAT WAS NEVER ESTABLISHED IS NOT PERMISSION TO MUTATE. This + // used to fall through to the pathname operations below, so a root that + // could not be opened when setup began was compensated by writing to, or + // removing from, whatever answered to the name afterwards. + // + // A root that is simply absent is different: there is no object to confuse + // and nothing of a previous run to put back, and the created-directory + // rollback owns that case. + if _, err := os.Lstat(snapshot.root); err == nil { + return fmt.Errorf("sandbox runtime root %s could not be identified when setup began, so the stamp at %s cannot be shown to belong to this run; leaving it untouched", snapshot.root, snapshot.path) + } + return nil + } + // AN UNPROVEN PRIOR STATE IS NOT PERMISSION TO DELETE. Absence has to have + // been observed, not inferred from a generic error: compensation for + // "existed=false" removes the current stamp and returns with nothing to put + // back, so reaching here on an unreadable snapshot would destroy the + // attestation of the previous successful setup on behalf of a run that + // failed. Setup refuses before the apply for exactly this reason, and this is + // the second half of that guard for any path that assembled a record anyway. + if snapshot.priorState == runtimeStampUnknown { + return fmt.Errorf("the stamp at %s could not be read when setup began, so removing or restoring it now cannot be shown to be an undo; leaving it untouched", snapshot.path) + } + // BOUND TO THE OBJECT, not to the name. The identity check and the mutation + // now share one handle, so a rename and replacement cannot land between them. + return compensateRuntimeStampBound(snapshot.root, snapshot.rootIdentity, snapshot.prior, snapshot.priorState == runtimeStampPresent) +} + +// run removes what was created, innermost first. +// +// os.Remove rather than os.RemoveAll, deliberately. A directory that is not +// empty by the time we get here holds something this call did not create, and +// removing it would turn a failed setup into data loss. Refusing is the right +// answer: the error is reported and the residue stays findable. +func (rollback windowsRuntimeRootRollback) run() error { + var errs []error + // The stamp first, so a root this run created is empty again by the time the + // directory walk reaches it. EVERY compensation still runs if this one fails: + // one broken undo must not strand the rest. + if err := rollback.stamp.restore(); err != nil { + errs = append(errs, err) + } + for index := len(rollback.created) - 1; index >= 0; index-- { + entry := rollback.created[index] + // Same rule as the stamp: prove this is the directory we made before + // removing it. A substitute at the same name belongs to whoever put it + // there. + if !entry.identified { + // Created, but never identified. Removing whatever is here now would be + // a pathname-authoritative delete of an object this run cannot prove it + // made. + errs = append(errs, fmt.Errorf("sandbox runtime root %s was created by this run but could not be identified, so it cannot be removed safely; leaving it in place", entry.path)) + continue + } + if err := removeCreatedRuntimeDirBound(entry.path, entry.identity); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// runWindowsSandboxSetupCompensations undoes a failed setup COMPLETELY, and +// reports everything that went wrong doing it. +// +// One function because the old code had two failure closures that composed by +// calling each other, and the outer one returned as soon as the ACL rollback +// reported an error. The runtime rollback then never ran, so the failure most +// likely to leave a machine in a strange state was the one failure that skipped +// half the cleanup. Every compensation runs here, unconditionally, and the +// errors are joined rather than raced. +// +// aclRollback is nil before the ACL plan has been applied, which is the only +// difference between the two failure points. +func runWindowsSandboxSetupCompensations(cause error, aclRollback func() error, runtimeRollback windowsRuntimeRootRollback) error { + errs := []error{cause} + if aclRollback != nil { + if err := aclRollback(); err != nil { + errs = append(errs, fmt.Errorf("acl rollback failed: %w", err)) + } + } + if err := runtimeRollback.run(); err != nil { + errs = append(errs, fmt.Errorf("runtime rollback failed: %w", err)) + } + return errors.Join(errs...) +} + +// createRuntimeDirRecording is MkdirAll that reports which components it made. +// +// The distinction between "created" and "already there" is the whole contract: +// a pre-existing cache or temp ancestor belongs to the user and must survive a +// failed setup, while the components this run added must not. + +// refuseReparsedRuntimeAncestors rejects a reparse point at any component Zero +// creates, so an elevated ACL is never written through one. +// +// ONLY THE COMPONENTS WE OWN. The cache root above them is the user's, and on a +// machine with a redirected LOCALAPPDATA it is legitimately a reparse point, so +// refusing there would break ordinary setups. Everything below it is ours, was +// created by us, and has no business being a link. +// +// The check has to cover EVERY owned component, not just the deepest one that +// exists. A junction planted at "zero" with the components below it created by +// the attacker leaves the deepest existing component an ordinary directory, so a +// check that looks only there passes while creation follows the junction and the +// leaf lands in the attacker's tree. openWindowsACLTarget then opens that leaf, +// sees no reparse point on it, and the capability ACL is written outside the +// runtime hierarchy entirely. +// +// os.Lstat reports a junction as ModeIrregular rather than ModeSymlink, which is +// why both are tested: a Windows junction needs no privilege to create, so this +// is reachable by any local user. +func refuseReparsedRuntimeAncestors(root string) error { + cleaned := filepath.Clean(root) + owned := make([]string, 0, windowsSandboxRuntimeOwnedDepth) + current := cleaned + for range windowsSandboxRuntimeOwnedDepth { + owned = append(owned, current) + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + for _, component := range owned { + info, err := os.Lstat(component) + if err != nil { + if os.IsNotExist(err) { + continue + } + return fmt.Errorf("inspect sandbox runtime component %s: %w", component, err) + } + if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 { + return fmt.Errorf("refusing to provision the sandbox runtime through a link at %s: a reparse point here would redirect the directory the sandbox is granted write access to", component) + } + } + return nil +} + +func createRuntimeDirRecording(root string) ([]windowsCreatedRuntimeDir, error) { + if strings.TrimSpace(root) == "" { + return nil, nil + } + // Checked BEFORE anything is created, and again by the caller after, because + // this alone is a check-then-use: an ancestor swapped between the two would + // still redirect the leaf. Pairing it with the post-check narrows the window + // to the creation itself rather than to the whole of setup. + if err := refuseReparsedRuntimeAncestors(root); err != nil { + return nil, err + } + // THE BASE IS FIXED, NOT DISCOVERED. This used to os.Stat its way to the + // deepest component that already existed and open THAT by name. Existence then + // chose the trust boundary: on the ordinary second-workspace shape + // \zero\runtime\v1 is already there and only the digest is missing, so + // the single by-name open was v1, one of the predictable components this + // traversal exists to protect. A local owner could replace it with a junction + // between the pre-check and that open, let elevated setup create the digest + // beneath the redirected target, and put the original back before the + // post-check; the creation record then held the redirected object under the + // original pathname, so compensation found a mismatch and left a privileged + // creation as residue. + // + // So the split comes from the same inventory that built the root. Only the + // cache or TEMP directory above the owned tail is opened by name, and every + // owned component below it, existing or missing, is reached from the retained + // parent handle. + base, components, owned := windowsSandboxRuntimeOwnedTail(root) + if !owned { + // Refused rather than walked by name. Falling back to the pathname walk is + // exactly the unprotected path this replaced, and both production builders + // derive a root from windowsSandboxRuntimeOwnedNames, so a root that does + // not have the shape is a bug rather than a configuration. + return nil, fmt.Errorf("sandbox runtime root %s does not have the owned shape %v/, so its trust boundary cannot be established", root, windowsSandboxRuntimeOwnedNames) + } + // The base itself is the operator's: a redirected LOCALAPPDATA or TMP is an + // ordinary configuration, and setup has always created it when absent. Only + // what Zero owns below it is handle-relative. + if err := os.MkdirAll(base, 0o755); err != nil { + return nil, fmt.Errorf("create sandbox runtime base %s: %w", base, err) + } + tail := make([]string, 0, len(components)) + current := base + for _, component := range components { + current = filepath.Join(current, component) + tail = append(tail, current) + } + created, err := createRuntimeTailHandleRelative(base, tail) + if err != nil { + return created, err + } + // Re-checked after creation. If an ancestor was swapped for a junction while + // we were creating, the leaf we just made is in the wrong tree, and granting + // it the capability ACL would put it on someone elses directory. Reported as + // a failure so the caller rolls back rather than proceeding. + if err := refuseReparsedRuntimeAncestors(root); err != nil { + return created, err + } + return created, nil +} + +func ensureWindowsSandboxRuntimeRoots(profile PermissionProfile, workspaceRoots []string) (windowsRuntimeRootRollback, error) { + var rollback windowsRuntimeRootRollback + for _, root := range windowsSandboxRuntimeRoots(profile, workspaceRoots) { + created, err := createRuntimeDirRecording(root) + // Appended before the error check: a partial creation still has to be + // undone, and returning the record with the error is what lets the caller + // do that. + rollback.created = append(rollback.created, created...) + if err != nil { + return rollback, err + } + } + return rollback, nil +} + +// buildWindowsSandboxSetupACLPlan provisions the runtime roots and then builds the +// plan that grants them, in that order. +// +// One function rather than two statements at the call site because the ordering is +// the contract: BuildWindowsACLPlan emits AllowWrite entries for the runtime +// candidates, applyWindowsACLPlan materializes only DenyRead targets, and an +// AllowWrite target that does not exist fails the entire run. The elevated setup +// path had the provisioning omitted once already, which turned a clean `zero +// sandbox setup` into "windows ACL target does not exist". Keeping the two joined +// here means a caller cannot get the plan without the trees it names. +func buildWindowsSandboxSetupACLPlan(config WindowsSandboxSetupConfig) (WindowsACLPlan, windowsRuntimeRootRollback, error) { + rollback, err := ensureWindowsSandboxRuntimeRoots(config.PermissionProfile, config.WorkspaceRoots) + if err != nil { + return WindowsACLPlan{}, rollback, err + } + plan, err := BuildWindowsACLPlan(config.commandConfig()) + if err != nil { + return WindowsACLPlan{}, rollback, err + } + return plan, rollback, nil +} + +// windowsSandboxProfileWithProvisionedRuntime is the command-side counterpart: +// it creates the runtime candidates and returns the profile that grants them. +// +// Joined for the same reason as the setup helper, and called from the PARENT for +// one more. The unelevated tier applies its own ACL plan inside the re-exec'd +// runner, where TEMP points into the runtime tree; deriving the candidates there +// yields a temp-side root under the redirected temp rather than the one the plan +// names, so the runner can neither derive nor provision them. The parent still has +// the operator's environment, so it does both and the runner only applies. +func windowsSandboxProfileWithProvisionedRuntime(profile PermissionProfile, workspaceRoots []string) (PermissionProfile, error) { + // The command side does not roll back: it is not transactional, and a runtime + // root it created is the tree the command is about to use. + if _, err := ensureWindowsSandboxRuntimeRoots(profile, workspaceRoots); err != nil { + return PermissionProfile{}, err + } + return windowsSandboxProfileWithRuntime(profile, workspaceRoots), nil +} + +// shortWindowsACLPlanHash trims a plan hash for a human-facing error. Twelve hex +// characters is plenty to tell two plans apart by eye, and the full 64 buries the +// rest of the message. +func shortWindowsACLPlanHash(hash string) string { + hash = strings.TrimSpace(hash) + if hash == "" { + return "(none)" + } + if len(hash) > 12 { + return hash[:12] + } + return hash +} + +// WindowsSandboxProfileWithRuntimeRoots folds the sandbox runtime roots into a +// permission profile, for callers that build a setup config outside this package. +// +// Call it ONLY from a process whose TEMP and TMP are the operator's. The +// temp-derived candidate reads os.TempDir(), and the sandbox points those +// variables at its own runtime temp for anything it launches, so a process on the +// far side of that redirection derives a path no other process agrees on. The +// command runner is exactly such a process: it takes the profile it is handed. +func WindowsSandboxProfileWithRuntimeRoots(profile PermissionProfile, workspaceRoots []string) PermissionProfile { + return windowsSandboxProfileWithRuntime(profile, workspaceRoots) +} + +// PermissionProfileWithRuntimeRoot names the CONCRETE runtime root on a profile, +// which is what makes the stamp check run. +// +// validateWindowsSandboxRuntimeStamp returns nil when profile.Runtime is nil, +// and that is the correct answer for the setup side and for every unrestricted +// profile. It was the wrong answer for doctor: doctor built its profile with +// PermissionProfileFromPolicy, which never sets Runtime, so the one check that +// can tell an evicted runtime tree from a healthy one was skipped and `zero +// doctor` reported a healthy sandbox on exactly the machine state the stamp was +// added to detect. +func PermissionProfileWithRuntimeRoot(profile PermissionProfile, root string) PermissionProfile { + if strings.TrimSpace(root) == "" { + return profile + } + return permissionProfileWithRuntime(profile, SandboxRuntime{Root: root}) +} + +// windowsSandboxRuntimeStampName marks a runtime root that ELEVATED SETUP +// actually provisioned and applied the capability ACL to. +const windowsSandboxRuntimeStampName = ".zero-sandbox-setup" + +func windowsSandboxRuntimeStampPath(root string) string { + return filepath.Join(root, windowsSandboxRuntimeStampName) +} + +// writeWindowsSandboxRuntimeStamp records, INSIDE the runtime root, that this +// exact tree carries the capability ACL for this exact plan. +// +// The marker alone cannot tell. It hashes ACL-plan ENTRIES, which are pathnames, +// not the objects those pathnames resolve to. cleanupSandboxRuntimeRoots removes +// inactive roots with os.RemoveAll on an age and count policy, and the next run +// for that workspace recreates the same deterministic pathname through +// os.MkdirAll with ordinary inherited permissions. The plan hash is unchanged, +// so both the elevated and the unelevated marker checks reported setup as +// current while the recreated directory carried NO capability ACE, and a +// WRITE_RESTRICTED token could not write TMP, GOCACHE or anything else under it. +// +// A file inside the tree survives exactly as long as the tree does. Eviction +// takes it, ordinary recreation does not restore it, so its absence is precisely +// the condition "this pathname exists but is not the object setup provisioned". +func writeWindowsSandboxRuntimeStamp(root string, planHash string) error { + root = strings.TrimSpace(root) + if root == "" { + return nil + } + if err := os.MkdirAll(root, 0o700); err != nil { + return fmt.Errorf("create sandbox runtime root for the setup stamp: %w", err) + } + // Written through the rooted traversal where one is available, so the stamp + // lands in the object the ACL was applied to rather than in whatever the + // pathname resolves to by now. MkdirAll above and a pathname write left a + // second unbound interval: a tree replaced after the ACL apply could be + // recreated and stamped with no capability grant on it at all, and validation + // still passed because it only compares the stamp contents. + if err := writeRuntimeStampThroughHandle(root, planHash); err == nil { + return nil + } else if !errors.Is(err, errRuntimeTailNotOwned) && !errors.Is(err, errNoRootedStampWriter) { + return err + } + if err := os.WriteFile(windowsSandboxRuntimeStampPath(root), []byte(planHash), 0o600); err != nil { + return fmt.Errorf("write sandbox runtime setup stamp: %w", err) + } + return nil +} + +// validateWindowsSandboxRuntimeStamp reports whether the runtime root this +// command will use is the one setup provisioned. +// +// Absent when the profile carries no runtime root, which is the setup side +// itself and every non-restricted profile, so this adds no requirement where +// there is nothing to check. +func validateWindowsSandboxRuntimeStamp(profile PermissionProfile, planHash string) error { + if profile.Runtime == nil { + return nil + } + root := strings.TrimSpace(profile.Runtime.Root) + if root == "" { + return nil + } + recorded, err := os.ReadFile(windowsSandboxRuntimeStampPath(root)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("the sandbox runtime directory for this workspace was removed since setup ran, so it no longer carries the permissions the sandbox needs — run `zero sandbox setup` from an elevated (Administrator) terminal (%s)", root) + } + return fmt.Errorf("read sandbox runtime setup stamp: %w", err) + } + if strings.TrimSpace(string(recorded)) != strings.TrimSpace(planHash) { + return fmt.Errorf("the sandbox runtime directory for this workspace was provisioned for a different configuration — run `zero sandbox setup` from an elevated (Administrator) terminal (%s)", root) + } + return nil +} + +// windowsSandboxSelectedRuntimeRoot returns the concrete runtime root a profile +// carries, or empty when it carries none. +func windowsSandboxSelectedRuntimeRoot(profile PermissionProfile) string { + if profile.Runtime == nil { + return "" + } + return strings.TrimSpace(profile.Runtime.Root) +} + +// ValidateWindowsSandboxLaunchGrants reports whether the objects this command's +// ACL plan names still carry its allow grants. +// +// SEPARATE FROM THE MARKER, BECAUSE IT IS A DIFFERENT KIND OF QUESTION. The +// marker comparisons ask whether setup's intent matches this command's, and both +// sides can agree perfectly while the filesystem has moved on underneath them. +// The runtime stamp narrows it to "the directory was not removed and recreated +// under this pathname", which an ordinary file answers by existing: it survives +// an ACL edit untouched, so an `icacls /reset`, an inheritance change on a +// parent, or a security product rewriting the DACL all leave a valid stamp over +// a runtime root the WRITE_RESTRICTED child cannot write. +// +// This one reads the security descriptors, and it is a LAUNCH decision rather +// than a report: the elevated tier calls it beside its marker validation, which +// puts both tiers' attestation in the same place, one refusing because it cannot +// repeat an elevated provisioning and one re-applying because it can. +func ValidateWindowsSandboxLaunchGrants(config WindowsSandboxSetupConfig) error { + plan, err := BuildWindowsACLPlan(config.commandConfig()) + if err != nil { + return err + } + if windowsACLPlanApplied(plan) { + return nil + } + return errors.New("the sandbox directories for this workspace no longer carry the permissions setup granted them, " + + "so the sandboxed command would be unable to write its temp and cache directories — " + + "run `zero sandbox setup` from an elevated (Administrator) terminal") +} diff --git a/internal/sandbox/windows_setup_identity_windows_test.go b/internal/sandbox/windows_setup_identity_windows_test.go new file mode 100644 index 000000000..6feacd6ce --- /dev/null +++ b/internal/sandbox/windows_setup_identity_windows_test.go @@ -0,0 +1,84 @@ +//go:build windows + +package sandbox + +import ( + "bytes" + "strings" + "testing" +) + +// SETUP MUST NOT PROVISION A STAMP FOR AN ACCOUNT THAT IS NOT DOING THE SETUP. +// +// Zero never elevates: runSandboxSetupHelper launches the helper with a plain +// exec.Command, and the helper refuses a non-elevated token, so the consumer SID +// is resolved in a process that is ALREADY elevated. Under same-account UAC that +// is harmless, because both tokens carry the same user SID. Under alternate +// administrator credentials it is not: the serialized SID describes the admin, +// the protected stamp gets its allow ACE for the wrong token, and every +// restricted command stops before launch on a setup that has just reported +// success. +// +// That case cannot be detected from inside an already-elevated process, so it is +// excluded rather than mis-provisioned. This pins the exclusion, and it pins it +// at the point that matters: BEFORE anything is provisioned. +func TestWindowsSetupRefusesAConsumerThatIsNotTheInstaller(t *testing.T) { + const consumer = "S-1-5-21-1111111111-2222222222-3333333333-1001" + const installer = "S-1-5-21-1111111111-2222222222-3333333333-500" + + previousElevated := windowsSetupProcessIsElevated + previousSID := windowsSetupInstallerSID + t.Cleanup(func() { + windowsSetupProcessIsElevated = previousElevated + windowsSetupInstallerSID = previousSID + }) + // Elevated, because otherwise the gate under test is never reached on an + // ordinary developer box. + windowsSetupProcessIsElevated = func() bool { return true } + windowsSetupInstallerSID = func() (string, error) { return installer, nil } + + var stderr bytes.Buffer + code := runWindowsSandboxSetup(WindowsSandboxSetupConfig{ + ConsumerSID: consumer, + // Deliberately nothing else: if the gate does not fire first, setup would + // go on to provision with an empty config, and this test would fail on that + // instead, which is still a failure but for the wrong reason. The message + // assertions below are what distinguish them. + }, &stderr) + + if code == 0 { + t.Fatalf("setup accepted a consumer that is not the installer and reported success:\n%s", stderr.String()) + } + message := stderr.String() + if !strings.Contains(message, "Alternate-account setup is not supported") { + t.Fatalf("the refusal does not name the unsupported model, so an operator cannot act on it:\n%s", message) + } + if !strings.Contains(message, consumer) || !strings.Contains(message, installer) { + t.Errorf("the refusal names neither identity, so it cannot be diagnosed:\n%s", message) + } +} + +// And the supported model is still accepted at this gate: same account, so the +// carried consumer matches the installing token and setup proceeds past it. +// +// Asserting the gate is passed rather than that setup succeeds, because setup +// goes on to do real provisioning that an unelevated test box cannot perform. +func TestWindowsSetupAcceptsTheSameAccountConsumer(t *testing.T) { + const same = "S-1-5-21-1111111111-2222222222-3333333333-1001" + + previousElevated := windowsSetupProcessIsElevated + previousSID := windowsSetupInstallerSID + t.Cleanup(func() { + windowsSetupProcessIsElevated = previousElevated + windowsSetupInstallerSID = previousSID + }) + windowsSetupProcessIsElevated = func() bool { return true } + windowsSetupInstallerSID = func() (string, error) { return same, nil } + + var stderr bytes.Buffer + _ = runWindowsSandboxSetup(WindowsSandboxSetupConfig{ConsumerSID: same}, &stderr) + + if strings.Contains(stderr.String(), "Alternate-account setup is not supported") { + t.Fatalf("the supported same-account model was refused by the identity gate:\n%s", stderr.String()) + } +} diff --git a/internal/sandbox/windows_setup_provision_test.go b/internal/sandbox/windows_setup_provision_test.go new file mode 100644 index 000000000..26cfedc84 --- /dev/null +++ b/internal/sandbox/windows_setup_provision_test.go @@ -0,0 +1,365 @@ +package sandbox + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// The tests here drive PRODUCTION entry points rather than the helpers those entry +// points call. The distinction is the point: this runtime-root work shipped once +// with tests that called windowsSandboxProfileWithRuntime and +// ensureWindowsSandboxRuntimeCandidates directly, and they stayed green while the +// production call sites were missing outright. A test that reaches past the caller +// proves the helper works and says nothing about whether anything calls it. + +// windowsRuntimeTestRoots returns a workspace plus the runtime candidates derived +// for it, with EVERY location the derivation reads pointed at test-owned +// directories. +// +// Stubbing sandboxUserCacheDir is not tidiness. windowsSandboxRuntimeCandidates +// reads the real user cache when it is left alone, so a test that then clears a +// candidate to prove provisioning recreates it was deleting the developer's own +// zero runtime tree under the real cache on every run, and failing outright on a +// read-only home. The test has no ownership claim on that path, so it must not +// derive one. windows_runner_marker_windows_test.go had this right already. +func windowsRuntimeTestRoots(t *testing.T) (string, []string) { + t.Helper() + workspaceRoot := t.TempDir() + cacheRoot := t.TempDir() + tempRoot := t.TempDir() + + originalCacheDir := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = originalCacheDir }) + t.Setenv("TMP", tempRoot) + t.Setenv("TEMP", tempRoot) + + candidates := windowsSandboxRuntimeRoots(PermissionProfile{}, []string{workspaceRoot}) + if len(candidates) == 0 { + t.Skip("no runtime candidates derivable in this environment") + } + // Belt and braces: refuse to run rather than touch anything the test does not + // own, so a later change to the derivation cannot quietly reintroduce this. + // + // BOTH SIDES CANONICALIZED, because pathWithinRoot compares spellings and the + // candidate arrives canonical: the derivation runs the cache root through + // canonicalSandboxWorkspaceRoot before joining. Measuring that against a raw + // t.TempDir() is the same one-sided comparison this PR fixes in + // fallbackSandboxRuntimeRoot, and it fired on exactly the machines that carry a + // second spelling: macOS /var vs /private/var, and a CI Windows runner whose + // profile has an 8.3 name (RUNNER~1 against runneradmin). It passed locally + // because this box has neither. + ownedCache := canonicalSandboxWorkspaceRoot(cacheRoot) + ownedTemp := canonicalSandboxWorkspaceRoot(tempRoot) + for _, candidate := range candidates { + canonical := canonicalSandboxWorkspaceRoot(candidate) + if !pathWithinRoot(ownedCache, canonical) && !pathWithinRoot(ownedTemp, canonical) { + t.Fatalf("candidate %s (canonically %s) is outside the test-owned cache (%s) and temp (%s) roots; refusing to modify it", candidate, canonical, ownedCache, ownedTemp) + } + } + return workspaceRoot, candidates +} + +// TestBuildWindowsSandboxSetupACLPlanCreatesTheRootsItGrants is the regression for +// the defect this PR shipped with: the plan named the runtime roots as AllowWrite +// targets and nothing created them. applyWindowsACLPlan materializes only DenyRead +// targets, so an absent AllowWrite target aborts the whole elevated setup with +// "windows ACL target does not exist". +func TestBuildWindowsSandboxSetupACLPlanCreatesTheRootsItGrants(t *testing.T) { + workspaceRoot, candidates := windowsRuntimeTestRoots(t) + for _, candidate := range candidates { + if err := os.RemoveAll(candidate); err != nil { + t.Fatalf("clear candidate %s: %v", candidate, err) + } + } + + config := WindowsSandboxSetupConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspaceRoot, + WorkspaceRoots: []string{workspaceRoot}, + // Augmented, because that is what the elevated helper parses off the wire: + // BuildWindowsSandboxSetupArgs folds the runtime roots in before the re-exec, + // so by the time runWindowsSandboxSetup builds this plan the roots are + // already write roots. Feeding a bare profile here would test a shape + // production never produces. + PermissionProfile: WindowsSandboxProfileWithRuntimeRoots( + PermissionProfileFromPolicy(workspaceRoot, DefaultPolicy(), nil), + []string{workspaceRoot}, + ), + } + plan, _, err := buildWindowsSandboxSetupACLPlan(config) + if err != nil { + t.Fatalf("buildWindowsSandboxSetupACLPlan: %v", err) + } + + granted := map[string]struct{}{} + for _, entry := range plan.Entries { + if entry.Action == WindowsACLAllowWrite { + granted[windowsCapabilityPathKey(entry.Path)] = struct{}{} + } + } + for _, candidate := range candidates { + if _, ok := granted[windowsCapabilityPathKey(candidate)]; !ok { + t.Fatalf("plan does not grant runtime root %s; the marker and the command plan will disagree", candidate) + } + info, err := os.Stat(candidate) + if err != nil { + t.Fatalf("runtime root %s is granted but absent: %v; applyWindowsACLPlan aborts the entire setup with \"windows ACL target does not exist\"", candidate, err) + } + if !info.IsDir() { + t.Fatalf("runtime root %s exists but is not a directory", candidate) + } + } +} + +// TestBuildWindowsSandboxSetupArgsCarriesEveryRuntimeCandidate closes the gap the +// reviewer named: every prior test fed BuildWindowsSandboxSetupArgs a profile the +// caller had already augmented, so deleting the augmentation inside the builder +// left the suite green. This one hands it a BARE profile and decodes the argument +// the elevated helper actually receives. +func TestBuildWindowsSandboxSetupArgsCarriesEveryRuntimeCandidate(t *testing.T) { + workspaceRoot, candidates := windowsRuntimeTestRoots(t) + bare := PermissionProfileFromPolicy(workspaceRoot, DefaultPolicy(), nil) + for _, root := range bare.FileSystem.WriteRoots { + for _, candidate := range candidates { + if windowsCapabilityPathKey(root.Root) == windowsCapabilityPathKey(candidate) { + t.Fatalf("the bare profile already contains runtime root %s, so this test could not detect the augmentation going missing", candidate) + } + } + } + + setupPlan, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + SandboxHome: t.TempDir(), + CommandCWD: workspaceRoot, + // Deliberately NOT pre-augmented: the builder folds the runtime roots in + // itself, and passing them here would hide it if that ever stopped. + PermissionProfile: bare, + WorkspaceRoots: []string{workspaceRoot}, + }) + if err != nil { + t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) + } + args := setupPlan.Args + + encoded := "" + for i, arg := range args { + if arg == "--permission-profile" && i+1 < len(args) { + encoded = args[i+1] + break + } + if strings.HasPrefix(arg, "--permission-profile=") { + encoded = strings.TrimPrefix(arg, "--permission-profile=") + break + } + } + if encoded == "" { + t.Fatalf("no --permission-profile argument in %v", args) + } + var decoded PermissionProfile + if err := json.Unmarshal([]byte(encoded), &decoded); err != nil { + t.Fatalf("decode --permission-profile: %v", err) + } + + present := map[string]struct{}{} + for _, root := range decoded.FileSystem.WriteRoots { + present[windowsCapabilityPathKey(root.Root)] = struct{}{} + } + for _, candidate := range candidates { + if _, ok := present[windowsCapabilityPathKey(candidate)]; !ok { + t.Fatalf("the setup args omit runtime root %s; setup would fingerprint a profile no command reproduces and every command would die on \"permission roots or deny lists changed\"", candidate) + } + } +} + +// TestSetupMarkerSurvivesADifferentTempInALaterProcess is the regression for the +// finding that the marker depended on the caller's transient TEMP. +// +// The sequence is the real one and the old code could not survive it: elevated +// setup runs from one terminal, and a later command is planned by a parent +// process an IDE or service started with a different TEMP. The cache runtime is +// untouched and healthy throughout. While both candidates were folded in +// unconditionally, the second process derived a different temp-side path, the +// plan hashes disagreed, and every command died on "permission roots or deny +// lists changed" with nothing wrong. +func TestSetupMarkerSurvivesADifferentTempInALaterProcess(t *testing.T) { + workspaceRoot := t.TempDir() + cacheRoot := t.TempDir() + originalCacheDir := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = originalCacheDir }) + + runtimeRootUnder := func(temp string) string { + t.Helper() + t.Setenv("TMP", temp) + t.Setenv("TEMP", temp) + roots := windowsSandboxRuntimeRoots(PermissionProfile{}, []string{workspaceRoot}) + if len(roots) != 1 { + t.Fatalf("expected one runtime root under TEMP=%s, got %v", temp, roots) + } + return roots[0] + } + + atSetup := runtimeRootUnder(t.TempDir()) + atLaterCommand := runtimeRootUnder(t.TempDir()) + + if atSetup != atLaterCommand { + t.Fatalf("the runtime root moved when only TEMP changed:\n setup %s\n later process %s\nboth feed the ACL plan the marker fingerprints, so every command would fail validation with \"permission roots or deny lists changed\" while the cache runtime sat there healthy", + atSetup, atLaterCommand) + } + + // Scope, stated rather than implied. This asserts the RUNTIME ROOT no longer + // tracks TEMP, which is the part this PR introduced and this change removes. + // The whole plan hash is still TEMP-dependent for a separate, older reason: + // PermissionProfileFromPolicy grants os.TempDir() itself as a write root when + // the policy allows temp, so the profile carries the caller's TEMP before any + // runtime augmentation happens. Asserting on the full hash here would fail for + // that pre-existing reason and read as though this fix were broken. + base := PermissionProfileFromPolicy(workspaceRoot, DefaultPolicy(), nil) + carriesAmbientTemp := false + for _, root := range base.FileSystem.WriteRoots { + if pathWithinRoot(canonicalSandboxWorkspaceRoot(os.TempDir()), canonicalSandboxWorkspaceRoot(root.Root)) { + carriesAmbientTemp = true + } + } + if !carriesAmbientTemp { + t.Log("the base profile no longer grants the ambient temp dir; the wider TEMP dependency may now be closed and this note can go") + } +} + +// TestRuntimeRootsPinToTheProfileTheCommandActuallyHolds covers the other half: +// once a profile carries a runtime, the plan names THAT tree and does not +// re-derive one. prepareSandboxRuntime relocates on a lease failure, so a +// re-derivation would name the tree the command is not writing to. +func TestRuntimeRootsPinToTheProfileTheCommandActuallyHolds(t *testing.T) { + workspaceRoot := t.TempDir() + cacheRoot := t.TempDir() + originalCacheDir := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = originalCacheDir }) + tempRoot := t.TempDir() + t.Setenv("TMP", tempRoot) + t.Setenv("TEMP", tempRoot) + + derived := windowsSandboxRuntimeRoots(PermissionProfile{}, []string{workspaceRoot}) + if len(derived) != 1 { + t.Fatalf("expected exactly one derived runtime root, got %v", derived) + } + + // A runtime the process actually selected, deliberately NOT the derived one, + // standing in for the lease-failure relocation. + relocated := filepath.Join(t.TempDir(), "relocated-runtime") + profile := PermissionProfile{Runtime: &SandboxRuntime{Root: relocated}} + + pinned := windowsSandboxRuntimeRoots(profile, []string{workspaceRoot}) + if len(pinned) != 1 || pinned[0] != relocated { + t.Fatalf("the plan did not pin to the runtime the profile holds:\n profile runtime %s\n plan named %v\nthe command would write to one tree while the plan grants another", relocated, pinned) + } + if pinned[0] == derived[0] { + t.Fatalf("pinned and derived roots are identical (%s), so this test cannot tell them apart", pinned[0]) + } +} + +// TestFallbackSandboxRuntimeRootIsSpellingStable pins the canonicalization added +// for the aliased-TEMP finding. Two spellings of one directory must produce one +// runtime root; producing two is what let a root resolving inside the workspace +// pass the containment check. +func TestFallbackSandboxRuntimeRootIsSpellingStable(t *testing.T) { + workspaceRoot := t.TempDir() + tempRoot := t.TempDir() + + // An UPPER-CASED spelling is the one alias available without privilege or a + // volume setting: 8.3 generation is disabled on many volumes and creating a + // symlink needs a privilege the test process may not hold, while a + // case-insensitive filesystem resolves this to the same directory and + // GetLongPathName returns the on-disk casing. + alias := strings.ToUpper(tempRoot) + if alias == tempRoot { + t.Skip("the temp path has no distinct upper-cased spelling here") + } + + // Decide whether the alias is usable by asking the filesystem, never by asking + // canonicalSandboxWorkspaceRoot. Skipping on what the function under test says + // would turn a canonicalization regression into a silent skip on the one + // platform this test exists to protect. + realInfo, err := os.Stat(tempRoot) + if err != nil { + t.Fatalf("stat temp dir: %v", err) + } + aliasInfo, err := os.Stat(alias) + if err != nil || !os.SameFile(realInfo, aliasInfo) { + t.Skip("case-sensitive filesystem: the upper-cased spelling is a different directory") + } + + // Case folding is a Windows property of canonicalSandboxWorkspaceRoot, not a + // cross-platform one: it folds case only because filepath.EvalSymlinks returns + // the on-disk spelling there. On a case-insensitive macOS volume os.SameFile + // calls these one directory and canonicalization still keeps them apart, which + // is a real gap but not one this branch claims to close, and it cannot produce + // the setup-versus-command disagreement fixed here because the elevated setup + // marker is Windows-only. Gate on the platform, never on what the function + // under test returns. + if runtime.GOOS != "windows" { + t.Skip("canonicalization folds case only on windows; nothing to assert here") + } + + // One directory under two spellings. Canonicalization has to fold them, and a + // failure here is the regression, not a reason to stop testing. + canonical := canonicalSandboxWorkspaceRoot(tempRoot) + if got := canonicalSandboxWorkspaceRoot(alias); got != canonical { + t.Fatalf("canonicalization did not fold two spellings of one directory:\n %s\n -> %s\n %s\n -> %s\nos.SameFile says these are the same directory, so the runtime roots derived from them will disagree", + tempRoot, canonical, alias, got) + } + + t.Setenv("TMP", tempRoot) + t.Setenv("TEMP", tempRoot) + viaReal, err := fallbackSandboxRuntimeRoot(workspaceRoot) + if err != nil { + t.Fatalf("fallbackSandboxRuntimeRoot(real spelling): %v", err) + } + + t.Setenv("TMP", alias) + t.Setenv("TEMP", alias) + viaAlias, err := fallbackSandboxRuntimeRoot(workspaceRoot) + if err != nil { + t.Fatalf("fallbackSandboxRuntimeRoot(alias): %v", err) + } + + if viaReal != viaAlias { + t.Fatalf("two spellings of ONE temp directory produced two runtime roots:\n via %s\n -> %s\n via %s\n -> %s\nsetup and the command side derive from the same function, so they would grant and expect different paths, and pathWithinRoot would measure the workspace against a spelling it does not match", tempRoot, viaReal, alias, viaAlias) + } +} + +// TestWindowsSandboxRuntimeCandidatesUsesOneWorkspaceRoot pins the single-root +// contract rather than treating it as a defect. The marker compares plan hashes +// for EQUALITY and a command presents exactly one root, so deriving candidates for +// every root would put entries in the marker that no command reproduces. Whoever +// widens this has to change the marker comparison in the same change. +func TestWindowsSandboxRuntimeCandidatesUsesOneWorkspaceRoot(t *testing.T) { + first := t.TempDir() + second := t.TempDir() + + // Owned cache and temp even though this test only reads: the derivation would + // otherwise depend on the developer's real cache directory, which makes the + // result environment-dependent as well as impolite. + cacheRoot := t.TempDir() + originalCacheDir := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = originalCacheDir }) + tempRoot := t.TempDir() + t.Setenv("TMP", tempRoot) + t.Setenv("TEMP", tempRoot) + + combined := windowsSandboxRuntimeRoots(PermissionProfile{}, []string{first, second}) + alone := windowsSandboxRuntimeRoots(PermissionProfile{}, []string{first}) + if len(combined) == 0 { + t.Skip("no runtime candidates derivable in this environment") + } + separator := string(filepath.ListSeparator) + if strings.Join(combined, separator) != strings.Join(alone, separator) { + t.Fatalf("a second workspace root changed the candidate set:\n [first, second] %v\n [first] %v\nsetup would grant roots no single command reproduces", combined, alone) + } +} diff --git a/internal/sandbox/windows_setup_rollback_completeness_test.go b/internal/sandbox/windows_setup_rollback_completeness_test.go new file mode 100644 index 000000000..2705411d8 --- /dev/null +++ b/internal/sandbox/windows_setup_rollback_completeness_test.go @@ -0,0 +1,180 @@ +package sandbox + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// A FAILED SETUP MUST LEAVE NOTHING IT CREATED. +// +// The stamp goes inside the runtime root and is written before the marker file +// is renamed into place, so any failure after that point left the root holding a +// file this run had written. The directory removal refuses a non-empty directory +// on purpose, to avoid turning a failed setup into data loss, and the two +// combined meant the residue could never be cleaned up: a failed run kept the +// persistent runtime tree it had just created, permanently. +func TestRollbackRemovesTheStampItWroteAndThenTheRoot(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "zero", "runtime", "v1", "abcd") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime root: %v", err) + } + + // Snapshot BEFORE the stamp exists, which is the fresh-setup case. + snapshot, err := snapshotWindowsSandboxRuntimeStamp(root) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if err := writeWindowsSandboxRuntimeStamp(root, "planhash"); err != nil { + t.Fatalf("writeWindowsSandboxRuntimeStamp: %v", err) + } + + rollback := windowsRuntimeRootRollback{ + created: createdRuntimeDirsForTest( + filepath.Join(parent, "zero"), + filepath.Join(parent, "zero", "runtime"), + filepath.Join(parent, "zero", "runtime", "v1"), + root, + ), + stamp: snapshot, + } + if err := rollback.run(); err != nil { + t.Fatalf("rollback.run: %v", err) + } + if _, err := os.Stat(filepath.Join(parent, "zero")); !os.IsNotExist(err) { + t.Errorf("the failed setup kept the runtime tree it created (stat err %v)", err) + } +} + +// And a stamp that was already there is RESTORED, not deleted. +// +// Deleting it would leave a machine whose previous setup succeeded with a valid +// marker pointing at a tree with no stamp, which reads as "the runtime directory +// was removed since setup ran". A healthy machine would start reporting itself +// broken because an unrelated later setup failed. +func TestRollbackRestoresAPreviousSetupsStamp(t *testing.T) { + root := filepath.Join(t.TempDir(), "runtime") + if err := writeWindowsSandboxRuntimeStamp(root, "the-previous-setup"); err != nil { + t.Fatalf("writeWindowsSandboxRuntimeStamp: %v", err) + } + + snapshot, err := snapshotWindowsSandboxRuntimeStamp(root) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if err := writeWindowsSandboxRuntimeStamp(root, "this-run"); err != nil { + t.Fatalf("writeWindowsSandboxRuntimeStamp: %v", err) + } + + // created is empty: this run found the root already there and owns none of it. + if err := (windowsRuntimeRootRollback{stamp: snapshot}).run(); err != nil { + t.Fatalf("rollback.run: %v", err) + } + + restored, err := os.ReadFile(windowsSandboxRuntimeStampPath(root)) + if err != nil { + t.Fatalf("the previous setup's stamp is gone: %v", err) + } + if string(restored) != "the-previous-setup" { + t.Errorf("the stamp is %q, want the previous setup's %q", restored, "the-previous-setup") + } +} + +// Pre-existing content is never removed, whatever else the rollback does. +func TestRollbackRefusesToRemoveWhatItDidNotCreate(t *testing.T) { + root := t.TempDir() + theirs := filepath.Join(root, "somebody-elses-file") + if err := os.WriteFile(theirs, []byte("keep me"), 0o600); err != nil { + t.Fatalf("seed the pre-existing file: %v", err) + } + + if err := (windowsRuntimeRootRollback{created: createdRuntimeDirsForTest(root)}).run(); err == nil { + t.Error("a non-empty directory was removed without complaint") + } + if _, err := os.Stat(theirs); err != nil { + t.Errorf("pre-existing content was destroyed by rollback: %v", err) + } +} + +// One broken compensation must not strand the others. The stamp restore is +// attempted first, and a failure there has to be reported without stopping the +// directory removal. +func TestRollbackContinuesAfterACompensationFails(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "zero", "runtime") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime root: %v", err) + } + + // The failure is a root that is no longer the directory this run stamped, + // which is a shape production actually produces. + // + // It used to be a snapshot pointing at an unwritable stamp path. That stopped + // meaning anything once compensation began deriving the stamp from the verified + // root handle rather than the recorded path: the restore then wrote a stamp INTO + // the directory the rollback goes on to remove, so the removal failed for a + // reason this test is not about. It passed on an unelevated box and failed on + // every CI runner, because whether that recreate succeeds depends on the token. + stamp := windowsSandboxStampSnapshot{ + path: windowsSandboxRuntimeStampPath(root), + prior: []byte("x"), + priorState: runtimeStampPresent, + root: root, + rootIdentity: "0:0:0", + rootIdentified: true, + } + rollback := windowsRuntimeRootRollback{ + created: createdRuntimeDirsForTest(filepath.Join(parent, "zero"), root), + stamp: stamp, + } + + err := rollback.run() + if err == nil { + t.Fatal("the failed stamp restore was not reported") + } + if _, statErr := os.Stat(filepath.Join(parent, "zero")); !os.IsNotExist(statErr) { + t.Errorf("the directory removal was skipped because the stamp restore failed (stat err %v); every compensation has to run", statErr) + } +} + +// A FAILING ACL ROLLBACK MUST NOT STRAND THE RUNTIME ROLLBACK. +// +// The two undos used to compose by calling each other, and the outer one +// returned as soon as the ACL rollback reported an error. The runtime rollback +// then never ran, so the failure most likely to leave a machine in a strange +// state was the one failure that skipped half the cleanup. +// +// Composed through a function with no build tag on purpose: the setup entry +// point is Windows-only and needs Administrator plus WFP to reach, so a test +// there would run on nobody's machine and prove nothing on CI. +func TestEveryCompensationRunsWhenTheACLRollbackFails(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "zero", "runtime") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime root: %v", err) + } + + aclCalled := false + err := runWindowsSandboxSetupCompensations( + errors.New("the setup failure"), + func() error { aclCalled = true; return errors.New("acl restore exploded") }, + windowsRuntimeRootRollback{created: createdRuntimeDirsForTest(filepath.Join(parent, "zero"), root)}, + ) + if !aclCalled { + t.Fatal("the ACL rollback was never attempted") + } + if err == nil { + t.Fatal("the failures were not reported") + } + for _, want := range []string{"the setup failure", "acl restore exploded"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the report drops %q: %v", want, err) + } + } + if _, statErr := os.Stat(filepath.Join(parent, "zero")); !os.IsNotExist(statErr) { + t.Errorf("the runtime rollback was skipped because the ACL rollback failed (stat err %v)", statErr) + } +} diff --git a/internal/sandbox/windows_setup_runtime_compensation_test.go b/internal/sandbox/windows_setup_runtime_compensation_test.go new file mode 100644 index 000000000..be6137278 --- /dev/null +++ b/internal/sandbox/windows_setup_runtime_compensation_test.go @@ -0,0 +1,208 @@ +package sandbox + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// setupCompensationFixture points the cache root at a test-owned directory and +// returns it with the runtime root the selector will choose. +func setupCompensationFixture(t *testing.T) (cacheRoot string, workspace string, root string) { + t.Helper() + cacheRoot = t.TempDir() + workspace = t.TempDir() + previous := sandboxUserCacheDir + t.Cleanup(func() { sandboxUserCacheDir = previous }) + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + + var ok bool + root, ok = deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot)) + if !ok { + t.Fatalf("SETUP INVALID: no deterministic runtime root under %s", cacheRoot) + } + return cacheRoot, workspace, root +} + +func buildSetupPlan(t *testing.T, workspace string) (WindowsSandboxSetupPlan, error) { + t.Helper() + return BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + CommandCWD: workspace, + SandboxHome: t.TempDir(), + }) +} + +// ownedRuntimeTreeExists reports whether the first component Zero owns is still +// there, which is the thing a rollback has to have removed. +func ownedRuntimeTreeExists(cacheRoot string) bool { + _, err := os.Lstat(filepath.Join(canonicalSandboxWorkspaceRoot(cacheRoot), "zero")) + return err == nil +} + +// BUILDING THE ARGS IS ALREADY A TRANSACTION. +// +// Selecting the runtime root takes a lease, and taking a lease creates +// zero/runtime/v1 and the lease file when they are not there. Those writes happen +// in this process before the helper exists, and the wrapper that produced them +// dropped the record: the helper reacquires an already-existing tree and records +// no creation, provisioning records only the leaf, so a failure before the marker +// left the parents behind with nothing that knew it had made them. +func TestSetupArgsRollbackRemovesWhatTheSelectionCreated(t *testing.T) { + cacheRoot, workspace, root := setupCompensationFixture(t) + + // SETUP: nothing of ours is there yet, or the rollback would be removing + // somebody else's tree and the assertion below would be about the wrong thing. + if ownedRuntimeTreeExists(cacheRoot) { + t.Fatal("SETUP INVALID: the owned tree already exists before the selection") + } + + plan, err := buildSetupPlan(t, workspace) + if err != nil { + t.Skipf("cannot build setup args here: %v", err) + } + if !ownedRuntimeTreeExists(cacheRoot) { + t.Fatal("SETUP INVALID: building the args created no owned tree, so there is nothing for rollback to undo") + } + if _, err := os.Lstat(sandboxRuntimeLeasePath(root)); err != nil { + t.Fatalf("SETUP INVALID: no lease file was created, so the artifact under test is missing: %v", err) + } + + if err := plan.Rollback(); err != nil { + t.Fatalf("rollback reported residue on a tree it had just created alone: %v", err) + } + if ownedRuntimeTreeExists(cacheRoot) { + t.Error("the owned runtime tree survived a rollback of the invocation that created it") + } + if _, err := os.Lstat(sandboxRuntimeLeasePath(root)); err == nil { + t.Error("the lease file survived; it keeps v1 non-empty, so the tree can never be removed") + } +} + +// A FAILURE AFTER THE TREE EXISTS COMPENSATES BEFORE IT RETURNS. +// +// This is the interval the whole finding is about. The selection has already +// created zero/runtime/v1 and the lease file, the helper does not exist yet, and +// the builder can still fail. The old shape returned that error and left the +// tree, with nothing downstream holding a record that this invocation had made +// it: the helper reacquires an existing tree and records no creation, and +// provisioning records only the leaf. +func TestSetupArgsCompensateWhenTheyFailAfterCreatingTheTree(t *testing.T) { + cacheRoot, workspace, root := setupCompensationFixture(t) + + previousSID := setupConsumerSID + t.Cleanup(func() { setupConsumerSID = previousSID }) + asked := false + setupConsumerSID = func() (string, error) { + asked = true + // Anything that fails after the selection. The identity lookup is the only + // step in this interval that can. + return "", errors.New("cannot read this process's identity") + } + + _, err := buildSetupPlan(t, workspace) + + // SETUP: the failure really landed after the selection, or this is measuring + // an error path that never reached the state under test. + if !asked { + t.Skip("the builder failed before the identity lookup, so the tree was never created here") + } + if err == nil { + t.Fatal("the builder returned args although the step after the selection failed") + } + + if ownedRuntimeTreeExists(cacheRoot) { + t.Errorf("a failed build left its owned runtime tree behind at %s: %v", cacheRoot, err) + } + if _, statErr := os.Lstat(sandboxRuntimeLeasePath(root)); statErr == nil { + t.Error("a failed build left its lease file behind, which keeps v1 non-empty forever") + } +} + +// A PRE-EXISTING TREE IS NOT THIS INVOCATION'S TO REMOVE. +// +// Without this, a rollback that deleted the runtime root unconditionally would +// satisfy every test above, and would destroy a tree another setup had already +// provisioned and published a marker for. +func TestSetupArgsRollbackLeavesAPreExistingTreeAlone(t *testing.T) { + cacheRoot, workspace, root := setupCompensationFixture(t) + + // Somebody else's tree, complete with a stamp-shaped file, made before this + // invocation runs. + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + keep := filepath.Join(filepath.Dir(root), "someone-elses.txt") + if err := os.WriteFile(keep, []byte("not ours\n"), 0o600); err != nil { + t.Fatal(err) + } + // AND A LEASE FILE THAT WAS ALREADY THERE. Removing one this invocation did + // not create takes the coordination object out from under whoever made it, + // which is why createdness is recorded by the create rather than guessed at + // afterwards. + preExistingLease := sandboxRuntimeLeasePath(root) + if err := os.WriteFile(preExistingLease, nil, 0o600); err != nil { + t.Fatal(err) + } + + plan, err := buildSetupPlan(t, workspace) + if err != nil { + t.Skipf("cannot build setup args here: %v", err) + } + // Rollback may report residue, because it refuses a non-empty directory on + // purpose. What it must not do is remove any of it. + _ = plan.Rollback() + + if !ownedRuntimeTreeExists(cacheRoot) { + t.Fatal("rollback removed an owned tree this invocation did not create") + } + if _, err := os.Lstat(keep); err != nil { + t.Fatalf("rollback removed a file that was there before this invocation: %v", err) + } + if _, err := os.Lstat(root); err != nil { + t.Fatalf("rollback removed a pre-existing runtime root: %v", err) + } + if _, err := os.Lstat(preExistingLease); err != nil { + t.Fatalf("rollback removed a lease file that was there before this invocation: %v", err) + } +} + +// AND A ROLLBACK THAT CANNOT FINISH SAYS SO. +// +// Reporting a clean undo while a tree is still on disk is worse than reporting +// the residue: the operator retries setup against state they were told was gone. +// A holder on the lease is the case that matters, because it also means the tree +// must NOT be removed while somebody is using it. +func TestSetupArgsRollbackReportsResidueWhileTheLeaseIsHeld(t *testing.T) { + cacheRoot, workspace, root := setupCompensationFixture(t) + + plan, err := buildSetupPlan(t, workspace) + if err != nil { + t.Skipf("cannot build setup args here: %v", err) + } + if !ownedRuntimeTreeExists(cacheRoot) { + t.Fatal("SETUP INVALID: building the args created no owned tree") + } + + // Another process's grip on the same runtime root. + holder, _, err := prepareSandboxRuntimeLeaseRecording(root) + if err != nil { + t.Skipf("cannot take a second lease here: %v", err) + } + defer holder.release() + + undo := plan.Rollback() + if undo == nil { + t.Fatal("rollback claimed a clean undo while another holder had the lease; it either removed a tree in use or reported success for work it did not do") + } + if !strings.Contains(undo.Error(), "in use") { + t.Errorf("the residue report does not say why it stopped: %v", undo) + } + if !ownedRuntimeTreeExists(cacheRoot) { + t.Error("rollback removed the runtime tree while another process held its lease") + } + if _, err := os.Lstat(sandboxRuntimeLeasePath(root)); err != nil { + t.Errorf("rollback removed a lease another process was holding: %v", err) + } +} diff --git a/internal/sandbox/windows_setup_runtime_root_test.go b/internal/sandbox/windows_setup_runtime_root_test.go new file mode 100644 index 000000000..c36ad0aa6 --- /dev/null +++ b/internal/sandbox/windows_setup_runtime_root_test.go @@ -0,0 +1,244 @@ +package sandbox + +import ( + "os" + "strings" + "testing" +) + +// runtimeRootTestConfig is the shape every command reaches the Windows runner +// with: a restricted filesystem rooted at the workspace, which is what makes the +// runtime root necessary in the first place. Its derivation lands entirely +// inside test-owned storage. +// +// EVERY DERIVATION INPUT IS REDIRECTED BEFORE ANY CANDIDATE IS COMPUTED, which +// is the whole point of routing through windowsRuntimeTestRoots rather than +// taking a bare t.TempDir(). Without it, windowsSandboxRuntimeRoots reads the +// real user cache: tests below then create and RemoveAll a genuine +// ~/.cache/zero/runtime/... path, deleting the developer's own runtime tree on +// every run, and failing outright on a read-only home before they ever reach an +// assertion. The fixture also refuses to run at all if a candidate escapes the +// owned roots, so a later change to the derivation cannot quietly reintroduce +// this. +func runtimeRootTestConfig(t *testing.T) WindowsSandboxCommandConfig { + t.Helper() + workspace, _ := windowsRuntimeTestRoots(t) + return WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + } +} + +// A marker written by a fresh setup must accept the ordinary command, and the +// ordinary command is the RUNTIME-AUGMENTED one: Engine.run calls +// permissionProfileWithRuntime before the Windows runner ever sees the profile, +// so the profile presented at validation always carries the selected runtime +// root as an extra write root. +// +// Setup used to fingerprint the bare profile. The extra write root changed the +// ACL plan, the plan hash changed with it, and validation rejected a marker +// written seconds earlier with "permission roots or deny lists changed" — so on +// a restricted filesystem no command could run at all, including the very +// command that had just been set up for. +// +// Asserted for BOTH candidates because which one a process selects is not fixed: +// sandboxRuntimeRootFor prefers the cache-derived root and falls back to the +// temp-derived one, and a marker that only accepts the preferred root bricks +// every machine that falls back. +func TestWindowsSandboxSetupMarkerAcceptsRuntimeAugmentedCommand(t *testing.T) { + config := runtimeRootTestConfig(t) + // The setup half, as BuildWindowsSandboxSetupArgs prepares it in the + // operator's shell before the elevated helper ever runs. + // Setup SELECTS, exactly as BuildWindowsSandboxSetupArgs does in the operator + // shell, so the tree it provisions is the tree a command will choose. This + // used to fingerprint a merely-derived root and then accept EITHER candidate + // at validation, which was compensating for a disagreement rather than + // removing it: whichever root the command selected, only one of them had ever + // been provisioned or carried the capability ACE. + selected, lease, _, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true, "") + if err != nil { + t.Fatalf("selectSandboxRuntimeRoot: %v", err) + } + lease.release() + + setup := WindowsSandboxSetupConfigFromCommand(config) + setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(setup.PermissionProfile, SandboxRuntime{Root: selected}), + config.WorkspaceRoots, + ) + if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + + // The command half, in the same order the real path builds it: the engine + // appends the SELECTED root, then the Windows plan folds in the candidate set + // before serializing the profile to the runner. + augmented := config + augmented.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(config.PermissionProfile, SandboxRuntime{Root: selected}), + config.WorkspaceRoots, + ) + if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(augmented)); err != nil { + t.Fatalf("ValidateWindowsSandboxSetupMarker with the selected runtime root %s: %v", selected, err) + } + + // THE RUNNER'S TEMP IS NOT THE OPERATOR'S. + // + // sandboxRuntimeEnvironment points TMPDIR/TMP/TEMP at the sandbox runtime + // temp for everything the sandbox launches, and the command runner inherits + // that env. While the runner derived the candidate set itself, os.TempDir() + // there returned the redirected value, so it produced a temp-derived root + // under the runtime tree while setup produced one under the real temp: two + // plans with the SAME entry count and different hashes, and every sandboxed + // command refused to run with "permission roots or deny lists changed". + // + // Validation must not move when that variable does. + // Augmented FIRST, standing in for the parent, whose TEMP is still real. + runner := config + runner.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(config.PermissionProfile, SandboxRuntime{Root: selected}), + config.WorkspaceRoots, + ) + // Only THEN does the environment become the runner's. Anything downstream of + // this line that re-derives a runtime root gets the redirected answer, which + // is precisely the defect: validation has to be settled before here. + t.Setenv("TEMP", t.TempDir()) + t.Setenv("TMP", os.Getenv("TEMP")) + if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(runner)); err != nil { + t.Fatalf("ValidateWindowsSandboxSetupMarker with a redirected TEMP: %v", err) + } + + // The guard has to still bite, or the test above passes for the wrong reason + // — a validator that accepts everything would satisfy it too. + changed := config + changed.PermissionProfile.FileSystem.DenyRead = []string{`C:\workspace\secret`} + if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(changed)); err == nil { + t.Fatal("ValidateWindowsSandboxSetupMarker accepted a changed deny list, so it no longer detects drift") + } else if !strings.Contains(err.Error(), "out of date") { + t.Fatalf("ValidateWindowsSandboxSetupMarker changed error = %v, want out of date", err) + } +} + +// The runtime root needs BOTH sides of the write-restricted grant. +// +// A principal command runs on a token restricted to the capability SIDs, and a +// WRITE_RESTRICTED token grants a write only when the normal token check AND the +// restricting-SID check both pass. The runtime root used to be appended to the +// principal plan alone, so it carried the account ACE and no capability ACE: the +// normal check passed, the restricted check found nothing, and every cache and +// temp write was denied even once the marker agreed. +// +// This asserts the capability side, which is the side that was missing. Both +// candidates again, for the same reason as above. +func TestWindowsSandboxRuntimeRootsAreInTheCapabilityPlan(t *testing.T) { + config := runtimeRootTestConfig(t) + candidates := windowsSandboxRuntimeRoots(PermissionProfile{}, config.WorkspaceRoots) + if len(candidates) == 0 { + t.Fatal("windowsSandboxRuntimeCandidates returned none, so this test proves nothing") + } + + setup := WindowsSandboxSetupConfigFromCommand(config) + setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(setup.PermissionProfile, config.WorkspaceRoots) + plan, err := BuildWindowsACLPlan(setup.commandConfig()) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + granted := make(map[string]struct{}, len(plan.Entries)) + for _, entry := range plan.Entries { + granted[windowsCapabilityPathKey(entry.Path)] = struct{}{} + } + for _, candidate := range candidates { + if _, ok := granted[windowsCapabilityPathKey(candidate)]; !ok { + t.Fatalf("capability ACL plan has no entry for runtime root %s; writes there fail the restricting-SID check", candidate) + } + } +} + +// EVERY write root the capability plan grants must exist by the time setup +// applies it. +// +// The capability plan deliberately refuses to materialize a write root, so a +// granted path that is merely absent fails the entire setup run with +// +// windows ACL target does not exist: ...\zero\runtime\v1\ +// +// which is exactly what an elevated run hit: the runtime candidates were added +// to the plan while only the selected one was ever created, and `zero sandbox +// setup` stopped working altogether. The two halves are separate functions, so +// nothing but this test stops one from growing a candidate without the other. +func TestWindowsSandboxSetupProvisionsEveryGrantedWriteRoot(t *testing.T) { + config := runtimeRootTestConfig(t) + candidates := windowsSandboxRuntimeRoots(PermissionProfile{}, config.WorkspaceRoots) + if len(candidates) == 0 { + t.Fatal("windowsSandboxRuntimeCandidates returned none, so this test proves nothing") + } + // The workspace is unique per run, and the roots are derived from it, so these + // cannot pre-exist. Assert that rather than trust it: if they did, the test + // would pass with the provisioning step deleted. + for _, candidate := range candidates { + if _, err := os.Stat(candidate); err == nil { + t.Fatalf("runtime root %s already exists before provisioning, so this test proves nothing", candidate) + } + t.Cleanup(func() { _ = os.RemoveAll(candidate) }) + } + + if _, err := ensureWindowsSandboxRuntimeRoots(PermissionProfile{}, config.WorkspaceRoots); err != nil { + t.Fatalf("ensureWindowsSandboxRuntimeCandidates: %v", err) + } + + setup := WindowsSandboxSetupConfigFromCommand(config) + setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(setup.PermissionProfile, config.WorkspaceRoots) + plan, err := BuildWindowsACLPlan(setup.commandConfig()) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + for _, entry := range plan.Entries { + if entry.Action != WindowsACLAllowWrite || entry.Materialize { + continue + } + if _, err := os.Stat(entry.Path); err != nil { + t.Errorf("capability plan grants write on %s but nothing created it, so setup fails with "+ + "\"windows ACL target does not exist\": %v", entry.Path, err) + } + } +} + +// Both candidates are pure functions of the workspace root. Setup provisions the +// set and a later command selects from it in a different process, so a candidate +// that varied per process (a random or time-seeded fallback) would be granted by +// setup and never selected, or selected and never granted. +func TestWindowsSandboxRuntimeCandidatesAreDeterministic(t *testing.T) { + config := runtimeRootTestConfig(t) + first := windowsSandboxRuntimeRoots(PermissionProfile{}, config.WorkspaceRoots) + if len(first) == 0 { + t.Fatal("windowsSandboxRuntimeCandidates returned none, so this test proves nothing") + } + second := windowsSandboxRuntimeRoots(PermissionProfile{}, config.WorkspaceRoots) + if len(first) != len(second) { + t.Fatalf("candidate count = %d then %d, want stable", len(first), len(second)) + } + for i := range first { + if first[i] != second[i] { + t.Fatalf("candidate %d = %q then %q, want stable", i, first[i], second[i]) + } + } + + other := runtimeRootTestConfig(t) + otherCandidates := windowsSandboxRuntimeRoots(PermissionProfile{}, other.WorkspaceRoots) + for _, candidate := range otherCandidates { + for _, mine := range first { + if candidate == mine { + t.Fatalf("workspaces %s and %s share runtime root %s, so one workspace's grant covers the other", + config.CommandCWD, other.CommandCWD, candidate) + } + } + } +} diff --git a/internal/sandbox/windows_setup_runtime_selection_test.go b/internal/sandbox/windows_setup_runtime_selection_test.go new file mode 100644 index 000000000..ca7eb4990 --- /dev/null +++ b/internal/sandbox/windows_setup_runtime_selection_test.go @@ -0,0 +1,69 @@ +package sandbox + +import ( + "errors" + "strings" + "testing" +) + +// SETUP MUST NOT PERSIST STATE IT CANNOT ATTEST. +// +// The runtime-root selection used to be best effort here: if it failed, setup +// carried on and wrote a marker with no runtime root in it, having already +// applied the capability ACLs to a derived tree. A later command makes its own +// concrete selection, finds no stamp on what it picked and refuses, and +// re-running setup takes the same branch and records nothing again. That is the +// permanent brick the recorded-root contract exists to prevent, reached through +// the one path allowed to skip it. +func TestSetupArgsFailWhenNoRuntimeRootCanBeSelected(t *testing.T) { + original := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return "", errors.New("no cache directory on this machine") } + t.Cleanup(func() { sandboxUserCacheDir = original }) + + setupPlan, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + CommandCWD: t.TempDir(), + SandboxHome: t.TempDir(), + }) + if err == nil { + t.Fatalf("setup args were built without a runtime root; the marker they produce attests nothing: %v", setupPlan.Args) + } + if !strings.Contains(err.Error(), "runtime root") { + t.Errorf("the failure does not name the step that failed: %v", err) + } +} + +// And the ordinary path still records the concrete root it selected, so the +// test above is failing on the selection and not on some unrelated argument. +func TestSetupArgsRecordTheSelectedRuntimeRoot(t *testing.T) { + workspace := t.TempDir() + tempHome := t.TempDir() + t.Setenv("TMPDIR", tempHome) + t.Setenv("TMP", tempHome) + t.Setenv("TEMP", tempHome) + + cacheRoot := t.TempDir() + original := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = original }) + + setupPlan, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + CommandCWD: workspace, + SandboxHome: t.TempDir(), + }) + if err != nil { + t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) + } + args := setupPlan.Args + profile := "" + for index, arg := range args { + if arg == "--permission-profile" && index+1 < len(args) { + profile = args[index+1] + } + } + if profile == "" { + t.Fatalf("no permission profile in the setup args: %v", args) + } + if !strings.Contains(profile, "\"runtime\"") { + t.Errorf("the setup profile records no runtime root, so the marker cannot attest one: %s", profile) + } +} diff --git a/internal/sandbox/windows_setup_test.go b/internal/sandbox/windows_setup_test.go index 0a3c6f044..e268e9764 100644 --- a/internal/sandbox/windows_setup_test.go +++ b/internal/sandbox/windows_setup_test.go @@ -11,7 +11,7 @@ import ( func TestBuildAndParseWindowsSandboxSetupArgs(t *testing.T) { home := t.TempDir() - args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + setupPlan, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ SandboxHome: home, CommandCWD: `C:\workspace\src`, WorkspaceRoots: []string{`C:\workspace`}, @@ -29,6 +29,7 @@ func TestBuildAndParseWindowsSandboxSetupArgs(t *testing.T) { if err != nil { t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) } + args := setupPlan.Args config, err := ParseWindowsSandboxSetupArgs(args) if err != nil { t.Fatalf("ParseWindowsSandboxSetupArgs: %v", err) diff --git a/internal/sandbox/windows_setup_unidentified_compensation_test.go b/internal/sandbox/windows_setup_unidentified_compensation_test.go new file mode 100644 index 000000000..947d0aa89 --- /dev/null +++ b/internal/sandbox/windows_setup_unidentified_compensation_test.go @@ -0,0 +1,95 @@ +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// AN IDENTITY THAT WAS NEVER ESTABLISHED IS NOT PERMISSION TO MUTATE. +// +// Both capture sites discarded runtimeDirIdentity's success flag, so a root that +// could not be opened when setup began was indistinguishable from one with no +// identity, and both mutation sites read the empty string as "skip the check". +// Compensation then wrote to, or removed from, whatever answered to the pathname +// afterwards. That is elevated compensation reaching an object this run cannot +// prove it touched. +func TestStampCompensationRefusesAnUnidentifiedRoot(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "zero", "runtime", "v1", "abcd") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime root: %v", err) + } + stamp := windowsSandboxRuntimeStampPath(root) + const foreign = "belongs to whoever put it here" + if err := os.WriteFile(stamp, []byte(foreign), 0o600); err != nil { + t.Fatalf("seed the stamp: %v", err) + } + + // The shape the old capture produced when runtimeDirIdentity failed. + snapshot := windowsSandboxStampSnapshot{ + path: stamp, + prior: []byte("this run's stamp"), + priorState: runtimeStampPresent, + root: root, + rootIdentified: false, + } + + err := snapshot.restore() + if err == nil { + t.Fatal("compensation proceeded with an identity it never established") + } + if !strings.Contains(err.Error(), "could not be identified") { + t.Errorf("the refusal does not say why: %v", err) + } + + // The object is untouched, which is the half that matters. + after, readErr := os.ReadFile(stamp) + if readErr != nil { + t.Fatalf("read the stamp back: %v", readErr) + } + if string(after) != foreign { + t.Errorf("compensation rewrote a stamp it could not prove was this run's: %q", string(after)) + } +} + +// The same rule for the directory removal, where the mutation is a delete. +func TestDirectoryCompensationRefusesAnUnidentifiedDirectory(t *testing.T) { + parent := t.TempDir() + created := filepath.Join(parent, "zero") + if err := os.MkdirAll(created, 0o700); err != nil { + t.Fatalf("create the directory: %v", err) + } + + rollback := windowsRuntimeRootRollback{ + created: []windowsCreatedRuntimeDir{{path: created, identified: false}}, + } + err := rollback.run() + if err == nil { + t.Fatal("an unidentified directory was removed by pathname") + } + if !strings.Contains(err.Error(), "could not be identified") { + t.Errorf("the refusal does not say why: %v", err) + } + if _, statErr := os.Stat(created); statErr != nil { + t.Errorf("the directory was removed despite the unproven identity: %v", statErr) + } +} + +// And a root that is simply ABSENT is not the same case: there is no object to +// confuse, nothing of a previous run to put back, and the created-directory +// rollback owns the cleanup. Refusing here would make every fresh setup report a +// compensation failure. +func TestStampCompensationStaysQuietWhenTheRootIsAbsent(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "zero", "runtime", "v1", "abcd") + snapshot := windowsSandboxStampSnapshot{ + path: windowsSandboxRuntimeStampPath(root), + root: root, + rootIdentified: false, + } + if err := snapshot.restore(); err != nil { + t.Fatalf("an absent root was treated as a compensation failure: %v", err) + } +} diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 888355397..9857d993a 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -5,52 +5,174 @@ package sandbox import ( "fmt" "io" + "strings" "golang.org/x/sys/windows" ) +// Seams for the identity gate above. The gate refuses a setup whose carried +// consumer is not the account doing the installing, and both halves of that are +// otherwise unobservable from a test: an unelevated box never reaches the gate, +// and one process cannot hold two user SIDs. Production values by default. +var ( + windowsSetupProcessIsElevated = windowsProcessIsElevated + windowsSetupInstallerSID = currentProcessSID +) + func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) int { // Applying the WFP network filters and workspace ACLs requires Administrator // rights; without them WFP fails deep inside with a raw ACCESS_DENIED (0x5). // Check up front and return an actionable message instead. - if !windowsProcessIsElevated() { + if !windowsSetupProcessIsElevated() { fmt.Fprintln(stderr, WindowsSandboxSetupName+": Administrator rights are required. Re-run `zero sandbox setup` from an elevated (Run as administrator) terminal.") return 1 } - plan, err := BuildWindowsACLPlan(config.commandConfig()) - if err != nil { - fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) + // THE CONSUMER IS CARRIED, AND VALIDATED BEFORE ANYTHING IS PROVISIONED. + // + // Zero does not elevate. runSandboxSetupHelper launches this with a plain + // exec.Command, and the check above requires the caller to have elevated the + // terminal already, so the SID that arrives here was resolved in a process that + // was ALREADY elevated. The only model that actually works is the documented + // one: an elevated terminal belonging to the account that will run Zero + // afterwards. Same-account UAC satisfies it, because both tokens carry the same + // user SID. + // + // Alternate-administrator setup does not. The SID would describe the admin, the + // stamp would get its allow ACE for the wrong token, and every restricted + // command would stop before launch on a setup that had just reported success. + // That case cannot be detected from inside an already-elevated process, so it is + // EXCLUDED rather than silently mis-provisioned: the carried identity must match + // this token. That holds today, and it makes the unsupported model fail loudly + // the moment a real elevation step is introduced between the two. + if trimmed := strings.TrimSpace(config.ConsumerSID); trimmed != "" { + consumer, sidErr := windows.StringToSid(trimmed) + if sidErr != nil { + fmt.Fprintln(stderr, WindowsSandboxSetupName+": the calling user SID could not be parsed: "+sidErr.Error()) + return 1 + } + installer, installerErr := windowsSetupInstallerSID() + if installerErr != nil { + fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+installerErr.Error()) + return 1 + } + if !strings.EqualFold(strings.TrimSpace(installer), trimmed) { + fmt.Fprintln(stderr, WindowsSandboxSetupName+": setup is running as "+installer+" but the stamp would be provisioned for "+trimmed+ + ". Alternate-account setup is not supported: run `zero sandbox setup` from a terminal elevated as the account that will use Zero.") + return 1 + } + restore := setWindowsSetupConsumerSID(consumer) + defer restore() + } + + // HOLD THE SELECTED ROOT FOR THE WHOLE TRANSACTION. + // + // The unelevated caller took a lease only to learn which root wins and released + // it immediately, so nothing owned that root while this process provisions the + // tree, applies the ACL and stamp, installs network state and writes the + // marker. A command for another workspace scanning the same runtime parent + // excludes only ITS own current root, so it can take this root's cleanup lease + // and RemoveAll it. In the damaging ordering cleanup selects the root before + // setup refreshes its mtime and removes it after the stamp handle closes but + // before the marker is published, so setup reports success for a pathname that + // is gone or delete-pending and the next command finds no stamp on what it + // selected. + // + // A shared lease is what the cleanup's exclusive acquire fails against, and it + // is released when this function returns, which is after the marker write. If + // it cannot be taken, fail here: that is before any ACL, network or marker + // state is persisted, so a retry can still get the operator out. + if root := strings.TrimSpace(windowsSandboxSelectedRuntimeRoot(config.PermissionProfile)); root != "" { + lease, leaseErr := prepareSandboxRuntimeLease(root) + if leaseErr != nil { + fmt.Fprintln(stderr, WindowsSandboxSetupName+": reserve the sandbox runtime root "+root+" for setup: "+leaseErr.Error()) + return 1 + } + defer lease.release() + } + // Provisions the runtime candidate roots, then builds the plan that grants + // them. One call because a granted-but-absent write root fails the whole apply. + plan, runtimeRollback, err := buildWindowsSandboxSetupACLPlan(config) + // SETUP IS TRANSACTIONAL FOR THE STATE IT CREATED. Runtime roots are + // materialized before the network plan, the ACL apply, the network apply and + // the marker write, and every one of those can fail. Previously only ACL + // snapshots were restored, so a run that reported failure still left new + // persistent runtime directories behind, and it could not have cleaned them up + // even in principle because provisioning returned nothing about what it made. + // + // Composed once here so no later failure path can forget it. It removes only + // directories THIS run created, innermost first, and refuses to remove a + // non-empty one, so a pre-existing cache or temp tree is never touched. + failed := func(cause error) int { + fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+runWindowsSandboxSetupCompensations(cause, nil, runtimeRollback).Error()) return 1 } + if err != nil { + return failed(err) + } // Always provision the mode-INDEPENDENT infrastructure: the outbound block // filters scoped to the offline-marker SID. Runtime gates network per command // by whether the token carries that SID, so one setup serves both modes. networkPlan, err := BuildWindowsNetworkInfraPlan(config.commandConfig()) if err != nil { - fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) - return 1 + return failed(err) } - rollback, err := applyWindowsACLPlan(plan) + // THE STAMP RIDES WITH THE ACE. Computed before the apply so the apply can + // write it through the very handle it grants the capability on, which is the + // only way the two are provably about one object. Writing it afterwards by + // pathname left a window in which the predictable root could be replaced by an + // ordinary directory that then collected a valid-looking stamp while carrying + // no ACE at all. + marker, err := BuildWindowsSandboxSetupMarker(config) if err != nil { - fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) - return 1 + return failed(err) } - if err := applyWindowsNetworkPlan(networkPlan); err != nil { - if rollbackErr := rollback(); rollbackErr != nil { - fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, err, rollbackErr) - return 1 + var stamp *windowsACLStampRequest + if root := windowsSandboxSelectedRuntimeRoot(config.PermissionProfile); strings.TrimSpace(root) != "" { + stamp = &windowsACLStampRequest{Root: root, PlanHash: marker.ACLPlanHash} + // Snapshotted BEFORE the apply, because the apply is now what writes the + // stamp. Taking it afterwards would record this run's own stamp as the + // state to restore, so a failed setup would put its own artifact back + // rather than what it found. + // + // AND REFUSED IF IT CANNOT BE ESTABLISHED. The stamp writer uses + // FILE_OVERWRITE_IF, so it can replace an existing stamp even where this + // read was denied. Continuing on an unknown prior state would let a setup + // that then fails delete an attestation it never recorded, leaving the + // previous run's marker pointing at a runtime root it can no longer + // prove. No privileged state is applied until this is known. + snapshot, snapshotErr := snapshotWindowsSandboxRuntimeStamp(root) + if snapshotErr != nil { + return failed(snapshotErr) } - fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) - return 1 + runtimeRollback.stamp = snapshot + // Carried to the apply so the two stages are provably about one object, + // rather than about one pathname resolved twice. + stamp.RootIdentity = snapshot.rootIdentity + stamp.RootIdentified = snapshot.rootIdentified } - if _, err := WriteWindowsSandboxSetupMarker(config); err != nil { - if rollbackErr := rollback(); rollbackErr != nil { - fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, err, rollbackErr) - return 1 - } - fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) + rollback, err := applyWindowsACLPlanWithStamp(plan, stamp) + if err != nil { + return failed(err) + } + // From here both have to be undone, ACLs first so the directories are empty + // of our grants before they are removed. This used to return as soon as the + // ACL rollback reported an error, so the runtime rollback never ran and a + // failed setup kept the persistent directories it had just created; one undo + // failing is the moment the others matter most. + failedAfterACL := func(cause error) int { + fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+runWindowsSandboxSetupCompensations(cause, rollback, runtimeRollback).Error()) return 1 } + if err := applyWindowsNetworkPlan(networkPlan); err != nil { + return failedAfterACL(err) + } + // The stamp is already on disk, written through the handle the capability ACE + // was applied on, so this records the marker only and never names the runtime + // tree again. The rollback already owns that stamp, snapshotted above, so a + // failure here still leaves the root removable. + if err := writeWindowsSandboxSetupMarkerFile(config, marker); err != nil { + return failedAfterACL(err) + } return 0 } diff --git a/internal/sandbox/windows_stamp_identity_binding_windows_test.go b/internal/sandbox/windows_stamp_identity_binding_windows_test.go new file mode 100644 index 000000000..b6b348d56 --- /dev/null +++ b/internal/sandbox/windows_stamp_identity_binding_windows_test.go @@ -0,0 +1,144 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A SNAPSHOT OF ONE OBJECT DOES NOT AUTHORIZE MUTATING ANOTHER. +// +// snapshotWindowsSandboxRuntimeStamp reads the root's identity and prior stamp +// through one handle and closes it. applyWindowsACLPlanWithStamp then resolved +// the same pathname again and mutated whatever answered. Neither ever proved the +// two were the same object, so the transaction established "these prior bytes +// belong to B" and then wrote to A. +// +// The root's owner can do that with ordinary directories and no privilege: +// rename the real root aside, put a plain directory at the predictable name for +// the snapshot to read, and restore the original before the apply. Nothing is a +// reparse point, so a no-follow open does not notice, and the setup lease is a +// sibling of the root rather than the root entry itself. +// +// The damage is not only a misplaced ACL. On a later failure, stamp compensation +// compares what it finds against the snapshot's identity, refuses to restore, and +// leaves this run's stamp on a directory whose published marker still describes +// the previous successful setup. A failed setup invalidates a good one. +func TestTheApplyRefusesARuntimeRootTheSnapshotNeverRead(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + // A prior stamp, which is the thing a failed setup must not destroy. + priorPath := filepath.Join(root, windowsSandboxRuntimeStampName) + prior := []byte("previous-successful-setup") + if err := os.WriteFile(priorPath, prior, 0o600); err != nil { + t.Fatalf("write the prior stamp: %v", err) + } + + // The snapshot reads the real root. + request := stampRequestFor(t, root, "planhash") + if !request.RootIdentified { + t.Fatalf("SETUP INVALID: the snapshot could not identify %s, so the check below would refuse for the wrong reason", root) + } + + // Between the apply's open and its first mutation, the owner substitutes an + // ordinary directory under the same name. This is the interval the two + // separate opens created. + moved := root + "-original" + previous := windowsACLStampIdentitySwapHook + t.Cleanup(func() { windowsACLStampIdentitySwapHook = previous }) + swapped := false + windowsACLStampIdentitySwapHook = func(path string) { + if swapped || !strings.EqualFold(filepath.Clean(path), filepath.Clean(root)) { + return + } + swapped = true + if err := os.Rename(path, moved); err != nil { + t.Skipf("cannot rename the runtime root here: %v", err) + } + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatalf("plant the replacement: %v", err) + } + } + + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: "S-1-5-32-546"}, + }} + rollback, err := applyWindowsACLPlanWithStamp(plan, request) + if rollback != nil { + t.Cleanup(func() { _ = rollback() }) + } + if !swapped { + t.Skip("the swap hook never fired, so the interval was not reproduced") + } + if err == nil { + t.Fatal("the apply mutated a runtime root the snapshot never read, so its ACL and stamp attest to an object nobody inspected") + } + if !strings.Contains(err.Error(), "no longer the directory this run recorded") { + t.Errorf("the refusal does not say what went wrong: %v", err) + } + + // And the substitute is untouched: no stamp, so it cannot later validate as + // set up while carrying no capability ACE. + if _, statErr := os.Stat(filepath.Join(root, windowsSandboxRuntimeStampName)); statErr == nil { + t.Error("the substituted directory collected a stamp") + } + // The real root's prior stamp survives byte for byte. + got, readErr := os.ReadFile(filepath.Join(moved, windowsSandboxRuntimeStampName)) + if readErr != nil { + t.Fatalf("read the original stamp back: %v", readErr) + } + if string(got) != string(prior) { + t.Errorf("the previous setup's stamp was changed to %q", got) + } +} + +// And an unswapped root still applies, or the refusal above would be satisfied by +// an apply that refuses everything. +func TestTheApplyStillStampsTheRootTheSnapshotRead(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: "S-1-5-32-546"}, + }} + rollback, err := applyWindowsACLPlanWithStamp(plan, stampRequestFor(t, root, "planhash")) + if err != nil { + t.Fatalf("an unswapped runtime root was refused: %v", err) + } + t.Cleanup(func() { _ = rollback() }) + if _, statErr := os.Stat(filepath.Join(root, windowsSandboxRuntimeStampName)); statErr != nil { + t.Fatalf("the stamp was not written: %v", statErr) + } +} + +// An identity the snapshot could not establish refuses rather than passing. The +// guard exists for the case where nobody can tell, so treating "unknown" as +// permission would make it a no-op exactly there. +func TestTheApplyRefusesAnUnidentifiedRuntimeRoot(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: "S-1-5-32-546"}, + }} + rollback, err := applyWindowsACLPlanWithStamp(plan, &windowsACLStampRequest{Root: root, PlanHash: "planhash"}) + if rollback != nil { + t.Cleanup(func() { _ = rollback() }) + } + if err == nil { + t.Fatal("an apply whose snapshot never identified the root was allowed to mutate and stamp it") + } + if !strings.Contains(err.Error(), "could not be identified") { + t.Errorf("the refusal does not name the cause: %v", err) + } +} diff --git a/internal/sandbox/windows_stamp_protection_windows_test.go b/internal/sandbox/windows_stamp_protection_windows_test.go new file mode 100644 index 000000000..71ef3ed94 --- /dev/null +++ b/internal/sandbox/windows_stamp_protection_windows_test.go @@ -0,0 +1,124 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// stampDACLGrants reports whether the stamp's DACL grants the named SID, and +// whether it still inherits from the runtime root. +func stampDACLGrants(t *testing.T, path string, sid *windows.SID) (granted bool, inherits bool) { + t.Helper() + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("read the stamp security descriptor: %v", err) + } + control, _, err := descriptor.Control() + if err != nil { + t.Fatalf("read the stamp descriptor control bits: %v", err) + } + inherits = control&windows.SE_DACL_PROTECTED == 0 + dacl, _, err := descriptor.DACL() + if err != nil { + t.Fatalf("read the stamp DACL: %v", err) + } + if dacl == nil { + return false, inherits + } + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, index, &ace); err != nil { + t.Fatalf("read ACE %d: %v", index, err) + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + continue + } + if (*windows.SID)(unsafePointerOfSID(ace)).Equals(sid) { + granted = true + } + } + return granted, inherits +} + +// THE ATTESTATION MUST NOT SIT IN THE SUBJECT'S OWN WRITABLE NAMESPACE. +// +// The runtime root grants the capability SID FILE_GENERIC_WRITE with +// SUB_CONTAINERS_AND_OBJECTS_INHERIT, which is what lets a sandboxed command +// write TMP, GOCACHE and the package caches beneath it. A stamp created inside +// that root inherits the same grant, so the restricted command could overwrite +// the file attesting its own setup: its current command would continue, and +// every later elevated command and zero doctor would reject the altered plan +// hash until an Administrator re-ran setup. +func TestTheStampDoesNotInheritTheCapabilityGrant(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + + // Grant a capability SID write on the root, inheritable, exactly as the ACL + // plan does for a real runtime root. + capability, err := windows.StringToSid("S-1-5-32-546") + if err != nil { + t.Fatalf("resolve the stand-in capability SID: %v", err) + } + entries := []windows.EXPLICIT_ACCESS{{ + AccessPermissions: windows.GENERIC_WRITE | windows.GENERIC_READ, + AccessMode: windows.GRANT_ACCESS, + Inheritance: windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(capability), + }, + }} + dacl, err := windows.ACLFromEntries(entries, nil) + if err != nil { + t.Fatalf("build the root DACL: %v", err) + } + if err := windows.SetNamedSecurityInfo(root, windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil { + t.Skipf("cannot set an inheritable ACL here: %v", err) + } + + // An ordinary runtime descendant DOES inherit it: that is the grant the + // sandbox needs, and the precondition that makes this test meaningful. + cache := filepath.Join(root, "cache") + if err := os.MkdirAll(cache, 0o700); err != nil { + t.Fatalf("create the runtime cache: %v", err) + } + if granted, _ := stampDACLGrants(t, cache, capability); !granted { + t.Skip("the inheritable grant did not reach an ordinary descendant here, so this case is not being exercised") + } + + if err := writeWindowsRuntimeStampThroughHandle(root, "planhash"); err != nil { + t.Fatalf("write the stamp: %v", err) + } + stamp := windowsSandboxRuntimeStampPath(root) + + granted, inherits := stampDACLGrants(t, stamp, capability) + if granted { + t.Error("the stamp grants the capability SID; a sandboxed command could overwrite the attestation about its own setup") + } + if inherits { + t.Error("the stamp DACL is not protected, so the root's inheritable capability grant still applies to it") + } + + // And setup can still read what it wrote, or the protection would have + // locked out doctor and every later elevated command. + body, err := os.ReadFile(stamp) + if err != nil || string(body) != "planhash" { + t.Fatalf("the stamp is unreadable by its own writer (%q, err %v)", body, err) + } +} + +func unsafePointerOfSID(ace *windows.ACCESS_ALLOWED_ACE) unsafe.Pointer { + return unsafe.Pointer(&ace.SidStart) +} diff --git a/internal/sandbox/windows_stamp_reader_windows_test.go b/internal/sandbox/windows_stamp_reader_windows_test.go new file mode 100644 index 000000000..e92f8e567 --- /dev/null +++ b/internal/sandbox/windows_stamp_reader_windows_test.go @@ -0,0 +1,201 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// stampACEMask returns the access mask the stamp's DACL grants sid, and whether +// an ACE for it exists at all. +func stampACEMask(t *testing.T, path string, sid *windows.SID) (uint32, bool) { + t.Helper() + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("read the stamp security descriptor: %v", err) + } + dacl, _, err := descriptor.DACL() + if err != nil || dacl == nil { + t.Fatalf("read the stamp DACL: %v", err) + } + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, index, &ace); err != nil { + t.Fatalf("read ACE %d: %v", index, err) + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + continue + } + if (*windows.SID)(unsafe.Pointer(&ace.SidStart)).Equals(sid) { + return uint32(ace.Mask), true + } + } + return 0, false +} + +func ownerOfDirectory(t *testing.T, path string) *windows.SID { + t.Helper() + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("read the runtime root owner: %v", err) + } + owner, _, err := descriptor.Owner() + if err != nil { + t.Fatalf("read the runtime root owner: %v", err) + } + return owner +} + +// THE STAMP HAS TO BE READABLE BY THE IDENTITY THAT VALIDATES IT LATER, AND +// WRITABLE BY NEITHER IT NOR THE SANDBOX. +// +// Setup writes the stamp elevated; runWindowsSandboxCommand and zero doctor read +// it afterwards from an ordinary shell. The DACL used to name WinCreatorOwnerSid +// at GENERIC_ALL. SetSecurityInfo does substitute that placeholder even in a +// NO_INHERITANCE ACE, so a concrete SID did land in the ACE, but the one it +// named was whoever ran setup. Elevation by a different administrator account +// therefore produced a stamp the ordinary reader could not open, and a reader +// named in no ACE gets Access is denied, so a successful setup handed over an +// unreadable attestation. +// +// Resolving the reader from the runtime root binds the grant to the install +// rather than to the elevation, and read-only keeps the attestation out of reach +// of everything but setup and repair. +func TestStampGrantsTheRuntimeRootOwnerReadOnly(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + if err := writeWindowsRuntimeStampThroughHandle(root, "planhash"); err != nil { + t.Fatalf("write the stamp: %v", err) + } + stamp := windowsSandboxRuntimeStampPath(root) + owner := ownerOfDirectory(t, root) + + mask, present := stampACEMask(t, stamp, owner) + if !present { + t.Fatalf("the stamp names no ACE for the runtime root owner %s, so the post-setup reader is locked out", owner) + } + + // Read, because doctor and the launch gate have to open it. + const readBits = windows.FILE_READ_DATA + if mask&readBits == 0 { + t.Errorf("the runtime root owner cannot read the stamp (mask 0x%08x)", mask) + } + // Not write, UNLESS the owner is itself a repair identity. A runtime root + // created by an elevated process is commonly owned by BUILTINAdministrators + // rather than by the invoking user, which is what CI runners do, and repair + // has to keep write there. Asserting no-write unconditionally failed on every + // runner while passing on an unelevated box. + const writeBits = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA | windows.WRITE_DAC | windows.WRITE_OWNER + if isRepairIdentity(t, owner) { + if mask&windows.FILE_WRITE_DATA == 0 { + t.Errorf("the runtime root is owned by a repair identity that cannot rewrite the stamp (mask 0x%08x)", mask) + } + } else if mask&writeBits != 0 { + t.Errorf("the runtime root owner can rewrite the stamp (mask 0x%08x, write bits 0x%08x)", mask, mask&writeBits) + } + + // The end of the handoff: it is actually readable. + if _, err := os.ReadFile(stamp); err != nil { + t.Errorf("the post-setup reader cannot open the stamp: %v", err) + } + + // And repair still can, or a damaged stamp could never be replaced. + for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{windows.WinLocalSystemSid, windows.WinBuiltinAdministratorsSid} { + sid, err := windows.CreateWellKnownSid(wellKnown) + if err != nil { + t.Fatalf("resolve well-known SID: %v", err) + } + mask, present := stampACEMask(t, stamp, sid) + if !present || mask&windows.FILE_WRITE_DATA == 0 { + t.Errorf("repair identity %s cannot rewrite the stamp (present=%v mask 0x%08x)", sid, present, mask) + } + } +} + +// An identity that could not be resolved is not permission to protect the stamp +// with a DACL naming nobody. +func TestProtectRefusesWithoutAResolvedReader(t *testing.T) { + // A REAL handle, so the refusal can only come from the missing identity. + // Handle 0 fails on its own, which made the first version of this test pass + // with the guard deleted. + stamp := filepath.Join(t.TempDir(), "stamp.json") + if err := os.WriteFile(stamp, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + handle, err := windows.CreateFile(windows.StringToUTF16Ptr(stamp), + windows.READ_CONTROL|windows.WRITE_DAC, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatalf("open the stamp: %v", err) + } + defer windows.CloseHandle(handle) + + err = protectWindowsRuntimeStamp(handle, nil) + if err == nil { + t.Fatal("protecting the stamp with no resolved reader succeeded, which would lock out the identity that has to validate it") + } + if !strings.Contains(err.Error(), "no identity was supplied") { + t.Fatalf("refused for the wrong reason, so this does not pin the guard: %v", err) + } +} + +func isRepairIdentity(t *testing.T, sid *windows.SID) bool { + t.Helper() + for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{windows.WinLocalSystemSid, windows.WinBuiltinAdministratorsSid} { + known, err := windows.CreateWellKnownSid(wellKnown) + if err != nil { + t.Fatalf("resolve well-known SID: %v", err) + } + if sid.Equals(known) { + return true + } + } + return false +} + +// A READER THAT IS ALSO A REPAIR IDENTITY MUST NOT LOSE WRITE. +// +// The reader entry and the repair entries can name the same SID, because an +// elevated create commonly leaves BUILTINAdministrators as the owner. Naming it +// twice let the narrower read-only entry win, and repair could no longer rewrite +// the stamp: setup succeeded and left an attestation nothing could replace. My +// own regression caught this on CI and not here, since an unelevated box owns +// the directory as the ordinary user. +func TestReaderThatIsARepairIdentityKeepsWrite(t *testing.T) { + administrators, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + t.Fatalf("resolve the Administrators SID: %v", err) + } + stamp := filepath.Join(t.TempDir(), "stamp.json") + if err := os.WriteFile(stamp, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + handle, err := windows.CreateFile(windows.StringToUTF16Ptr(stamp), + windows.READ_CONTROL|windows.WRITE_DAC, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatalf("open the stamp: %v", err) + } + if err := protectWindowsRuntimeStamp(handle, administrators); err != nil { + windows.CloseHandle(handle) + t.Fatalf("protect the stamp: %v", err) + } + windows.CloseHandle(handle) + + mask, present := stampACEMask(t, stamp, administrators) + if !present { + t.Fatal("Administrators has no ACE at all") + } + if mask&windows.FILE_WRITE_DATA == 0 { + t.Errorf("Administrators lost write when it was also the resolved reader (mask 0x%08x)", mask) + } +} diff --git a/internal/sandbox/windows_unelevated.go b/internal/sandbox/windows_unelevated.go index 980664d6a..044805766 100644 --- a/internal/sandbox/windows_unelevated.go +++ b/internal/sandbox/windows_unelevated.go @@ -47,6 +47,11 @@ func WindowsUnelevatedSetupMarkerPath(sandboxHome string) string { // output later feeds the apply step, so the fingerprint and the applied grants // can never drift apart. func buildWindowsUnelevatedAppliedPlan(config WindowsSandboxCommandConfig) (WindowsUnelevatedAppliedPlan, WindowsACLPlan, error) { + // No provisioning here, deliberately. This tier applies a plan whose runtime + // write roots were derived and created by the PARENT, because this process runs + // re-exec'd with TEMP redirected into the runtime tree and would derive a + // different temp-side spelling than the one the plan names. See the note at the + // windowsSandboxProfileWithProvisionedRuntime call in BuildCommandPlan. plan, err := BuildWindowsACLPlan(config) if err != nil { return WindowsUnelevatedAppliedPlan{}, WindowsACLPlan{}, err