diff --git a/internal/doctor/hardening.go b/internal/doctor/hardening.go index baf21e04c..a01c559db 100644 --- a/internal/doctor/hardening.go +++ b/internal/doctor/hardening.go @@ -99,11 +99,54 @@ 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. + if recorded, current, err := sandbox.WindowsSandboxRecordedRuntimeRootIsCurrent(sandboxHome, workspaceRoot); err == nil && 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..4bd0f318e --- /dev/null +++ b/internal/doctor/windows_runtime_stamp_test.go @@ -0,0 +1,138 @@ +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) + } +} 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/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..099f87f55 --- /dev/null +++ b/internal/sandbox/runtime_descend_other.go @@ -0,0 +1,32 @@ +//go:build !windows + +package sandbox + +import ( + "fmt" + "os" +) + +// runtimeDescentBarrier is the Windows test seam; nothing consults it here, but +// the name exists on every platform so a test that sets it compiles everywhere. +var runtimeDescentBarrier func() + +// createRuntimeTailHandleRelative keeps the pathname form off Windows. The +// elevated-installer-versus-unelevated-renamer split the handle-relative +// descent closes does not apply here, so this is the mkdir loop it replaced, +// creating outermost-first and recording each component it made. +func createRuntimeTailHandleRelative(base string, tail []string) ([]windowsCreatedRuntimeDir, error) { + _ = base + var created []windowsCreatedRuntimeDir + for _, path := range tail { + if err := os.Mkdir(path, 0o700); err != nil { + if os.IsExist(err) { + continue + } + return created, fmt.Errorf("create sandbox runtime root %s: %w", path, err) + } + identity, ok := runtimeIdentityAfterCreate(path) + created = append(created, windowsCreatedRuntimeDir{path: path, identity: identity, identified: ok}) + } + return created, nil +} diff --git a/internal/sandbox/runtime_descend_windows.go b/internal/sandbox/runtime_descend_windows.go new file mode 100644 index 000000000..df1985a5c --- /dev/null +++ b/internal/sandbox/runtime_descend_windows.go @@ -0,0 +1,98 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "fmt" + "path/filepath" + + "golang.org/x/sys/windows" +) + +// 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 junction 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() + +// 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) { + parent, err := openWindowsDirectoryByName(base) + if err != nil { + return nil, fmt.Errorf("open sandbox runtime base %s: %w", base, err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + 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, 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, fmt.Errorf("inspect sandbox runtime component %s after a concurrent create: %w", path, reopenErr) + } + _ = windows.CloseHandle(parent) + parent = existing + continue + } + return created, fmt.Errorf("create sandbox runtime root %s: %w", path, createErr) + } + identity, idErr := handleRuntimeIdentity(handle) + if idErr != nil { + _ = windows.CloseHandle(handle) + return created, 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, 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..56e30f14b --- /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(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(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(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(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_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_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_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..19ea6103a 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,101 @@ 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. + root, lease, err := selectSandboxRuntimeRoot(workspaceRoot, true, sandboxHome) + if err != nil { + return SandboxRuntime{}, nil, err } prepared := false defer func() { @@ -90,6 +149,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 +166,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,6 +182,12 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) } func prepareSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) { + // Before the parent is created, because MkdirAll walks and creates through + // whatever the owned components resolve to and the lease file is opened + // without O_NOFOLLOW. + if err := refuseAliasedRuntimeComponents(root); err != nil { + return nil, err + } if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil { return nil, fmt.Errorf("create sandbox runtime parent: %w", err) } @@ -174,22 +254,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) + // 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) } - 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) - } - fallbackSandboxRuntimes.roots[workspaceRoot] = root return root, nil } @@ -226,3 +344,227 @@ 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(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 + } + } + return "" +} + +// 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(preferred, fallback, sandboxHome) != "", nil +} + +func selectSandboxRuntimeRoot(workspaceRoot string, honorRecorded bool, sandboxHome string) (string, *sandboxRuntimeLease, error) { + workspaceRoot = canonicalSandboxWorkspaceRoot(workspaceRoot) + if workspaceRoot == "" || workspaceRoot == "." { + return "", nil, errors.New("sandbox runtime requires a workspace root") + } + cacheRoot, err := sandboxUserCacheDir() + if err != nil { + return "", nil, fmt.Errorf("resolve user cache directory: %w", err) + } + cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot) + if cacheRoot == "" || cacheRoot == "." { + return "", nil, errors.New("user cache directory is unavailable") + } + root, err := sandboxRuntimeRootFor(workspaceRoot, cacheRoot) + if err != nil { + return "", 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(root, fallbackRoot, sandboxHome); pinned != "" { + lease, leaseErr := prepareSandboxRuntimeLease(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, 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, nil + } + } + lease, err := prepareSandboxRuntimeLease(root) + if err == nil { + return root, lease, 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, 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, err + } + lease, err = prepareSandboxRuntimeLease(root) + if err != nil { + return "", nil, err + } + return root, lease, 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..a6cf622c8 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -24,13 +24,104 @@ 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 +} + +// 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 + +// 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 +164,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 @@ -138,8 +233,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 +271,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 +375,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..09a1562d4 --- /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, &windowsACLStampRequest{Root: root, PlanHash: "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..b93989787 --- /dev/null +++ b/internal/sandbox/windows_acl_stamp_swap_windows_test.go @@ -0,0 +1,59 @@ +//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, &windowsACLStampRequest{Root: root, PlanHash: "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) + } +} 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..888bb1a93 --- /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..eab395a02 --- /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..8da640336 --- /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(), "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(), "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..624d075bc 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,6 +61,22 @@ 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 { @@ -66,15 +100,66 @@ func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]str 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, selectErr := selectSandboxRuntimeRoot(firstNonEmpty(workspaceRoots...), false, sandboxHome) + if selectErr != nil { + return nil, fmt.Errorf("select the sandbox runtime root for setup: %w", selectErr) + } + lease.release() + 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) } + // RESOLVED HERE, in the operator's shell, for the same reason the runtime + // root is selected here: the elevated helper cannot observe who will run the + // commands afterwards. 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 := currentProcessSID() + if sidErr != nil { + return nil, 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) } @@ -110,6 +195,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 +290,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 +374,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 +390,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 +492,663 @@ 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 + } + // Find the deepest ancestor that already exists, then create downwards from + // there, so the record contains exactly the new components. + var missing []string + current := filepath.Clean(root) + for { + // os.Stat, which FOLLOWS links, deliberately. os.Lstat reports a junction + // as ModeIrregular rather than as a directory, so using it here refused a + // redirected LOCALAPPDATA -- an ordinary Windows configuration -- with + // "exists and is not a directory". Whether a link is acceptable is a + // different question, answered by refuseReparsedRuntimeAncestors for the + // components Zero actually owns. + if info, err := os.Stat(current); err == nil { + if !info.IsDir() { + return nil, fmt.Errorf("sandbox runtime path %s exists and is not a directory", current) + } + break + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("inspect sandbox runtime path %s: %w", current, err) + } + missing = append(missing, current) + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + // HANDLE-RELATIVE FROM HERE DOWN. The walk above found the deepest ancestor + // that already exists, and that is the only thing opened by name. Every + // missing component beneath it is created relative to its parent's handle, + // no-follow, so a junction swapped into an owned component between the + // pre-check and the create is seen as the link it is rather than descended + // into. The old loop reopened each parent by name, which is exactly the + // interval a swap needs; the pre and post checks below are now belt and + // braces rather than the authorization. Outermost first, so each parent + // exists before its child. + tail := make([]string, 0, len(missing)) + for index := len(missing) - 1; index >= 0; index-- { + tail = append(tail, missing[index]) + } + created, err := createRuntimeTailHandleRelative(current, 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_provision_test.go b/internal/sandbox/windows_setup_provision_test.go new file mode 100644 index 000000000..b06b5bb54 --- /dev/null +++ b/internal/sandbox/windows_setup_provision_test.go @@ -0,0 +1,364 @@ +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) + } + } + } + + args, 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) + } + + 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_root_test.go b/internal/sandbox/windows_setup_runtime_root_test.go new file mode 100644 index 000000000..aeec51b25 --- /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..d09e78695 --- /dev/null +++ b/internal/sandbox/windows_setup_runtime_selection_test.go @@ -0,0 +1,68 @@ +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 }) + + args, 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", 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 }) + + args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + CommandCWD: workspace, + SandboxHome: t.TempDir(), + }) + if err != nil { + t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) + } + 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_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..9bef1cbd7 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -5,6 +5,7 @@ package sandbox import ( "fmt" "io" + "strings" "golang.org/x/sys/windows" ) @@ -17,40 +18,125 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) 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()) + // CARRY THE CONSUMER ACROSS THE ELEVATION BOUNDARY, before anything is + // provisioned. The unelevated caller resolved who will read the stamp and + // passed it in; this process cannot observe that, because it is the installer + // and it creates the runtime leaf the old code derived the reader from. + 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 + } + 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 } - 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_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