mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
759133cdd3 | ||
|
|
cf40b8680b | ||
|
|
88dc53dd98 | ||
|
|
b203ad1201 | ||
|
|
e81ed96af3 | ||
|
|
31da8a9abf | ||
|
|
8af05997e1 | ||
|
|
41590d74fd | ||
|
|
55b8e2699d | ||
|
|
16fea97904 | ||
|
|
a40b50fe1f | ||
|
|
ef8f5c9003 | ||
|
|
746b6af602 | ||
|
|
3239565241 | ||
|
|
d9b72ba9d0 | ||
|
|
e26e292d1a | ||
|
|
695ef641f9 | ||
|
|
aecd614305 | ||
|
|
7cad854012 | ||
|
|
1efc1aff4b | ||
|
|
ab76cfeb1e | ||
|
|
3944704131 | ||
|
|
1e29354a0e | ||
|
|
51976966aa | ||
|
|
1dd1709bff | ||
|
|
622371f73f | ||
|
|
2d755dba96 | ||
|
|
16d7512e2a | ||
|
|
c736616526 | ||
|
|
1826326ed0 | ||
|
|
c981c3cb9a | ||
|
|
c914d762fc | ||
|
|
67f3d6145a | ||
|
|
0fda33eee2 | ||
|
|
d608a3c087 | ||
|
|
09cc534e92 | ||
|
|
ff077553bf | ||
|
|
84709257a8 | ||
|
|
4e1f4831de | ||
|
|
8bb5546b49 | ||
|
|
c11e350d2c | ||
|
|
3cb40ce849 | ||
|
|
b86bfc17e9 | ||
|
|
37629a2fa5 | ||
|
|
279c7bb6e8 | ||
|
|
0a80db48fe | ||
|
|
9b9937bc3d | ||
|
|
52d1a31657 | ||
|
|
9b54fb48b5 | ||
|
|
66724c6e0e | ||
|
|
03e556bce2 | ||
|
|
542728a7da | ||
|
|
c13799d56a | ||
|
|
53fb2e8daa | ||
|
|
11534a4f1f | ||
|
|
2d5dcfa259 | ||
|
|
7681a95656 | ||
|
|
1767a08872 | ||
|
|
1a6b08fe5f | ||
|
|
e1c57564a0 | ||
|
|
b1fb0ab42a | ||
|
|
1945d47ef8 | ||
|
|
d0693ffa8d | ||
|
|
dfe7fa3eb1 | ||
|
|
6315181bbf | ||
|
|
1391376f9d | ||
|
|
bf7a530b33 | ||
|
|
6d9b437028 | ||
|
|
239894103e | ||
|
|
815d220a2f | ||
|
|
0b6b37eca5 | ||
|
|
324d9f32fb | ||
|
|
706e1a4bee | ||
|
|
b46d7d19c6 | ||
|
|
ae294d8f98 | ||
|
|
01dcfdc782 | ||
|
|
38bac64c50 | ||
|
|
672f407d6b | ||
|
|
9bdfa4c618 | ||
|
|
39e0e02cc8 | ||
|
|
a56e6f86c9 | ||
|
|
f855802c85 | ||
|
|
718e064711 | ||
|
|
2e2ea27dcf |
No files matched your search
+11
-1
@@ -22,7 +22,7 @@ const permissionProfileScopeNote = "permissionProfile is derived from this proce
|
||||
|
||||
func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
if len(args) == 0 {
|
||||
return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy` or `zero sandbox grants list`.")
|
||||
return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy`, `zero sandbox exec`, or `zero sandbox grants list`.")
|
||||
}
|
||||
switch args[0] {
|
||||
case "-h", "--help", "help":
|
||||
@@ -36,6 +36,8 @@ func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
return runSandboxSetup(args[1:], stdout, stderr, deps)
|
||||
case "check":
|
||||
return runSandboxCheck(args[1:], stdout, stderr, deps)
|
||||
case "exec":
|
||||
return runSandboxExec(args[1:], stdout, stderr, deps)
|
||||
case "grants":
|
||||
return runSandboxGrants(args[1:], stdout, stderr, deps)
|
||||
default:
|
||||
@@ -174,10 +176,17 @@ func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps app
|
||||
if !setupHelper.Available() {
|
||||
return writeAppError(stderr, "Windows sandbox setup helper is not available", exitProvider)
|
||||
}
|
||||
// Resolved here, in the shell the user typed `zero sandbox setup` into, and
|
||||
// carried in the args. The helper may be launched elevated, and an elevated
|
||||
// process does not inherit this shell's environment. Stated explicitly rather
|
||||
// than left nil (which resolves the same way) because this is the call site
|
||||
// the opt-in is about.
|
||||
principalOptIn := zeroSandbox.WindowsSandboxPrincipalOptIn(nil)
|
||||
setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
PermissionProfile: profile,
|
||||
PrincipalOptIn: &principalOptIn,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
@@ -646,6 +655,7 @@ Commands:
|
||||
policy Inspect active sandbox policy and platform backend
|
||||
setup Run native platform sandbox setup
|
||||
check Evaluate the sandbox decision for a hypothetical tool action
|
||||
exec Run one command through the real sandbox
|
||||
grants Manage persistent sandbox grants
|
||||
|
||||
`)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// runSandboxExec runs ONE command through the real sandbox and exits with its
|
||||
// status.
|
||||
//
|
||||
// This exists because until now the sandbox could only be exercised through a
|
||||
// full agent turn with a model in the loop. `zero sandbox policy` reports what
|
||||
// the posture would be and `zero sandbox check` evaluates a hypothetical
|
||||
// decision, but nothing actually ran a command and let you look at what
|
||||
// happened on disk afterwards. The practical result is that enforcement is
|
||||
// covered almost entirely by tests asserting the shape of an ACL plan, and
|
||||
// almost not at all by tests asserting a write was refused.
|
||||
//
|
||||
// A plan can be perfectly correct and never reach the filesystem. That is not
|
||||
// hypothetical here: the .git rename guard was emitted correctly by the planner
|
||||
// and silently skipped by the applier, and four tests covering the plan all
|
||||
// passed while the ACE was absent from disk. Something that runs the real
|
||||
// binary and then stats the file is the only thing that catches that class.
|
||||
//
|
||||
// Deliberately NOT a debug curiosity: it takes the same path a shell tool
|
||||
// takes, through SandboxManager.BuildCommandPlan, so what it proves is what
|
||||
// users get. It prints the resolved backend and enforcement level to stderr
|
||||
// before running, so a harness can assert the sandbox was actually engaged
|
||||
// rather than quietly downgraded.
|
||||
func runSandboxExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
command, err := parseSandboxExecArgs(args)
|
||||
if err != nil {
|
||||
if errors.Is(err, errSandboxExecHelp) {
|
||||
if writeErr := writeSandboxExecHelp(stdout); writeErr != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
workspaceRoot, err := resolveWorkspaceRoot("", deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitProvider)
|
||||
}
|
||||
policy := applyConfiguredSandboxPolicy(zeroSandbox.DefaultPolicy(), resolved.Sandbox)
|
||||
|
||||
scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, fmt.Sprintf("resolve sandbox write roots: %v", err), exitCrash)
|
||||
}
|
||||
|
||||
// Through the ENGINE, not a SandboxManager built here.
|
||||
//
|
||||
// The engine is what a real tool call goes through, and it does more than
|
||||
// hand the manager a request: it resolves the permission profile, calls
|
||||
// prepareSandboxRuntime, and folds the runtime state into that profile
|
||||
// before planning. Building a manager directly skipped all of it, so the
|
||||
// command ran without the runtime write root, and cache or temp writes could
|
||||
// pass or fail differently from the sandboxed command this exists to imitate.
|
||||
// A harness that exercises the wrong path is worse than no harness, because
|
||||
// its result still reads as evidence.
|
||||
engine := zeroSandbox.NewEngine(zeroSandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Scope: scope,
|
||||
Backend: deps.selectSandboxBackend(zeroSandbox.BackendOptions{}),
|
||||
// The same scrub list a real tool call gets. Without it the engine keeps an
|
||||
// empty set, and only the hardcoded names plus the provider catalog's own
|
||||
// AuthEnvVars are removed from the child's environment: a key named by
|
||||
// `apiKeyEnv` in the user's config would be scrubbed for every sandboxed
|
||||
// tool call and handed to this one. Reproducing the production environment
|
||||
// is the entire point of the command, and a credential is the last part of
|
||||
// it that may differ.
|
||||
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
|
||||
})
|
||||
plan, err := engine.BuildCommandPlan(zeroSandbox.CommandSpec{
|
||||
Name: command[0],
|
||||
Args: command[1:],
|
||||
Dir: workspaceRoot,
|
||||
Env: os.Environ(),
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, fmt.Sprintf("build sandbox command plan: %v", err), exitCrash)
|
||||
}
|
||||
// The plan owns resources beyond its construction: on Linux it allocates a
|
||||
// policy-report file and registers the removal here, so without this every
|
||||
// invocation leaves a /tmp/zero-sandbox-report-* behind.
|
||||
defer plan.Cleanup()
|
||||
|
||||
// Printed before the command runs and on stderr, so it survives a command
|
||||
// that writes to stdout and stays greppable by a test harness. A downgrade
|
||||
// is reported loudly for the same reason: a smoke test that passes because
|
||||
// the sandbox quietly stood down is worse than no smoke test.
|
||||
fmt.Fprintf(stderr, "sandbox: backend=%s enforcement=%s wrapped=%t workspace=%s\n",
|
||||
plan.Backend.Name, plan.EnforcementLevel, plan.Wrapped, plan.WorkspaceRoot)
|
||||
if strings.TrimSpace(plan.DowngradeReason) != "" {
|
||||
fmt.Fprintf(stderr, "sandbox: DOWNGRADED: %s\n", plan.DowngradeReason)
|
||||
}
|
||||
|
||||
return runSandboxPlannedCommand(plan, stdout, stderr)
|
||||
}
|
||||
|
||||
func runSandboxPlannedCommand(plan zeroSandbox.CommandPlan, stdout io.Writer, stderr io.Writer) int {
|
||||
process := exec.Command(plan.Name, plan.Args...)
|
||||
process.Dir = plan.Dir
|
||||
if process.Dir == "" {
|
||||
process.Dir = plan.WorkspaceRoot
|
||||
}
|
||||
if len(plan.Env) > 0 {
|
||||
process.Env = plan.Env
|
||||
}
|
||||
process.Stdin = os.Stdin
|
||||
process.Stdout = stdout
|
||||
process.Stderr = stderr
|
||||
|
||||
if err := process.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
// The command's own status, not ours. A harness asserting "the write
|
||||
// was refused" needs the refusal's exit code, not a wrapper's.
|
||||
// A SIGNALED CHILD HAS NO EXIT CODE TO REPORT. ExitCode() answers -1
|
||||
// there, and os.Exit truncates that to 255, so a child that took SIGTERM
|
||||
// became indistinguishable from one that chose to exit 255. Fold the
|
||||
// signal into the conventional 128+n a shell would report, which needs
|
||||
// the ProcessState rather than the integer.
|
||||
if status, signaled := signaledExitStatus(exitErr.ProcessState); signaled {
|
||||
return status
|
||||
}
|
||||
return exitErr.ExitCode()
|
||||
}
|
||||
fmt.Fprintf(stderr, "sandbox exec: %v\n", err)
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
var errSandboxExecHelp = errors.New("help requested")
|
||||
|
||||
// parseSandboxExecArgs takes everything after `--` as the command, so the
|
||||
// command's own flags are never mistaken for ours.
|
||||
func parseSandboxExecArgs(args []string) ([]string, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, errors.New("usage: zero sandbox exec -- <command> [args...]")
|
||||
}
|
||||
// Only the FIRST token is ours to interpret. Everything from the second
|
||||
// onwards belongs to the child, help flags included.
|
||||
//
|
||||
// Written as a straight-line decision on args[0] rather than a loop, because
|
||||
// a loop here is a lie. An earlier version scanned for `--` "up front" and
|
||||
// said so in its comment, but every branch of its body returned, so it only
|
||||
// ever examined index 0 and the promised scan did not exist. The rule below
|
||||
// is what that code actually implemented, and it is the rule we want: it is
|
||||
// the wrapper's own prefix that can ask for the wrapper's help, and the
|
||||
// prefix is at most one token long.
|
||||
switch args[0] {
|
||||
case "--":
|
||||
// Everything after the separator is the command, verbatim, including
|
||||
// `--help`. That is the documented contract: `zero sandbox exec -- cmd
|
||||
// --help` must run cmd's help, not ours.
|
||||
command := args[1:]
|
||||
if len(command) == 0 {
|
||||
return nil, errors.New("usage: zero sandbox exec -- <command> [args...]")
|
||||
}
|
||||
return command, nil
|
||||
case "-h", "--help", "help":
|
||||
return nil, errSandboxExecHelp
|
||||
}
|
||||
// The separator-less form, tolerated for interactive use: the first token is
|
||||
// the command, so nothing after it is ours to read. Without this,
|
||||
// `zero sandbox exec mycmd --help` printed OUR help and never ran mycmd,
|
||||
// which is the same contract break as reading past `--`. The help text still
|
||||
// shows the separator, because anything with a leading dash needs it.
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func writeSandboxExecHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero sandbox exec -- <command> [args...]
|
||||
|
||||
Runs one command through the real sandbox and exits with its status.
|
||||
|
||||
Everything after the -- separator is the command, so its own flags are not
|
||||
parsed as Zero's. The resolved backend and enforcement level are written to
|
||||
stderr before the command runs, and a downgrade is reported there explicitly.
|
||||
|
||||
Examples:
|
||||
zero sandbox exec -- cmd /c echo hello
|
||||
zero sandbox exec -- powershell -Command "Set-Content out.txt x"
|
||||
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// signaledExitStatus reports the conventional shell status for a child that was
|
||||
// terminated by a signal, which exec.ExitError cannot represent.
|
||||
//
|
||||
// ExitCode() returns -1 for a signaled child, and the top level hands that to
|
||||
// os.Exit, which truncates it to 255. A child that takes SIGTERM is then
|
||||
// indistinguishable from one that exited 255 of its own accord, which breaks the
|
||||
// documented status contract for a command whose whole job is to report the
|
||||
// child's own status faithfully. 128+signal is what a shell reports and what a
|
||||
// harness comparing statuses expects.
|
||||
func signaledExitStatus(state *os.ProcessState) (int, bool) {
|
||||
if state == nil {
|
||||
return 0, false
|
||||
}
|
||||
status, ok := state.Sys().(syscall.WaitStatus)
|
||||
if !ok || !status.Signaled() {
|
||||
return 0, false
|
||||
}
|
||||
return 128 + int(status.Signal()), true
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A SIGNALED CHILD MUST NOT LOOK LIKE ONE THAT CHOSE TO EXIT 255.
|
||||
//
|
||||
// `sandbox exec` exists to report the child's own status faithfully, and
|
||||
// exec.ExitError cannot represent signal termination: ExitCode() answers -1, and
|
||||
// the top level hands that to os.Exit, which truncates it to 255. So a child
|
||||
// killed by SIGTERM was indistinguishable from an ordinary exit of 255, and a
|
||||
// harness comparing statuses could not tell a refusal from a kill.
|
||||
//
|
||||
// Driven with a real subprocess and a real signal rather than a synthetic
|
||||
// WaitStatus, because the mapping is only correct if the OS agrees with it.
|
||||
func TestSignaledChildReportsTheConventionalStatus(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
script string
|
||||
want int
|
||||
}{
|
||||
{name: "SIGTERM", script: "kill -TERM $$; sleep 5", want: 128 + int(syscall.SIGTERM)},
|
||||
{name: "SIGINT", script: "kill -INT $$; sleep 5", want: 128 + int(syscall.SIGINT)},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := exec.Command("/bin/sh", "-c", testCase.script).Run()
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("SETUP INVALID: the child did not end with an ExitError: %v", err)
|
||||
}
|
||||
// The behaviour being corrected: the integer alone cannot say this.
|
||||
if code := exitErr.ExitCode(); code != -1 {
|
||||
t.Fatalf("SETUP INVALID: a signaled child reported exit code %d, expected -1 on this platform", code)
|
||||
}
|
||||
status, signaled := signaledExitStatus(exitErr.ProcessState)
|
||||
if !signaled {
|
||||
t.Fatal("a signaled child was not recognised as signaled, so it would be reported as exit 255")
|
||||
}
|
||||
if status != testCase.want {
|
||||
t.Fatalf("status = %d, want %d (128 + signal), which is what a shell reports", status, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An ordinary non-zero exit is untouched: it has a real code and must keep it.
|
||||
func TestOrdinaryExitIsNotTreatedAsSignaled(t *testing.T) {
|
||||
err := exec.Command("/bin/sh", "-c", "exit 3").Run()
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("SETUP INVALID: the child did not end with an ExitError: %v", err)
|
||||
}
|
||||
if _, signaled := signaledExitStatus(exitErr.ProcessState); signaled {
|
||||
t.Fatal("an ordinary exit was reported as signaled, which would rewrite its status")
|
||||
}
|
||||
if code := exitErr.ExitCode(); code != 3 {
|
||||
t.Fatalf("exit code = %d, want the child's own 3", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import "os"
|
||||
|
||||
// signaledExitStatus has no Windows counterpart: a process there ends with an
|
||||
// exit code, and there is no signal to fold into one. Windows keeps the exit
|
||||
// code exec.ExitError already reports.
|
||||
func signaledExitStatus(*os.ProcessState) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// parseSandboxExecArgs sits on the boundary between Zero's flags and a child
|
||||
// command's, and it had no tests at all: not for the reported defect, not even
|
||||
// for the happy case. The contract it has to keep is that everything after the
|
||||
// separator belongs to the child, help flags included, so the wrapper can never
|
||||
// answer for a command it was supposed to be running.
|
||||
func TestParseSandboxExecArgs(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
args []string
|
||||
command []string
|
||||
help bool
|
||||
usage bool
|
||||
}{
|
||||
{
|
||||
// The reported defect: --help after the separator is the CHILD's.
|
||||
name: "help after the separator belongs to the child",
|
||||
args: []string{"--", "cmd", "--help"},
|
||||
command: []string{"cmd", "--help"},
|
||||
},
|
||||
{
|
||||
name: "a second separator is the child's too",
|
||||
args: []string{"--", "cmd", "--", "inner"},
|
||||
command: []string{"cmd", "--", "inner"},
|
||||
},
|
||||
{
|
||||
name: "a flag-looking command survives the separator",
|
||||
args: []string{"--", "--weird-binary"},
|
||||
command: []string{"--weird-binary"},
|
||||
},
|
||||
{
|
||||
// The separator-less form. The first token is the command, so its own
|
||||
// flags are not ours to read either.
|
||||
name: "help after a separatorless command belongs to the child",
|
||||
args: []string{"cmd", "--help"},
|
||||
command: []string{"cmd", "--help"},
|
||||
},
|
||||
{
|
||||
// A separator arriving AFTER the command has already started is part of
|
||||
// the child's argv, not a delimiter we get a second go at.
|
||||
name: "a separator after the command is the child's argument",
|
||||
args: []string{"ls", "--", "foo"},
|
||||
command: []string{"ls", "--", "foo"},
|
||||
},
|
||||
{name: "bare help", args: []string{"--help"}, help: true},
|
||||
{name: "short help", args: []string{"-h"}, help: true},
|
||||
{name: "help subcommand", args: []string{"help"}, help: true},
|
||||
{name: "help in the prefix wins", args: []string{"--help", "--", "cmd"}, help: true},
|
||||
{name: "no arguments", args: nil, usage: true},
|
||||
{name: "separator with nothing after it", args: []string{"--"}, usage: true},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
command, err := parseSandboxExecArgs(testCase.args)
|
||||
switch {
|
||||
case testCase.help:
|
||||
if !errors.Is(err, errSandboxExecHelp) {
|
||||
t.Fatalf("parseSandboxExecArgs(%q) = %q, %v; want the help sentinel", testCase.args, command, err)
|
||||
}
|
||||
case testCase.usage:
|
||||
if err == nil || errors.Is(err, errSandboxExecHelp) {
|
||||
t.Fatalf("parseSandboxExecArgs(%q) = %q, %v; want a usage error", testCase.args, command, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "usage:") {
|
||||
t.Errorf("a usage error must show the usage, got %q", err.Error())
|
||||
}
|
||||
default:
|
||||
if err != nil {
|
||||
t.Fatalf("parseSandboxExecArgs(%q) returned %v", testCase.args, err)
|
||||
}
|
||||
if strings.Join(command, "\x00") != strings.Join(testCase.command, "\x00") {
|
||||
t.Errorf("parseSandboxExecArgs(%q) = %q, want %q", testCase.args, command, testCase.command)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The wrapper must never consume a token it then fails to hand over. Whatever it
|
||||
// returns as the command has to be a trailing slice of what it was given, with
|
||||
// at most a leading separator removed, or an argument has gone missing on its
|
||||
// way to the child.
|
||||
func TestParseSandboxExecArgsNeverDropsAChildArgument(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--", "cmd", "--help", "-x", "--"},
|
||||
{"cmd", "--help"},
|
||||
{"ls", "--", "foo"},
|
||||
{"--", "--weird-binary", "arg"},
|
||||
} {
|
||||
command, err := parseSandboxExecArgs(args)
|
||||
if err != nil {
|
||||
t.Fatalf("parseSandboxExecArgs(%q) returned %v", args, err)
|
||||
}
|
||||
if len(command) == 0 {
|
||||
t.Fatalf("parseSandboxExecArgs(%q) returned an empty command", args)
|
||||
}
|
||||
suffix := strings.Join(args[len(args)-len(command):], "\x00")
|
||||
if strings.Join(command, "\x00") != suffix {
|
||||
t.Errorf("parseSandboxExecArgs(%q) = %q, which is not a trailing slice of the input; an argument was dropped or reordered", args, command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,10 +100,17 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo
|
||||
}
|
||||
profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope)
|
||||
setupConfig := sandbox.WindowsSandboxSetupConfig{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
PermissionProfile: profile,
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
// Same augmentation the setup args and the command plan apply, so doctor
|
||||
// fingerprints what a real command fingerprints. Safe here: doctor runs in
|
||||
// the operator's shell, not behind the sandbox's TEMP redirection.
|
||||
PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(profile, []string{workspaceRoot}),
|
||||
// Same opt-in a command would resolve, so doctor reports the principal
|
||||
// mismatch as out-of-date setup instead of passing a check the next command
|
||||
// will fail.
|
||||
PrincipalOptIn: sandbox.WindowsSandboxPrincipalOptIn(nil),
|
||||
}
|
||||
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{
|
||||
@@ -115,6 +122,53 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo
|
||||
})
|
||||
return &result
|
||||
}
|
||||
// Setup is valid and the opt-in is set, so report which principal a command
|
||||
// will actually run as.
|
||||
//
|
||||
// This used to warn that the principal stood down under the DEFAULT
|
||||
// network-deny policy, which was true while network denial could only be
|
||||
// expressed on the restricted token: a principal token cannot carry the
|
||||
// synthetic offline-marker SID the WFP filters matched. Dual-role provisioning
|
||||
// removed that limit by giving each workspace an offline account whose real
|
||||
// group membership the same filters match, so deny mode now runs on a
|
||||
// principal exactly as allow mode does. Leaving the warning would have doctor
|
||||
// telling operators their reads are unconfined while they are confined.
|
||||
//
|
||||
// Naming the role rather than just saying "active" is the useful part. The two
|
||||
// accounts differ only in that membership, so an operator debugging network
|
||||
// behaviour needs to know which one this workspace picks.
|
||||
if setupConfig.PrincipalOptIn {
|
||||
// Asked of the shared rule rather than re-derived here, so doctor and the
|
||||
// runtime cannot disagree about which account a command runs as. Two
|
||||
// copies drifting is what produced the wrong report this replaces.
|
||||
role := sandbox.WindowsSandboxPrincipalRoleForNetwork(profile.Network.Mode)
|
||||
// SETUP IS CURRENT IS NOT THE SAME AS THE PRINCIPAL IS LIVE, and this used
|
||||
// to claim the second from the first. Marker validation compares serialized
|
||||
// plans and hashes; it names no account. Delete the selected account or its
|
||||
// secret, or drop the offline account out of ZeroSandboxOffline, and the
|
||||
// marker still validates while the runtime either falls back to the weaker
|
||||
// restricted-token path or fails outright. Doctor asserting read
|
||||
// confinement in that state is worse than saying nothing, because it is the
|
||||
// surface an operator checks precisely when they are unsure.
|
||||
//
|
||||
// Verifying liveness needs the account, a usable secret and (for offline)
|
||||
// the group membership, all Windows-only live queries this package cannot
|
||||
// make. So the claim is narrowed to what the marker actually proves, and
|
||||
// the role is still named because that part IS derived rather than assumed
|
||||
// and is what an operator debugging network behaviour needs.
|
||||
result := check("sandbox.principal", "Sandbox principal", StatusPass,
|
||||
fmt.Sprintf("Sandbox principal setup is current; a command in this workspace selects the %s principal. Setup state does not confirm the account is usable.", role), map[string]any{
|
||||
"backend": string(backend.Name),
|
||||
"platform": goos,
|
||||
"optIn": true,
|
||||
// Deliberately not "active": nothing here checked the account, the
|
||||
// secret or the group membership.
|
||||
"setupCurrent": true,
|
||||
"role": role,
|
||||
"networkMode": string(profile.Network.Mode),
|
||||
})
|
||||
return &result
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// A VALID MARKER IS NOT A LIVE PRINCIPAL. Marker validation compares serialized
|
||||
// plans and hashes and names no account, so it stays valid after the selected
|
||||
// account is deleted, its secret is removed, or the offline account is dropped
|
||||
// out of ZeroSandboxOffline. In each of those states the runtime falls back to
|
||||
// the restricted-token path with no read confinement, or fails outright.
|
||||
//
|
||||
// Doctor is the surface an operator checks precisely when they are unsure, so
|
||||
// claiming read confinement it has not verified is worse than reporting less.
|
||||
// This pins the narrower claim: setup is current, the role is named because it
|
||||
// is derived rather than assumed, and nothing asserts the account is usable.
|
||||
func TestPrincipalCheckDoesNotClaimAnUnverifiedPrincipalIsActive(t *testing.T) {
|
||||
sandboxHome := t.TempDir()
|
||||
workspaceRoot := t.TempDir()
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", sandboxHome)
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_IDENTITY", "1")
|
||||
|
||||
sandboxConfig := config.SandboxConfig{}
|
||||
scope, err := sandbox.NewScope(workspaceRoot, sandboxConfig.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope)
|
||||
|
||||
// Mirrors what windowsSandboxSetupCheck derives, so the marker it writes is
|
||||
// the one the check will validate against. Nothing here provisions an
|
||||
// account, which is the whole point: the marker is valid and the principal
|
||||
// does not exist.
|
||||
if _, err := sandbox.WriteWindowsSandboxSetupMarker(sandbox.WindowsSandboxSetupConfig{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(profile, []string{workspaceRoot}),
|
||||
PrincipalOptIn: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
|
||||
}
|
||||
|
||||
result := windowsSandboxSetupCheck("windows", sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken}, workspaceRoot, sandboxConfig)
|
||||
if result == nil {
|
||||
t.Fatal("expected a principal check once the marker validates and the opt-in is set")
|
||||
}
|
||||
if result.ID != "sandbox.principal" {
|
||||
t.Fatalf("check = %q (%s): the marker did not validate, so this test is not exercising the principal branch", result.ID, result.Message)
|
||||
}
|
||||
|
||||
lowered := strings.ToLower(result.Message)
|
||||
for _, claim := range []string{"is active", "commands run as"} {
|
||||
if strings.Contains(lowered, claim) {
|
||||
t.Errorf("doctor asserts %q about a principal it never checked: %q", claim, result.Message)
|
||||
}
|
||||
}
|
||||
if active, ok := result.Details["active"]; ok {
|
||||
t.Errorf("details carry active=%v, which nothing here verified", active)
|
||||
}
|
||||
|
||||
// The role is still worth reporting: it is derived from the network mode by
|
||||
// the shared rule rather than assumed, and it is what an operator debugging
|
||||
// network behaviour needs.
|
||||
if role, ok := result.Details["role"]; !ok || strings.TrimSpace(role.(string)) == "" {
|
||||
t.Errorf("the selected role should still be reported: %#v", result.Details)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ensurePrivateDir(path string) error {
|
||||
func EnsurePrivateDir(path string) error {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func ensurePrivateDir(path string) error {
|
||||
func EnsurePrivateDir(path string) error {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestEnsurePrivateDirRejectsSymlink(t *testing.T) {
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensurePrivateDir(link); err == nil {
|
||||
if err := EnsurePrivateDir(link); err == nil {
|
||||
t.Fatal("expected symlink runtime directory to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func TestEnsurePrivateDirRejectsSymlinkedParent(t *testing.T) {
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensurePrivateDir(filepath.Join(link, "peers")); err == nil {
|
||||
if err := EnsurePrivateDir(filepath.Join(link, "peers")); err == nil {
|
||||
t.Fatal("expected symlinked parent to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func ensurePrivateDir(path string) (resultErr error) {
|
||||
func EnsurePrivateDir(path string) (resultErr error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestEnsurePrivateDirAppliesOwnerOnlyProtectedDACL(t *testing.T) {
|
||||
if !windowsDirectoryDACLContains(t, path, worldSID) {
|
||||
t.Fatal("test setup did not grant the broad Everyone ACE")
|
||||
}
|
||||
if err := ensurePrivateDir(path); err != nil {
|
||||
if err := EnsurePrivateDir(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
descriptor, err := windows.GetNamedSecurityInfo(
|
||||
@@ -149,7 +149,7 @@ func windowsDirectoryDACLContains(t *testing.T, path string, wanted *windows.SID
|
||||
|
||||
func TestSecurePrivateDirectoryRejectsOwnerMismatch(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "private")
|
||||
if err := ensurePrivateDir(path); err != nil {
|
||||
if err := EnsurePrivateDir(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle, err := openWindowsDirectory(path, windows.FILE_LIST_DIRECTORY|windows.FILE_TRAVERSE|windows.SYNCHRONIZE|windows.READ_CONTROL|windows.WRITE_DAC)
|
||||
@@ -172,7 +172,7 @@ func TestSecurePrivateDirectoryRejectsOwnerMismatch(t *testing.T) {
|
||||
|
||||
func TestSecurePrivateDirectoryReportsDACLWriteFailure(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "private")
|
||||
if err := ensurePrivateDir(path); err != nil {
|
||||
if err := EnsurePrivateDir(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle, err := openWindowsDirectory(path, windows.FILE_LIST_DIRECTORY|windows.FILE_TRAVERSE|windows.SYNCHRONIZE|windows.READ_CONTROL)
|
||||
@@ -206,7 +206,7 @@ func TestEnsurePrivateDirRejectsWindowsReparseParent(t *testing.T) {
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("cannot create Windows directory symlink: %v", err)
|
||||
}
|
||||
if err := ensurePrivateDir(filepath.Join(link, "peers")); err == nil {
|
||||
if err := EnsurePrivateDir(filepath.Join(link, "peers")); err == nil {
|
||||
t.Fatal("expected reparse-point parent to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func TestEnsurePrivateDirRejectsWindowsFileComponent(t *testing.T) {
|
||||
if err := os.WriteFile(file, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensurePrivateDir(filepath.Join(file, "peers")); err == nil {
|
||||
if err := EnsurePrivateDir(filepath.Join(file, "peers")); err == nil {
|
||||
t.Fatal("expected file path component to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func New(options Options) (*Service, error) {
|
||||
}
|
||||
|
||||
// canonicalRuntimePath normalizes aliases in the existing prefix. It does not
|
||||
// establish a security boundary; ensurePrivateDir validates that boundary when
|
||||
// establish a security boundary; EnsurePrivateDir validates that boundary when
|
||||
// the service starts.
|
||||
func canonicalRuntimePath(path string) (string, error) {
|
||||
missing := make([]string, 0, 4)
|
||||
@@ -219,10 +219,10 @@ func (service *Service) Start(handler Handler) error {
|
||||
if service.closed {
|
||||
return errors.New("peer messaging: service is closed")
|
||||
}
|
||||
if err := ensurePrivateDir(service.root); err != nil {
|
||||
if err := EnsurePrivateDir(service.root); err != nil {
|
||||
return fmt.Errorf("peer messaging: create runtime directory: %w", err)
|
||||
}
|
||||
if err := ensurePrivateDir(service.registryDir()); err != nil {
|
||||
if err := EnsurePrivateDir(service.registryDir()); err != nil {
|
||||
return fmt.Errorf("peer messaging: create registry: %w", err)
|
||||
}
|
||||
endpoint, err := service.transport.Endpoint(service.root, service.nonce, service.pid)
|
||||
|
||||
@@ -44,7 +44,7 @@ func canonicalPrivateDir(path string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := ensurePrivateDir(canonical); err != nil {
|
||||
if err := EnsurePrivateDir(canonical); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return canonical, nil
|
||||
|
||||
@@ -96,15 +96,104 @@ var protectedMetadataNames = []string{".git", ".zero", ".agents"}
|
||||
// gitMetadataWriteCarveouts below.
|
||||
var sandboxFullyProtectedMetadataNames = []string{".zero", ".agents"}
|
||||
|
||||
// sandboxRenameProtectedMetadataName is the metadata directory that cannot be
|
||||
// fully write-protected but must still not be REPLACEABLE.
|
||||
//
|
||||
// It is deliberately not in the list above. That list denies write, and git has
|
||||
// to write index, objects and refs. But the carveouts guarding it are attached
|
||||
// to .git/config and .git/hooks as objects, so a principal that renames .git and
|
||||
// recreates it gets fresh paths inheriting the workspace allow with no denies,
|
||||
// which restores credential.helper and core.hooksPath. The Windows ACL plan
|
||||
// therefore denies DELETE on this directory alone, uninherited.
|
||||
const sandboxRenameProtectedMetadataName = ".git"
|
||||
|
||||
// gitMetadataWriteCarveouts returns the .git subpaths that stay write-denied
|
||||
// under the OS-level sandbox even though the rest of .git is writable to git
|
||||
// subprocesses. Nonexistent paths are harmless no-ops in every backend's
|
||||
// enforcement (seatbelt regex, bwrap ro-bind, Windows ACL deny entry).
|
||||
func gitMetadataWriteCarveouts(root string) []string {
|
||||
return []string{
|
||||
filepath.Join(root, ".git", "hooks"),
|
||||
filepath.Join(root, ".git", "config"),
|
||||
specs := gitMetadataWriteCarveoutSpecs(root)
|
||||
out := make([]string, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
out = append(out, spec.Path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// gitMetadataCarveout is a write-denied .git path together with the shape git
|
||||
// expects it to have. The shape matters to exactly one backend: the Windows ACL
|
||||
// plan creates a missing carveout so the deny ACE is in place before git first
|
||||
// runs, and creating .git/config as a directory makes `git init` fail outright.
|
||||
// Every other backend only ever names the path, so it can ignore IsFile.
|
||||
type gitMetadataCarveout struct {
|
||||
Path string
|
||||
IsFile bool
|
||||
}
|
||||
|
||||
// gitMetadataWriteCarveoutSpecs is the single source of truth for the carveout
|
||||
// set. gitMetadataWriteCarveouts derives its list from this so a path can never
|
||||
// be added in one place and have its shape forgotten in the other.
|
||||
func gitMetadataWriteCarveoutSpecs(root string) []gitMetadataCarveout {
|
||||
gitPath := filepath.Join(root, ".git")
|
||||
// A LINKED WORKTREE OR SUBMODULE HAS .git AS A FILE, NOT A DIRECTORY.
|
||||
//
|
||||
// It holds a `gitdir:` pointer to the real control directory, which lives
|
||||
// outside this write root. Naming .git/config and .git/hooks there asks the
|
||||
// Windows plan to materialize them by descending through .git as a directory,
|
||||
// which cannot open a child beneath a regular file, so opted-in elevated setup
|
||||
// aborts and the sandbox is unusable in any worktree. Zero's own development
|
||||
// worktrees are exactly this shape.
|
||||
//
|
||||
// Deny the pointer file itself instead. It is the right protection rather than
|
||||
// a lesser one: a principal that can rewrite `gitdir:` repoints the whole
|
||||
// repository at a control directory it chooses, which subsumes editing config
|
||||
// or dropping a hook. The real control directory is outside the write root, so
|
||||
// the principal has no inherited access to it and needs no carveout there.
|
||||
//
|
||||
// Stat, not a lexical guess. Whether .git is a file is a property of the
|
||||
// checkout, and the two layouts want different ACEs. A missing .git (git has
|
||||
// not run yet) keeps the directory-shaped carveouts, which is what makes them
|
||||
// materialize before git first runs.
|
||||
if info, err := os.Lstat(gitPath); err == nil && !info.IsDir() {
|
||||
return []gitMetadataCarveout{{Path: gitPath, IsFile: true}}
|
||||
}
|
||||
return []gitMetadataCarveout{
|
||||
{Path: filepath.Join(root, ".git", "hooks")},
|
||||
{Path: filepath.Join(root, ".git", "config"), IsFile: true},
|
||||
}
|
||||
}
|
||||
|
||||
// gitMetadataCarveoutSuffixBase is a sentinel root used only to recover the
|
||||
// trailing segments of the carveout specs. It is never touched on disk.
|
||||
const gitMetadataCarveoutSuffixBase = string(filepath.Separator) + "zero-carveout-base"
|
||||
|
||||
// gitMetadataCarveoutIsFile reports whether path names a carveout git expects
|
||||
// to be a file.
|
||||
//
|
||||
// It matches on the trailing segments rather than on a whole reconstructed
|
||||
// path. The subpaths reaching the ACL plan are already normalized — resolved
|
||||
// through EvalSymlinks where that succeeds — while a rebuilt spec path cannot
|
||||
// be, because .git/config does not exist yet at setup and resolution falls back
|
||||
// to a plain Clean. On a host where two spellings of the same path differ (an
|
||||
// 8.3 short name, different casing) a whole-path equality check silently misses
|
||||
// and the carveout is created as a directory again, which is the original bug
|
||||
// reintroduced quietly. The suffix cannot drift from the spec list because it
|
||||
// is derived from it.
|
||||
func gitMetadataCarveoutIsFile(path string) bool {
|
||||
candidate := strings.ToLower(filepath.Clean(strings.TrimSpace(path)))
|
||||
if candidate == "" {
|
||||
return false
|
||||
}
|
||||
for _, spec := range gitMetadataWriteCarveoutSpecs(gitMetadataCarveoutSuffixBase) {
|
||||
if !spec.IsFile {
|
||||
continue
|
||||
}
|
||||
suffix := strings.ToLower(strings.TrimPrefix(spec.Path, gitMetadataCarveoutSuffixBase))
|
||||
if suffix != "" && strings.HasSuffix(candidate, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Scope) PermissionProfile {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A LINKED WORKTREE HAS .git AS A FILE.
|
||||
//
|
||||
// The Windows principal plan materializes every carveout, and it does so by
|
||||
// descending through .git as a directory. Against a `gitdir:` pointer file that
|
||||
// walk cannot open a child, so opted-in elevated setup aborts and the sandbox
|
||||
// cannot be used in a worktree at all. Zero's own development worktrees have
|
||||
// exactly this layout, so this is the common case for anyone working on the
|
||||
// sandbox itself.
|
||||
func TestGitCarveoutsHandleAWorktreeGitfile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
gitFile := filepath.Join(root, ".git")
|
||||
// The real shape git writes for a linked worktree.
|
||||
if err := os.WriteFile(gitFile, []byte("gitdir: "+filepath.Join(root, "..", "main", ".git", "worktrees", "wt")+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
specs := gitMetadataWriteCarveoutSpecs(root)
|
||||
if len(specs) != 1 {
|
||||
t.Fatalf("carveout specs = %#v, want only the .git pointer file", specs)
|
||||
}
|
||||
if filepath.Clean(specs[0].Path) != filepath.Clean(gitFile) {
|
||||
t.Fatalf("carveout path = %s, want the .git pointer file %s", specs[0].Path, gitFile)
|
||||
}
|
||||
if !specs[0].IsFile {
|
||||
t.Fatal("the .git pointer carveout is not marked as a file, so materialization would create a directory where git needs a file")
|
||||
}
|
||||
// Nothing may name a path BENEATH the pointer file, which is what forces the
|
||||
// directory descent that aborts setup.
|
||||
for _, path := range gitMetadataWriteCarveouts(root) {
|
||||
rest := strings.TrimPrefix(filepath.Clean(path), filepath.Clean(gitFile))
|
||||
if rest != "" {
|
||||
t.Errorf("carveout %s descends through the .git pointer file, which cannot have children", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary repository layout must keep the config and hooks carveouts. They
|
||||
// are the whole reason the set exists: without them a principal reinstates
|
||||
// credential.helper or core.hooksPath through inherited workspace write access.
|
||||
func TestGitCarveoutsKeepConfigAndHooksForARealGitDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
paths := gitMetadataWriteCarveouts(root)
|
||||
for _, want := range []string{filepath.Join(root, ".git", "hooks"), filepath.Join(root, ".git", "config")} {
|
||||
found := false
|
||||
for _, got := range paths {
|
||||
if filepath.Clean(got) == filepath.Clean(want) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("carveout %s missing for an ordinary .git directory: %#v", want, paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace where git has never run keeps the directory-shaped carveouts. That
|
||||
// is what lets the Windows plan create them before git first runs, so the deny
|
||||
// ACEs are already in place rather than being applied to paths git will create
|
||||
// later with inherited write access.
|
||||
func TestGitCarveoutsKeepConfigAndHooksWhenGitIsAbsent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
paths := gitMetadataWriteCarveouts(root)
|
||||
if len(paths) != 2 {
|
||||
t.Fatalf("carveouts = %#v, want the config and hooks pair when .git is absent", paths)
|
||||
}
|
||||
}
|
||||
@@ -352,6 +352,15 @@ func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) strin
|
||||
|
||||
func runWindowsRealSmokeSetup(t *testing.T, setupExe string, options WindowsSandboxSetupArgsOptions) {
|
||||
t.Helper()
|
||||
// options.PrincipalOptIn is deliberately left nil by both call sites, which
|
||||
// makes BuildWindowsSandboxSetupArgs resolve the opt-in from this process's
|
||||
// environment — the same value the command half resolves, since the smoke
|
||||
// WindowsSandboxCommandArgsOptions carries no explicit entry either. Do not
|
||||
// "fix" this by setting it to false: anyone running this suite with
|
||||
// ZERO_WINDOWS_SANDBOX_IDENTITY=1 (the only way to exercise the principal
|
||||
// backend) would then serialize `--sandbox-principal 0`, disagree with the
|
||||
// command half, and fail every command at marker validation instead of
|
||||
// testing the sandbox.
|
||||
args, err := BuildWindowsSandboxSetupArgs(options)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// physicalTempDir returns os.TempDir with every symlink in its ancestry
|
||||
// resolved, so the fallback anchor's parent chain is physical and only the
|
||||
// anchor itself is a new component.
|
||||
//
|
||||
// peermsg.EnsurePrivateDir walks from the root with O_NOFOLLOW and refuses any
|
||||
// component that is a link. That is right for the owned tail and wrong for the
|
||||
// ancestors above it: on macOS os.TempDir lives under /var, which is a symlink
|
||||
// to /private/var, so the first version of this fix refused every fallback on
|
||||
// every Mac with "refusing non-directory or symlink runtime path component
|
||||
// var". The constraint jatmn stated for #901 applies here identically:
|
||||
// redirected cache and TEMP locations above the owned tail are the operator's
|
||||
// business and must keep working; the restriction is on what Zero owns.
|
||||
//
|
||||
// EvalSymlinks is the correct resolver off Windows, where the only reparse
|
||||
// shape is a symlink and it traverses them.
|
||||
func physicalTempDir() (string, error) {
|
||||
return physicalDir(os.TempDir())
|
||||
}
|
||||
|
||||
// physicalDir resolves any directory the same way, for the other places that
|
||||
// have to hand EnsurePrivateDir a physical parent.
|
||||
func physicalDir(path string) (string, error) {
|
||||
return filepath.EvalSymlinks(path)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// physicalTempDir returns os.TempDir as the object it actually names, so the
|
||||
// fallback anchor's parent chain is physical and only the anchor itself is a
|
||||
// new component.
|
||||
//
|
||||
// peermsg.EnsurePrivateDir walks from the volume root with OBJ_DONT_REPARSE and
|
||||
// refuses any component that is a reparse point. That is right for the owned
|
||||
// tail and wrong for the ancestors above it: a redirected %LOCALAPPDATA% is an
|
||||
// ordinary Windows configuration, and TEMP sits beneath it. The constraint
|
||||
// jatmn stated for #901 applies here identically: redirected cache and TEMP
|
||||
// locations above the owned tail must keep working; the restriction is on what
|
||||
// Zero owns.
|
||||
//
|
||||
// GetFinalPathNameByHandle rather than filepath.EvalSymlinks, because
|
||||
// EvalSymlinks does not traverse a junction on Windows: it returns the
|
||||
// junction's own path and the walker then refuses it. The handle answers where
|
||||
// the directory actually is. Same recipe as verifyWindowsACLTargetNotRedirected,
|
||||
// which is why the flag constants and the prefix trim are shared.
|
||||
func physicalTempDir() (string, error) {
|
||||
return physicalDir(os.TempDir())
|
||||
}
|
||||
|
||||
// physicalDir resolves any directory through a handle, for the other places
|
||||
// that have to hand EnsurePrivateDir a physical parent. GetFinalPathNameByHandle
|
||||
// rather than EvalSymlinks, because EvalSymlinks does not traverse a junction.
|
||||
func physicalDir(path string) (string, error) {
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode directory %s: %w", path, err)
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
utf16Path,
|
||||
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,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open directory %s: %w", path, err)
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
buffer := make([]uint16, windows.MAX_LONG_PATH)
|
||||
n, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), windowsFileNameNormalized|windowsVolumeNameDOS)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve directory %s: %w", path, err)
|
||||
}
|
||||
if int(n) < len(buffer) {
|
||||
buffer = buffer[:n]
|
||||
}
|
||||
return trimWindowsExtendedPathPrefix(windows.UTF16ToString(buffer)), nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// DETERMINISM IS NOT OWNERSHIP.
|
||||
//
|
||||
// The fallback runtime root moved from a fresh os.MkdirTemp parent to a
|
||||
// predictable name so that elevated setup and the later command agree on it.
|
||||
// That is required. But a predictable path beneath the shared system temp is
|
||||
// not a private allocation: whatever another local user, or a stray earlier
|
||||
// process, has already placed at that name is what preparation then leases,
|
||||
// creates, chmods and cleans through. The anchor beneath which the tree lives
|
||||
// must therefore be proven to be this user's private directory before anything
|
||||
// is built under it, on BOTH paths that can hand back a fallback root.
|
||||
//
|
||||
// Junctions need no privilege on Windows, so the "attacker-precreated parent"
|
||||
// jatmn described is modelled as a junction at the anchor pointing into a
|
||||
// directory the test owns and watches. Either trigger must refuse, and the
|
||||
// watched directory must stay empty.
|
||||
|
||||
// fallbackAnchorFixture redirects TEMP so the anchor lands inside a directory
|
||||
// this test owns, plants a junction at the anchor into a watched target, and
|
||||
// returns the target. The junction is the shape that defeats a pathname walk:
|
||||
// it is not a symlink to os.ModeSymlink, and EvalSymlinks will not traverse it.
|
||||
func fallbackAnchorFixture(t *testing.T) (target string) {
|
||||
t.Helper()
|
||||
tempRoot := t.TempDir()
|
||||
target = t.TempDir()
|
||||
t.Setenv("TMP", tempRoot)
|
||||
t.Setenv("TEMP", tempRoot)
|
||||
|
||||
// The anchor's parent is the PHYSICAL temp dir, so compare against the
|
||||
// resolved form of the redirect rather than its spelling: t.TempDir can
|
||||
// come back as an 8.3 short name or in a different case from what the
|
||||
// handle reports, and a prefix check on the raw string would fail for a
|
||||
// correct anchor.
|
||||
anchor := fallbackRuntimeAnchor()
|
||||
resolvedTempRoot, err := physicalTempDir()
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: cannot resolve the redirected TEMP %s: %v", tempRoot, err)
|
||||
}
|
||||
if !pathWithinRoot(resolvedTempRoot, anchor) {
|
||||
t.Fatalf("SETUP INVALID: anchor %s is not under the redirected TEMP %s (resolved %s)", anchor, tempRoot, resolvedTempRoot)
|
||||
}
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", anchor, target).CombinedOutput(); err != nil {
|
||||
t.Fatalf("mklink /J: %v\n%s", err, out)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func assertNothingUnder(t *testing.T, target string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Errorf("preparation built %v beneath the junction target, which is somebody else's directory", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger one: the user cache resolves INSIDE the workspace, so
|
||||
// sandboxRuntimeRootFor itself returns the fallback root and the first lease
|
||||
// attempt is already against it.
|
||||
func TestFallbackAnchorIsRefusedWhenTheCacheLandsInsideTheWorkspace(t *testing.T) {
|
||||
target := fallbackAnchorFixture(t)
|
||||
workspace := t.TempDir()
|
||||
|
||||
previous := sandboxUserCacheDir
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
|
||||
|
||||
_, release, err := prepareSandboxRuntime(workspace)
|
||||
if err == nil {
|
||||
release()
|
||||
t.Fatal("preparation went through a junction at the fallback anchor and reported success")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "fallback anchor") {
|
||||
t.Errorf("the refusal does not name the anchor, so the operator cannot act on it: %v", err)
|
||||
}
|
||||
assertNothingUnder(t, target)
|
||||
}
|
||||
|
||||
// Trigger two: the cache root cannot be leased (a FILE sits where the cache
|
||||
// runtime tree would go), so preparation falls back explicitly.
|
||||
func TestFallbackAnchorIsRefusedWhenTheCacheRootCannotBeLeased(t *testing.T) {
|
||||
target := fallbackAnchorFixture(t)
|
||||
workspace := t.TempDir()
|
||||
|
||||
// Make the cache-derived root unleasable at its ROOT: the user cache is a
|
||||
// regular file, so nothing beneath it can be created and the first lease
|
||||
// fails, which is the path that asks for the fallback explicitly. The
|
||||
// first draft planted the file at the derived leaf instead, and the
|
||||
// preparation failed on that mkdir before ever reaching the fallback
|
||||
// branch, so the trigger under test never fired.
|
||||
cache := filepath.Join(t.TempDir(), "cache-is-a-file")
|
||||
if err := os.WriteFile(cache, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
previous := sandboxUserCacheDir
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
sandboxUserCacheDir = func() (string, error) { return cache, nil }
|
||||
|
||||
_, release, err := prepareSandboxRuntime(workspace)
|
||||
if err == nil {
|
||||
release()
|
||||
t.Fatal("preparation fell back through a junction at the anchor and reported success")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "fallback anchor") {
|
||||
t.Errorf("the refusal does not name the anchor: %v", err)
|
||||
}
|
||||
assertNothingUnder(t, target)
|
||||
}
|
||||
|
||||
// And the honest control: with nothing planted, the fallback prepares, the
|
||||
// anchor is a real directory this test can see, and the runtime tree sits
|
||||
// beneath it.
|
||||
func TestFallbackAnchorIsCreatedPrivatelyWhenNothingIsInTheWay(t *testing.T) {
|
||||
tempRoot := t.TempDir()
|
||||
t.Setenv("TMP", tempRoot)
|
||||
t.Setenv("TEMP", tempRoot)
|
||||
workspace := t.TempDir()
|
||||
|
||||
previous := sandboxUserCacheDir
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
|
||||
|
||||
runtimeState, release, err := prepareSandboxRuntime(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("a clean fallback was refused: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
anchor := fallbackRuntimeAnchor()
|
||||
info, err := os.Lstat(anchor)
|
||||
if err != nil || !info.IsDir() {
|
||||
t.Fatalf("the anchor was not created as a directory: err=%v", err)
|
||||
}
|
||||
if !pathWithinRoot(anchor, runtimeState.Root) {
|
||||
t.Errorf("runtime root %s is not beneath the anchor %s", runtimeState.Root, anchor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// SETUP AND EVERY COMMAND RUNNER MUST DERIVE THE SAME FALLBACK ROOT.
|
||||
//
|
||||
// They are separate processes. The fallback used to call os.MkdirTemp and cache
|
||||
// the answer in a process-global map, so elevated setup granted the sandbox
|
||||
// principal write access to the directory IT made, and each later
|
||||
// __windows-command-runner made a different one and pointed TMP, GOCACHE and npm
|
||||
// there. Those directories are created by the calling user and carry no ACE for
|
||||
// the principal, so ordinary cache writes failed with a bare ACCESS_DENIED.
|
||||
//
|
||||
// A repeated call is what a second process looks like from here: no shared
|
||||
// state, same inputs. The old implementation could not pass this, because the
|
||||
// only thing that made repeat calls agree was the in-process map.
|
||||
func TestFallbackRuntimeRootIsTheSameForEveryProcess(t *testing.T) {
|
||||
workspace := filepath.Join(t.TempDir(), "ws")
|
||||
|
||||
first, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("fallbackSandboxRuntimeRoot: %v", err)
|
||||
}
|
||||
second, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("fallbackSandboxRuntimeRoot (second process): %v", err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatalf("two processes derived different runtime roots, so setup grants one and commands write to another:\n %s\n %s", first, second)
|
||||
}
|
||||
if first == "" {
|
||||
t.Fatal("no runtime root derived")
|
||||
}
|
||||
}
|
||||
|
||||
// Different workspaces must not share a runtime tree, or one workspace's
|
||||
// principal would hold an ACE on another's caches.
|
||||
func TestFallbackRuntimeRootIsPerWorkspace(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
first, err := fallbackSandboxRuntimeRoot(filepath.Join(base, "alpha"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := fallbackSandboxRuntimeRoot(filepath.Join(base, "beta"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first == second {
|
||||
t.Fatalf("two workspaces share the runtime root %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
// It must NAME the tree without creating it. Teardown asks for this path on its
|
||||
// way out, and materializing a directory there would leave one behind on every
|
||||
// teardown.
|
||||
func TestFallbackRuntimeRootCreatesNothing(t *testing.T) {
|
||||
workspace := filepath.Join(t.TempDir(), "ws")
|
||||
root, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pathExists(root) {
|
||||
t.Errorf("naming the runtime root created %s", root)
|
||||
}
|
||||
}
|
||||
|
||||
// The root must land outside the workspace, which is the entire reason this
|
||||
// branch exists.
|
||||
func TestFallbackRuntimeRootStaysOutsideTheWorkspace(t *testing.T) {
|
||||
workspace := filepath.Join(t.TempDir(), "ws")
|
||||
root, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pathWithinRoot(workspace, root) {
|
||||
t.Errorf("runtime root %q is inside workspace %q", root, workspace)
|
||||
}
|
||||
if !strings.Contains(root, "zero") {
|
||||
t.Errorf("runtime root %q does not look like a zero-owned tree", root)
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,14 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/peermsg"
|
||||
)
|
||||
|
||||
var sandboxUserCacheDir = os.UserCacheDir
|
||||
@@ -21,11 +25,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,8 +32,37 @@ type SandboxRuntime struct {
|
||||
Temp string `json:"temp,omitempty"`
|
||||
}
|
||||
|
||||
// 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]))
|
||||
return root, !pathWithinRoot(workspaceRoot, root)
|
||||
}
|
||||
|
||||
func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) {
|
||||
workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot))
|
||||
workspaceRoot = canonicalSandboxWorkspaceRoot(workspaceRoot)
|
||||
if workspaceRoot == "" || workspaceRoot == "." {
|
||||
return SandboxRuntime{}, nil, errors.New("sandbox runtime requires a workspace root")
|
||||
}
|
||||
@@ -42,17 +70,30 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, fmt.Errorf("resolve user cache directory: %w", err)
|
||||
}
|
||||
cacheRoot = filepath.Clean(strings.TrimSpace(cacheRoot))
|
||||
// Canonicalized the SAME way as the workspace root, because
|
||||
// sandboxRuntimeRootFor compares the two: it falls back to a private temp
|
||||
// tree when the derived runtime root would land inside the workspace.
|
||||
// Normalizing only one side made that comparison run on two different
|
||||
// spellings of the same path — /var vs /private/var on macOS, an 8.3 short
|
||||
// name vs its long form on Windows — so the containment check missed and the
|
||||
// fallback never fired.
|
||||
cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot)
|
||||
if cacheRoot == "" || cacheRoot == "." {
|
||||
return SandboxRuntime{}, nil, errors.New("user cache directory is unavailable")
|
||||
}
|
||||
digest := sha256.Sum256([]byte(workspaceRoot))
|
||||
root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8]))
|
||||
if pathWithinRoot(workspaceRoot, root) {
|
||||
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
root, err := sandboxRuntimeRootFor(workspaceRoot, cacheRoot)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
// THE ANCHOR IS PROVEN BEFORE THE TREE IS BUILT, on both paths that can
|
||||
// hand back a fallback root: sandboxRuntimeRootFor returns one itself when
|
||||
// the cache lands inside the workspace, and the branch below asks for one
|
||||
// when the cache root cannot be leased. A deterministic name under the
|
||||
// shared temp is only safe beneath a directory this user owns; if the anchor
|
||||
// is a link, or exists and belongs to someone else, this refuses rather than
|
||||
// leasing, creating and chmod-ing through it.
|
||||
if err := ensureFallbackRuntimeAnchor(root); err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
lease, err := prepareSandboxRuntimeLease(root)
|
||||
if err != nil {
|
||||
@@ -60,6 +101,9 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
if err := ensureFallbackRuntimeAnchor(root); err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
}
|
||||
lease, err = prepareSandboxRuntimeLease(root)
|
||||
if err != nil {
|
||||
return SandboxRuntime{}, nil, err
|
||||
@@ -174,22 +218,109 @@ func combineSandboxCleanups(cleanups ...func()) func() {
|
||||
}
|
||||
}
|
||||
|
||||
// fallbackRuntimeAnchor is the per-user directory the deterministic fallback
|
||||
// runtime tree lives beneath.
|
||||
//
|
||||
// DETERMINISM IS NOT OWNERSHIP. The fallback moved from a fresh, private
|
||||
// os.MkdirTemp parent to a predictable name so that elevated setup and the
|
||||
// later command process agree on the runtime root. That agreement is required
|
||||
// and must stay. But a predictable path directly under the shared system temp
|
||||
// is not a private allocation: on an ordinary shared /tmp another local user
|
||||
// can create /tmp/zero with mode 0700 first, and every affected user then fails
|
||||
// before the sandbox command starts; a pre-existing redirected component makes
|
||||
// host-side preparation operate somewhere other than the derived tree.
|
||||
//
|
||||
// So the tree is rooted beneath an anchor that names THIS user, and the anchor
|
||||
// is created and validated by peermsg.EnsurePrivateDir before anything is
|
||||
// built under it: handle-relative descent, no-follow, refused if any component
|
||||
// is a link or the leaf is not owned by the current user, mode 0700. On Unix
|
||||
// the uid is in the name because /tmp is shared between users; on Windows
|
||||
// os.TempDir is already per-user, and the private descriptor plus the owner
|
||||
// check do the rest.
|
||||
func fallbackRuntimeAnchor() string {
|
||||
tag := "user"
|
||||
if runtime.GOOS != "windows" {
|
||||
tag = strconv.Itoa(os.Getuid())
|
||||
}
|
||||
// PHYSICAL PARENT, OWNED LEAF. The validator walks every component no-follow
|
||||
// and refuses links, which is right for the anchor Zero owns and wrong for
|
||||
// the operator's ancestors above it: macOS puts TempDir under /var, a
|
||||
// symlink to /private/var, and a redirected %LOCALAPPDATA% is ordinary on
|
||||
// Windows. The first version handed the validator the unresolved path and
|
||||
// refused every fallback on every Mac. Resolving the parent first means the
|
||||
// only new component the validator sees is the anchor itself.
|
||||
//
|
||||
// On a resolve error the unresolved path is used, deliberately: the
|
||||
// validator is still the fail-closed check, and refusing there names the
|
||||
// real component rather than hiding it behind a resolver failure.
|
||||
base := os.TempDir()
|
||||
if physical, err := physicalTempDir(); err == nil && physical != "" {
|
||||
base = physical
|
||||
}
|
||||
return filepath.Join(base, "zero-runtime-"+tag)
|
||||
}
|
||||
|
||||
// isFallbackRuntimeRoot reports whether root was derived by
|
||||
// fallbackSandboxRuntimeRoot, so the caller knows the anchor must be validated
|
||||
// before the tree beneath it is prepared. Both candidates a command derives can
|
||||
// be fallback roots: sandboxRuntimeRootFor returns one itself when the cache
|
||||
// lands inside the workspace, and prepareSandboxRuntime asks for one when the
|
||||
// cache root cannot be leased.
|
||||
func isFallbackRuntimeRoot(root string) bool {
|
||||
// pathWithinRoot(parent, child): is root beneath the anchor. The first
|
||||
// draft had these reversed, which asked whether the anchor was beneath the
|
||||
// root, answered false every time, and skipped the validation entirely
|
||||
// while every test that depended on it reported success through a
|
||||
// junction. Argument order is the whole function.
|
||||
return pathWithinRoot(fallbackRuntimeAnchor(), root)
|
||||
}
|
||||
|
||||
// ensureFallbackRuntimeAnchor validates the anchor beneath which a fallback
|
||||
// runtime tree is about to be built, and fails closed. Called only when the
|
||||
// root is a fallback root; the cache-derived root lives under the user cache,
|
||||
// which is already the user's own directory.
|
||||
func ensureFallbackRuntimeAnchor(root string) error {
|
||||
if !isFallbackRuntimeRoot(root) {
|
||||
return nil
|
||||
}
|
||||
anchor := fallbackRuntimeAnchor()
|
||||
if err := peermsg.EnsurePrivateDir(anchor); err != nil {
|
||||
return fmt.Errorf("sandbox runtime fallback anchor %s is not a private directory owned by this user: %w", anchor, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
parent, err := os.MkdirTemp("", "zero-runtime-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create fallback sandbox runtime: %w", err)
|
||||
}
|
||||
root := filepath.Join(parent, "runtime")
|
||||
digest := sha256.Sum256([]byte(workspaceRoot))
|
||||
root := filepath.Join(fallbackRuntimeAnchor(), "v1", hex.EncodeToString(digest[:8]))
|
||||
if pathWithinRoot(workspaceRoot, root) {
|
||||
_ = os.RemoveAll(parent)
|
||||
return "", fmt.Errorf("fallback sandbox runtime root %q must be outside workspace %q", root, workspaceRoot)
|
||||
// Both candidates land inside the workspace, so there is nowhere left to
|
||||
// put a runtime tree the workspace's own policy does not govern. Refused
|
||||
// rather than pointed somewhere arbitrary: a runtime root inside the
|
||||
// workspace makes the sandbox's own cache writes indistinguishable from
|
||||
// the work it is meant to be confining.
|
||||
return "", fmt.Errorf("sandbox runtime root %q would fall inside workspace %q; "+
|
||||
"open the workspace somewhere other than the cache or temp directory", root, workspaceRoot)
|
||||
}
|
||||
fallbackSandboxRuntimes.roots[workspaceRoot] = root
|
||||
return root, nil
|
||||
}
|
||||
|
||||
@@ -226,3 +357,59 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -329,3 +329,79 @@ func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) {
|
||||
}
|
||||
cleanupLease.release()
|
||||
}
|
||||
|
||||
// sandboxRuntimeRootFor compares the workspace root against the derived runtime
|
||||
// root to decide whether to fall back to a private temp tree. Both sides
|
||||
// therefore have to be the same spelling of the same path.
|
||||
//
|
||||
// Canonicalizing only the workspace root broke this on CI: the workspace
|
||||
// resolved (/var to /private/var on macOS, an 8.3 short name to its long form
|
||||
// on Windows) while the cache root kept its original spelling, so the
|
||||
// containment check compared two different strings, the fallback never fired,
|
||||
// and the runtime tree was placed inside the workspace it exists to stay out of.
|
||||
//
|
||||
// A symlink is the portable way to produce a spelling that only resolution
|
||||
// reconciles — Clean cannot see through one. Windows refuses to create symlinks
|
||||
// without privilege, so this skips there; the platforms that CI caught the bug
|
||||
// on are the ones that run it.
|
||||
func TestPrepareSandboxRuntimeNormalizesTheCacheRootBeforeComparingIt(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
link := filepath.Join(t.TempDir(), "workspace-link")
|
||||
if err := os.Symlink(workspace, link); err != nil {
|
||||
t.Skipf("cannot create a symlink here: %v", err)
|
||||
}
|
||||
// The cache root reaches us spelled through the symlink; the workspace does
|
||||
// not. Resolved, it is plainly inside the workspace and the fallback must
|
||||
// fire. Unresolved, the two strings share no prefix and it does not.
|
||||
original := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return filepath.Join(link, ".cache"), nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = original })
|
||||
|
||||
runtimeState, release, err := prepareSandboxRuntime(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("prepareSandboxRuntime: %v", err)
|
||||
}
|
||||
if release != nil {
|
||||
defer release()
|
||||
}
|
||||
// Resolve before comparing. Spelled through the link the runtime root shares
|
||||
// no textual prefix with the workspace, so an unresolved comparison would
|
||||
// call it "outside" while it sits physically inside — the test would pass
|
||||
// against the very bug it exists for.
|
||||
resolved := runtimeState.Root
|
||||
if actual, err := filepath.EvalSymlinks(runtimeState.Root); err == nil {
|
||||
resolved = actual
|
||||
}
|
||||
if pathWithinRoot(workspace, resolved) {
|
||||
t.Fatalf("runtime root %q resolves to %q, inside workspace %q; the containment check did not see through the cache root's spelling", runtimeState.Root, resolved, workspace)
|
||||
}
|
||||
}
|
||||
|
||||
// A path whose final segments do not exist yet must still normalize the same
|
||||
// way as one that does. This is the shape macOS CI hit: t.TempDir() sits under
|
||||
// /var, a symlink to /private/var, and the cache root it derives has not been
|
||||
// created when it is first normalized. Resolving only the workspace left the
|
||||
// two sides of the containment check spelled differently.
|
||||
func TestCanonicalSandboxWorkspaceRootResolvesThroughAMissingLeaf(t *testing.T) {
|
||||
real := t.TempDir()
|
||||
link := filepath.Join(t.TempDir(), "link")
|
||||
if err := os.Symlink(real, link); err != nil {
|
||||
t.Skipf("cannot create a symlink here: %v", err)
|
||||
}
|
||||
|
||||
existing := canonicalSandboxWorkspaceRoot(link)
|
||||
if existing != canonicalSandboxWorkspaceRoot(real) {
|
||||
t.Fatalf("an existing symlinked dir did not resolve: %q vs %q", existing, canonicalSandboxWorkspaceRoot(real))
|
||||
}
|
||||
|
||||
// The leaf, and its parent, do not exist.
|
||||
missing := filepath.Join(link, ".cache", "zero")
|
||||
got := canonicalSandboxWorkspaceRoot(missing)
|
||||
want := filepath.Join(existing, ".cache", "zero")
|
||||
if got != want {
|
||||
t.Errorf("missing leaf normalized to %q, want %q — the ancestor was not resolved", got, want)
|
||||
}
|
||||
if !pathWithinRoot(existing, got) {
|
||||
t.Errorf("%q should be inside %q once both are canonical", got, existing)
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,77 @@ package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isWindowsVolumeRoot reports whether a cleaned path is the top of a volume,
|
||||
// with nothing above it: `C:\`, a bare separator, or a UNC share root.
|
||||
//
|
||||
// Detected structurally rather than by pattern matching drive letters, because
|
||||
// filepath.Dir of a root is that same root and of anything else is strictly
|
||||
// shorter. That holds for drive-qualified paths, for the separator alone, and
|
||||
// for UNC roots, on either build host.
|
||||
func isWindowsVolumeRoot(path string) bool {
|
||||
cleaned := filepath.Clean(strings.TrimSpace(path))
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return false
|
||||
}
|
||||
return filepath.Dir(cleaned) == cleaned
|
||||
}
|
||||
|
||||
// validateWindowsACLComponent rejects anything that is not a single path
|
||||
// component.
|
||||
//
|
||||
// This is load-bearing in two places, which is why it lives in the portable file
|
||||
// rather than beside either of them.
|
||||
//
|
||||
// At apply time NtCreateFile happily resolves a RELATIVE name containing
|
||||
// separators, and it resolves it the ordinary way, so an intermediate junction
|
||||
// inside that name is followed and the object lands outside the pinned parent.
|
||||
// A name with a separator reopens exactly the hole the parent handle exists to
|
||||
// close.
|
||||
//
|
||||
// At plan time the same shape escapes the write root: a name is joined onto the
|
||||
// root to place a deny ACE, so ".." or a separator puts that ACE on a directory
|
||||
// outside the workspace entirely.
|
||||
//
|
||||
// The separators are checked explicitly rather than via filepath.Base, because
|
||||
// these are Windows paths whatever the build host is, and on Linux
|
||||
// filepath.Base leaves a backslash-joined name untouched and would wave it
|
||||
// through. A colon is rejected too: it names an alternate data stream or a
|
||||
// drive, neither of which is a child.
|
||||
func validateWindowsACLComponent(name string) error {
|
||||
switch {
|
||||
case name == "":
|
||||
return errors.New("windows ACL path component is empty")
|
||||
case name == "." || name == "..":
|
||||
return fmt.Errorf("windows ACL path component %q is a relative reference, not a child", name)
|
||||
case strings.ContainsAny(name, `\/`):
|
||||
return fmt.Errorf("windows ACL path component %q contains a separator, so it would resolve through intermediate directories instead of staying a child", name)
|
||||
case strings.Contains(name, ":"):
|
||||
return fmt.Errorf("windows ACL path component %q contains a colon, which names a stream or a drive rather than a child", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WindowsACLAction string
|
||||
|
||||
const (
|
||||
WindowsACLAllowWrite WindowsACLAction = "allow-write"
|
||||
WindowsACLDenyRead WindowsACLAction = "deny-read"
|
||||
WindowsACLDenyWrite WindowsACLAction = "deny-write"
|
||||
// WindowsACLDenyDelete denies removing or renaming the object it names,
|
||||
// WITHOUT denying writes to it or inside it, and without inheriting.
|
||||
//
|
||||
// It exists for .git. The write-denied carveouts live on .git/config and
|
||||
// .git/hooks as objects, so replacing the .git directory discards them: the
|
||||
// recreated config and hooks inherit the workspace allow with no deny, which
|
||||
// restores credential.helper and core.hooksPath. .git cannot simply join
|
||||
// sandboxFullyProtectedMetadataNames, because DenyWrite's mask includes
|
||||
// FILE_GENERIC_WRITE and git must write index, objects and refs.
|
||||
WindowsACLDenyDelete WindowsACLAction = "deny-delete"
|
||||
)
|
||||
|
||||
type WindowsACLEntry struct {
|
||||
@@ -19,6 +80,11 @@ type WindowsACLEntry struct {
|
||||
Path string `json:"path"`
|
||||
Capability string `json:"capability"`
|
||||
Materialize bool `json:"materialize,omitempty"`
|
||||
// MaterializeFile makes Materialize create an empty FILE instead of a
|
||||
// directory. Only meaningful with Materialize. .git/config is the case that
|
||||
// forces the distinction: created as a directory it does not merely carry
|
||||
// the wrong ACL, it makes `git init` fail outright.
|
||||
MaterializeFile bool `json:"materializeFile,omitempty"`
|
||||
}
|
||||
|
||||
type WindowsACLPlan struct {
|
||||
@@ -40,6 +106,15 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er
|
||||
Path: capability.Root,
|
||||
Capability: capability.SID,
|
||||
})
|
||||
// The grant above carries DELETE, so the same object guard the principal
|
||||
// plan has must come with it: not DenyWrite (git writes index, objects and
|
||||
// refs), not materialized (git creates .git, and an empty one breaks
|
||||
// git init), and not inherited, so everything underneath stays writable.
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLDenyDelete,
|
||||
Path: windowsRenameProtectedObject(capability.Root),
|
||||
Capability: capability.SID,
|
||||
})
|
||||
for _, path := range capability.ProtectedWriteDenyPaths {
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLDenyWrite,
|
||||
@@ -48,6 +123,31 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er
|
||||
})
|
||||
}
|
||||
}
|
||||
// Read roots, granted to the read-capability SID.
|
||||
//
|
||||
// A profile carrying DenyRead runs the command on a strict token rather than a
|
||||
// WRITE_RESTRICTED one, and the strict token applies the restricted-SID check
|
||||
// to reads. Read roots reached that check granted only to the principal's own
|
||||
// account SID, which the write jail keeps out of the restricting set on
|
||||
// purpose, so every read failed it — including opening the executable. The
|
||||
// principal plan still grants the account SID; this is the other half of the
|
||||
// same grant, so the allow-list and the restriction now come from one place.
|
||||
readSID, err := windowsReadAllowCapabilitySID(config)
|
||||
if err != nil {
|
||||
return WindowsACLPlan{}, err
|
||||
}
|
||||
if readSID != "" {
|
||||
for _, path := range config.PermissionProfile.FileSystem.ReadRoots {
|
||||
if path = strings.TrimSpace(path); path == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLAllowRead,
|
||||
Path: path,
|
||||
Capability: readSID,
|
||||
})
|
||||
}
|
||||
}
|
||||
writeSIDs := windowsWriteCapabilitySIDs(writeCapabilities)
|
||||
for _, path := range config.PermissionProfile.FileSystem.DenyWrite {
|
||||
path = strings.TrimSpace(path)
|
||||
@@ -79,6 +179,39 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er
|
||||
return WindowsACLPlan{Entries: dedupeWindowsACLEntries(entries)}, nil
|
||||
}
|
||||
|
||||
// windowsPlanVolumeRootGrant returns the first volume root the plan would have
|
||||
// to change permissions on, or empty when it needs none.
|
||||
//
|
||||
// Only elevated setup can write a volume-root DACL, so this is the one entry
|
||||
// that decides whether a plan is applicable by the unelevated tier at all. The
|
||||
// check is on the PLAN rather than on the profile because the plan is what gets
|
||||
// applied: a future entry that lands at a volume root for some other reason is
|
||||
// caught by the same test.
|
||||
func windowsPlanVolumeRootGrant(plan WindowsACLPlan) string {
|
||||
for _, entry := range plan.Entries {
|
||||
if isWindowsVolumeRoot(normalizeProfilePath(entry.Path)) {
|
||||
return entry.Path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// windowsRenameProtectedObject names the object whose DELETE must be denied on
|
||||
// a write root, whatever trustee holds the grant.
|
||||
//
|
||||
// ONE DERIVATION, BECAUSE TWO PLANNERS CONSUME IT. The allow-write mask both
|
||||
// backends share includes DELETE, and it inherits from the write root onto .git.
|
||||
// The carveouts that actually protect git live on .git/config and .git/hooks as
|
||||
// OBJECTS, so renaming .git aside and recreating it discards them: the fresh
|
||||
// config and hooks inherit the workspace allow with no deny of their own, which
|
||||
// hands back credential.helper and core.hooksPath. The principal planner denied
|
||||
// DELETE here and the capability planner did not, so the default restricted-token
|
||||
// backend was missing the guard entirely. Deriving it in one place is what stops
|
||||
// a change to the shared mask from updating one consumer and not the other.
|
||||
func windowsRenameProtectedObject(root string) string {
|
||||
return filepath.Join(root, sandboxRenameProtectedMetadataName)
|
||||
}
|
||||
|
||||
type windowsWriteRootCapability struct {
|
||||
Root string
|
||||
SID string
|
||||
@@ -140,17 +273,32 @@ func windowsWriteCapabilitySIDs(capabilities []windowsWriteRootCapability) []str
|
||||
}
|
||||
|
||||
func windowsReadDenyCapabilitySIDs(config WindowsSandboxCommandConfig, writeSIDs []string) ([]string, error) {
|
||||
if len(writeSIDs) > 0 {
|
||||
return writeSIDs, nil
|
||||
}
|
||||
if len(config.PermissionProfile.FileSystem.DenyRead) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
caps, err := LoadOrCreateWindowsCapabilitySIDs(config.SandboxHome)
|
||||
// The read-capability SID must be denied here as well as granted above. It
|
||||
// holds read on the read roots, and those start at the filesystem root, so a
|
||||
// carveout sitting inside one of them would stay readable through that grant
|
||||
// and the deny list would quietly stop meaning anything.
|
||||
// Every SID the token can carry has to be denied, not just the newest one.
|
||||
// With no write roots the token's only capability is ReadOnly, so dropping it
|
||||
// here would leave the deny naming a SID the command never holds.
|
||||
denySIDs := append([]string{}, writeSIDs...)
|
||||
if len(denySIDs) == 0 {
|
||||
caps, err := LoadOrCreateWindowsCapabilitySIDs(config.SandboxHome)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
denySIDs = append(denySIDs, caps.ReadOnly)
|
||||
}
|
||||
readSID, err := windowsReadAllowCapabilitySID(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{caps.ReadOnly}, nil
|
||||
if readSID != "" {
|
||||
denySIDs = append(denySIDs, readSID)
|
||||
}
|
||||
return denySIDs, nil
|
||||
}
|
||||
|
||||
func planWindowsDenyReadPaths(paths []string) []string {
|
||||
@@ -193,3 +341,19 @@ func dedupeWindowsACLEntries(entries []WindowsACLEntry) []WindowsACLEntry {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// windowsReadAllowCapabilitySID returns the read-capability SID when this profile
|
||||
// will run on a strict token, and "" otherwise.
|
||||
//
|
||||
// Gated on DenyRead because that is exactly what selects the strict token in the
|
||||
// runner: with no DenyRead the token stays WRITE_RESTRICTED, reads skip the
|
||||
// restricted-SID check entirely, and granting read on roots as broad as the
|
||||
// filesystem root would add ACEs that buy nothing. Derived purely from the
|
||||
// profile, so the setup half and the command half reach the same answer without
|
||||
// consulting anything ambient.
|
||||
func windowsReadAllowCapabilitySID(config WindowsSandboxCommandConfig) (string, error) {
|
||||
if len(config.PermissionProfile.FileSystem.DenyRead) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return WindowsReadAllowSID(config.SandboxHome)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -15,28 +16,121 @@ import (
|
||||
const windowsFileDeleteChild windows.ACCESS_MASK = 0x00000040
|
||||
|
||||
type windowsACLPathGroup struct {
|
||||
Path string
|
||||
Entries []WindowsACLEntry
|
||||
Materialize bool
|
||||
Path string
|
||||
Entries []WindowsACLEntry
|
||||
Materialize bool
|
||||
MaterializeFile bool
|
||||
}
|
||||
|
||||
// windowsACLChainStep is one directory component beneath the anchor, and
|
||||
// whether THIS run created it. The flag comes from the kernel rather than from
|
||||
// "it was missing when we looked", because something else can win the gap
|
||||
// between the probe and the create, and removing a directory the sandbox did not
|
||||
// make is how a rollback deletes a user's data.
|
||||
type windowsACLChainStep struct {
|
||||
Name string
|
||||
Made bool
|
||||
}
|
||||
|
||||
// windowsACLMaterialization is exactly what materialization created, recorded in
|
||||
// the shape rollback needs to undo it without resolving a single pathname below
|
||||
// the anchor.
|
||||
//
|
||||
// The anchor is the deepest directory that already existed, and it is the ONLY
|
||||
// pathname rollback re-resolves. Everything under it is a list of single
|
||||
// components walked one handle at a time, because a name containing a separator
|
||||
// is resolved the ordinary way by the kernel and would follow an intermediate
|
||||
// junction straight out of the approved tree.
|
||||
type windowsACLMaterialization struct {
|
||||
AnchorPath string
|
||||
AnchorID windowsFileIdentity
|
||||
// Chain is every component between the anchor and the target, shallow to
|
||||
// deep. All of them are needed to descend at rollback time; only the ones
|
||||
// with Made set are removed.
|
||||
Chain []windowsACLChainStep
|
||||
// File is the leaf file component created inside the deepest Chain entry,
|
||||
// for the .git/config carveout. Empty when the target is a directory.
|
||||
File string
|
||||
FileMade bool
|
||||
}
|
||||
|
||||
func (materialization windowsACLMaterialization) createdAnything() bool {
|
||||
if materialization.FileMade {
|
||||
return true
|
||||
}
|
||||
for _, step := range materialization.Chain {
|
||||
if step.Made {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// windowsACLRestoreHook observes restore order. Nil in production; the ordering
|
||||
// it exposes is load-bearing and was previously pinned by nothing at all,
|
||||
// despite a comment claiming otherwise.
|
||||
var windowsACLRestoreHook func(path string)
|
||||
|
||||
type windowsACLSnapshot struct {
|
||||
Path string
|
||||
Descriptor *windows.SECURITY_DESCRIPTOR
|
||||
Materialized bool
|
||||
Path string
|
||||
Descriptor *windows.SECURITY_DESCRIPTOR
|
||||
Created windowsACLMaterialization
|
||||
// TargetID is the object the DACL was read from, so the restore can prove it
|
||||
// is writing back to that same object rather than to whatever the pathname
|
||||
// resolves to at rollback time.
|
||||
//
|
||||
// A no-follow re-open catches a REPARSE POINT swapped in since apply, and
|
||||
// that is what the restore relied on. It cannot catch the other
|
||||
// substitution: rename the target aside and put an ordinary directory of the
|
||||
// same name in its place. Nothing about that decoy is a reparse point, so the
|
||||
// re-open succeeds and the old DACL is written onto the attacker's object
|
||||
// while the real one keeps the aborted setup's ACEs.
|
||||
//
|
||||
// Volume serial plus file index is the same identity the materialization
|
||||
// unwind already anchors on. See reopenWindowsACLDirectoryAsIdentity.
|
||||
TargetID windowsFileIdentity
|
||||
}
|
||||
|
||||
func applyWindowsACLPlan(plan WindowsACLPlan) (func() error, error) {
|
||||
groups := groupWindowsACLPlanByPath(plan)
|
||||
snapshots := make([]windowsACLSnapshot, 0, len(groups))
|
||||
abort := func(err error) (func() error, error) {
|
||||
if rollbackErr := rollbackWindowsACLSnapshots(snapshots); rollbackErr != nil {
|
||||
return nil, fmt.Errorf("%w; rollback failed: %v", err, rollbackErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var deferred []windowsACLPathGroup
|
||||
for _, group := range groups {
|
||||
snapshot, applied, err := applyWindowsACLPathGroup(group)
|
||||
if err != nil {
|
||||
rollbackErr := rollbackWindowsACLSnapshots(snapshots)
|
||||
if rollbackErr != nil {
|
||||
return nil, fmt.Errorf("%w; rollback failed: %v", err, rollbackErr)
|
||||
}
|
||||
return nil, err
|
||||
return abort(err)
|
||||
}
|
||||
if applied {
|
||||
snapshots = append(snapshots, snapshot)
|
||||
continue
|
||||
}
|
||||
deferred = append(deferred, group)
|
||||
}
|
||||
|
||||
// A group that applied to nothing was skipped because its target did not
|
||||
// exist and the group does not materialize one. A LATER group can still
|
||||
// create it, so the skip has to be retried rather than treated as final.
|
||||
//
|
||||
// The .git rename guard is exactly this shape and was silently absent
|
||||
// because of it. .git must NOT be materialized, since an empty one breaks
|
||||
// `git init`, so its deny-delete group carries no Materialize. But .git does
|
||||
// get created, as the parent chain of the .git\config carveout, and that
|
||||
// group sorts AFTER it. So on every workspace that did not already have a
|
||||
// .git, the guard was skipped, the directory appeared moments later, and
|
||||
// nothing went back for it: the principal could then rename .git aside and
|
||||
// shed the config and hooks carveouts, which is the escape the guard exists
|
||||
// to stop. Groups that are genuinely absent simply skip again here.
|
||||
for _, group := range deferred {
|
||||
snapshot, applied, err := applyWindowsACLPathGroup(group)
|
||||
if err != nil {
|
||||
return abort(err)
|
||||
}
|
||||
if applied {
|
||||
snapshots = append(snapshots, snapshot)
|
||||
@@ -61,6 +155,7 @@ func groupWindowsACLPlanByPath(plan WindowsACLPlan) []windowsACLPathGroup {
|
||||
}
|
||||
group.Entries = append(group.Entries, entry)
|
||||
group.Materialize = group.Materialize || entry.Materialize
|
||||
group.MaterializeFile = group.MaterializeFile || entry.MaterializeFile
|
||||
}
|
||||
out := make([]windowsACLPathGroup, 0, len(byPath))
|
||||
for _, group := range byPath {
|
||||
@@ -84,8 +179,40 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo
|
||||
// elevated setup a lower-privileged local user could swap the target for a
|
||||
// symlink/junction between operations and redirect the ACL change onto a
|
||||
// system object it never validated (issue #728, a TOCTOU privilege boundary).
|
||||
materialized := false
|
||||
handle, isDir, err := openWindowsACLTarget(path)
|
||||
// Reject a malformed group BEFORE touching the filesystem. This validation
|
||||
// used to run after materialization, so a bad SID or an unknown action
|
||||
// created a directory chain and only then failed, leaving the error path to
|
||||
// unwind work that never needed doing. Nothing here depends on isDir: that
|
||||
// argument only selects the inheritance flag, while the errors come from the
|
||||
// action lookup and the SID parse.
|
||||
if _, err := windowsExplicitAccessEntries(group.Entries, false); err != nil {
|
||||
return windowsACLSnapshot{}, false, err
|
||||
}
|
||||
|
||||
var created windowsACLMaterialization
|
||||
var handle windows.Handle
|
||||
// Every exit from here goes through one closure, because there are now two
|
||||
// things to undo rather than one: the open handle, and whatever
|
||||
// materialization created. Both are captured by reference and both start
|
||||
// zero, so calling this before either is set is safe and does nothing.
|
||||
//
|
||||
// The unwind is handle-relative. It must never fall back to a pathname
|
||||
// delete: the failure being cleaned up here can BE the path swap, and
|
||||
// os.RemoveAll on a swapped ancestor is precisely the recursive elevated
|
||||
// delete outside the workspace that this cleanup is supposed to prevent.
|
||||
fail := func(err error) (windowsACLSnapshot, bool, error) {
|
||||
if handle != 0 {
|
||||
_ = windows.CloseHandle(handle)
|
||||
}
|
||||
if unwindErr := rollbackWindowsACLMaterialization(created); unwindErr != nil {
|
||||
return windowsACLSnapshot{}, false, fmt.Errorf("%w; cleanup failed: %v", err, unwindErr)
|
||||
}
|
||||
return windowsACLSnapshot{}, false, err
|
||||
}
|
||||
|
||||
var isDir bool
|
||||
var err error
|
||||
handle, isDir, err = openWindowsACLTarget(path)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return windowsACLSnapshot{}, false, err
|
||||
@@ -96,25 +223,19 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo
|
||||
}
|
||||
return windowsACLSnapshot{}, false, nil
|
||||
}
|
||||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||||
return windowsACLSnapshot{}, false, fmt.Errorf("materialize windows ACL target %s: %w", path, err)
|
||||
// created is assigned even on failure: materialization reports what it
|
||||
// managed to make before it stopped, and fail() unwinds exactly that.
|
||||
created, err = materializeWindowsACLTarget(path, group.MaterializeFile)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("materialize windows ACL target %s: %w", path, err))
|
||||
}
|
||||
materialized = true
|
||||
handle, isDir, err = openWindowsACLTarget(path)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(path)
|
||||
return windowsACLSnapshot{}, false, fmt.Errorf("open materialized windows ACL target %s: %w", path, err)
|
||||
// This is the branch that fires when the post-create verify catches a
|
||||
// swap, so it is the single most important cleanup in the file.
|
||||
return fail(fmt.Errorf("open materialized windows ACL target %s: %w", path, err))
|
||||
}
|
||||
}
|
||||
// From here the handle is open; every early return must close it first (and
|
||||
// remove a freshly materialized target) so a failure leaks neither.
|
||||
fail := func(err error) (windowsACLSnapshot, bool, error) {
|
||||
_ = windows.CloseHandle(handle)
|
||||
if materialized {
|
||||
_ = os.RemoveAll(path)
|
||||
}
|
||||
return windowsACLSnapshot{}, false, err
|
||||
}
|
||||
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("read windows ACL for %s: %w", path, err))
|
||||
@@ -138,8 +259,14 @@ 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.
|
||||
// Captured BEFORE the handle closes, because that handle is the only thing
|
||||
// that names the object rather than the path.
|
||||
targetID, err := windowsIdentityOfHandle(handle)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("read windows ACL target identity for %s: %w", path, err))
|
||||
}
|
||||
_ = windows.CloseHandle(handle)
|
||||
return windowsACLSnapshot{Path: path, Descriptor: descriptor, Materialized: materialized}, true, nil
|
||||
return windowsACLSnapshot{Path: path, Descriptor: descriptor, Created: created, TargetID: targetID}, true, nil
|
||||
}
|
||||
|
||||
// openWindowsACLTarget opens path for reading and rewriting its DACL without
|
||||
@@ -178,6 +305,12 @@ func openWindowsACLTarget(path string) (windows.Handle, bool, error) {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, false, fmt.Errorf("refusing to apply ACL to reparse-point target %s: possible path swap during elevated setup", path)
|
||||
}
|
||||
// Ancestors are resolved by CreateFile even with FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
// so the check above is not enough on its own.
|
||||
if err := verifyWindowsACLTargetNotRedirected(handle, path); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, false, err
|
||||
}
|
||||
isDir := info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0
|
||||
return handle, isDir, nil
|
||||
}
|
||||
@@ -206,10 +339,19 @@ func windowsExplicitAccessEntries(entries []WindowsACLEntry, isDir bool) ([]wind
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entryInheritance := inheritance
|
||||
// DenyDelete governs the object it names and nothing beneath it. Inherited
|
||||
// onto .git's children it would deny DELETE on every file inside, so git
|
||||
// could not remove a lock file, a ref, or anything else it rewrites, and
|
||||
// the guard would read as a broken repository rather than as a blocked
|
||||
// rename.
|
||||
if entry.Action == WindowsACLDenyDelete {
|
||||
entryInheritance = windows.NO_INHERITANCE
|
||||
}
|
||||
out = append(out, windows.EXPLICIT_ACCESS{
|
||||
AccessPermissions: permissions,
|
||||
AccessMode: accessMode,
|
||||
Inheritance: inheritance,
|
||||
Inheritance: entryInheritance,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_GROUP,
|
||||
@@ -223,11 +365,59 @@ func windowsExplicitAccessEntries(entries []WindowsACLEntry, isDir bool) ([]wind
|
||||
func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACCESS_MASK, error) {
|
||||
switch action {
|
||||
case WindowsACLAllowWrite:
|
||||
return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE, nil
|
||||
// DELETE is part of the grant, not an extra. FILE_GENERIC_WRITE covers
|
||||
// creating and modifying but not removing or renaming, and a rename needs
|
||||
// delete access on the source. Under the old same-user token that gap was
|
||||
// invisible, because the caller already held inherited rights on its own
|
||||
// tree; a sandbox principal is a separate account with no such
|
||||
// inheritance, so without DELETE it can write a file it can never delete.
|
||||
// Ordinary editing and most git operations rewrite files by replacing
|
||||
// them, so the omission fails normal work rather than an edge case.
|
||||
//
|
||||
// FILE_DELETE_CHILD is deliberately NOT granted, for the same reason
|
||||
// WRITE_DAC and WRITE_OWNER are not. On a parent it authorises deleting a
|
||||
// child whatever the child's own DACL says, so granting it on a write root
|
||||
// hands back the write-denied carve-outs underneath it: a principal could
|
||||
// delete .git/config and recreate it, and the replacement inherits this
|
||||
// grant with no deny of its own — restoring exactly the credential.helper
|
||||
// and core.hooksPath control the carve-out exists to prevent.
|
||||
//
|
||||
// It was granted here originally to keep the mask symmetric with
|
||||
// WindowsACLDenyWrite, which does treat FILE_DELETE_CHILD as part of
|
||||
// write. Symmetry is the wrong goal: denying a capability is not a reason
|
||||
// to grant it. DELETE alone covers removing and renaming files the
|
||||
// principal owns inside its roots, which is what the grant is for.
|
||||
return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE | windows.DELETE, nil
|
||||
case WindowsACLAllowRead:
|
||||
// Read and traverse without write. A sandbox principal is a separate
|
||||
// account with no inherent access to the caller's tree, so a read-only
|
||||
// root has to be granted rather than assumed. Deliberately omits
|
||||
// FILE_GENERIC_WRITE, DELETE and WRITE_DAC.
|
||||
return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE, nil
|
||||
case windowsACLRevoke:
|
||||
// REVOKE_ACCESS drops every ACE naming the trustee regardless of the mask,
|
||||
// so the mask is ignored here. Used to retire a principal without having
|
||||
// to remember which access each path was granted.
|
||||
return windows.REVOKE_ACCESS, 0, nil
|
||||
case WindowsACLDenyRead:
|
||||
return windows.DENY_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE, nil
|
||||
case WindowsACLDenyWrite:
|
||||
return windows.DENY_ACCESS, windows.FILE_GENERIC_WRITE | windows.DELETE | windowsFileDeleteChild | windows.WRITE_DAC | windows.WRITE_OWNER, nil
|
||||
case WindowsACLDenyDelete:
|
||||
// Deny removing or RENAMING the object itself, nothing more. Renaming a
|
||||
// directory needs DELETE on that directory, so denying DELETE is what
|
||||
// stops .git being moved aside and recreated without its carveouts.
|
||||
//
|
||||
// WRITE_DAC and WRITE_OWNER come along because a guard the principal can
|
||||
// rewrite, or take ownership of and then rewrite, is not a guard.
|
||||
//
|
||||
// FILE_GENERIC_WRITE is deliberately absent: git writes index, objects and
|
||||
// refs constantly, and denying it would break every commit rather than the
|
||||
// rename. FILE_DELETE_CHILD is absent for the same reason one level down,
|
||||
// since git deletes its own lock files and refs. Neither is needed here:
|
||||
// this ACE does not inherit (see windowsExplicitAccessEntries), so it
|
||||
// governs the .git directory object alone.
|
||||
return windows.DENY_ACCESS, windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER, nil
|
||||
default:
|
||||
return 0, 0, fmt.Errorf("unsupported windows ACL action %q", action)
|
||||
}
|
||||
@@ -235,11 +425,18 @@ func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACC
|
||||
|
||||
func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error {
|
||||
var errs []error
|
||||
// Reverse order is load-bearing twice over. Groups are sorted ascending by
|
||||
// path key and an ancestor key is always a proper prefix of its descendants,
|
||||
// so walking backwards unwinds descendant before ancestor: a materialized
|
||||
// directory is therefore empty by the time its own removal is attempted.
|
||||
// Restoring ACLs in the same order is independently right, because
|
||||
// SetSecurityInfo propagates inheritable ACEs down, so the ancestor must go
|
||||
// last. TestRollbackUnwindsDescendantsBeforeAncestors pins it.
|
||||
for index := len(snapshots) - 1; index >= 0; index-- {
|
||||
snapshot := snapshots[index]
|
||||
if snapshot.Materialized {
|
||||
if err := os.RemoveAll(snapshot.Path); err != nil {
|
||||
errs = append(errs, fmt.Errorf("remove materialized windows ACL target %s: %w", snapshot.Path, err))
|
||||
if snapshot.Created.createdAnything() {
|
||||
if err := rollbackWindowsACLMaterialization(snapshot.Created); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -248,16 +445,36 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error {
|
||||
errs = append(errs, fmt.Errorf("read rollback windows DACL for %s: %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.
|
||||
// Re-open no-follow AND prove it is the same object. The no-follow open
|
||||
// alone only rules out a reparse point swapped in since apply; a target
|
||||
// renamed aside and replaced by an ordinary directory of the same name
|
||||
// passes it, and the old DACL would land on the decoy while the real
|
||||
// object kept the aborted setup's ACEs. See windowsACLSnapshot.TargetID.
|
||||
handle, _, err := openWindowsACLTarget(snapshot.Path)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("re-open windows ACL target %s for rollback: %w", snapshot.Path, err))
|
||||
continue
|
||||
}
|
||||
if !snapshot.TargetID.empty() {
|
||||
got, identityErr := windowsIdentityOfHandle(handle)
|
||||
if identityErr != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
errs = append(errs, fmt.Errorf("read rollback identity for %s: %w", snapshot.Path, identityErr))
|
||||
continue
|
||||
}
|
||||
if got != snapshot.TargetID {
|
||||
// REFUSED, not forced through. Leaving the real object with the
|
||||
// aborted setup's ACEs is the safe direction: the caller is failing
|
||||
// anyway, and writing a stale DACL onto an object setup never
|
||||
// touched is the one outcome rollback must not produce.
|
||||
_ = windows.CloseHandle(handle)
|
||||
errs = append(errs, fmt.Errorf("refusing to restore windows ACL for %s: it is no longer the object setup applied to", snapshot.Path))
|
||||
continue
|
||||
}
|
||||
}
|
||||
if windowsACLRestoreHook != nil {
|
||||
windowsACLRestoreHook(snapshot.Path)
|
||||
}
|
||||
if err := windows.SetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil {
|
||||
errs = append(errs, fmt.Errorf("rollback windows ACL for %s: %w", snapshot.Path, err))
|
||||
}
|
||||
@@ -265,3 +482,217 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error {
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// materializeWindowsACLTarget creates a missing ACL target with the shape the
|
||||
// owning tool expects. A directory target is created whole; a file target gets
|
||||
// its parent chain created and then an empty file, because creating it as a
|
||||
// directory would break the tool that owns it rather than just mis-ACL it.
|
||||
// The returned record is meaningful even when the error is non-nil: a chain that
|
||||
// got three levels deep and then failed still has three levels to unwind.
|
||||
func materializeWindowsACLTarget(path string, asFile bool) (windowsACLMaterialization, error) {
|
||||
directory := path
|
||||
leaf := ""
|
||||
if asFile {
|
||||
directory = filepath.Dir(path)
|
||||
leaf = filepath.Base(path)
|
||||
}
|
||||
created, parent, err := makeWindowsACLDirChainNoFollow(directory)
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
if !asFile {
|
||||
return created, nil
|
||||
}
|
||||
// A racing creator winning is still fine: the target exists, which is all
|
||||
// materialization needed. createWindowsACLChildFile reports that as
|
||||
// created=false, so rollback will not delete a file the sandbox did not make.
|
||||
created.File = leaf
|
||||
madeFile, err := createWindowsACLChildFile(parent, leaf)
|
||||
created.FileMade = madeFile
|
||||
return created, err
|
||||
}
|
||||
|
||||
// rollbackWindowsACLMaterialization removes exactly what materialization
|
||||
// created, deepest first, without resolving any pathname below the anchor.
|
||||
//
|
||||
// This is the other half of the pathname problem. The old cleanup called
|
||||
// os.RemoveAll on the target pathname, which re-resolves every ancestor at the
|
||||
// moment it runs, so an ancestor swapped to a junction after the object was
|
||||
// created sent a recursive elevated delete into an unrelated tree. It also only
|
||||
// ever removed the final component, leaving every intermediate directory the
|
||||
// chain had created behind.
|
||||
//
|
||||
// The anchor is the one pathname that has to be resolved again, and it is
|
||||
// checked by file identity rather than by name: replacing a directory with
|
||||
// another REAL directory of the same name needs no reparse point at all and
|
||||
// would otherwise pass every no-follow check there is.
|
||||
//
|
||||
// Residue is preferable to over-deletion throughout. When something cannot be
|
||||
// removed safely this reports it and leaves it, and never falls back to a
|
||||
// pathname delete.
|
||||
func rollbackWindowsACLMaterialization(materialization windowsACLMaterialization) error {
|
||||
if !materialization.createdAnything() {
|
||||
return nil
|
||||
}
|
||||
anchor, err := reopenWindowsACLDirectoryAsIdentity(materialization.AnchorPath, materialization.AnchorID)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// The anchor is gone, so everything created beneath it is gone too.
|
||||
// Nothing to undo, and no way to undo it if there were.
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unwind windows ACL materialization under %s: %w", materialization.AnchorPath, err)
|
||||
}
|
||||
|
||||
// One handle per level: handles[i] is the parent of Chain[i], which is what
|
||||
// deleting Chain[i] relative to a pinned parent requires.
|
||||
handles := []windows.Handle{anchor}
|
||||
defer func() {
|
||||
for _, handle := range handles {
|
||||
if handle != 0 {
|
||||
_ = windows.CloseHandle(handle)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Descend only as far as is actually required. Removing a directory needs its
|
||||
// PARENT's handle, not its own, so the deepest component is opened only when
|
||||
// a file leaf lives inside it. This is not just economy: the deepest
|
||||
// component is usually the ACL target itself, so it may already carry the
|
||||
// deny-read ACE this rollback is undoing, and opening it would be refused by
|
||||
// the very ACL being unwound.
|
||||
needed := len(materialization.Chain)
|
||||
if !materialization.FileMade && needed > 0 {
|
||||
needed--
|
||||
}
|
||||
depth := 0
|
||||
for ; depth < needed; depth++ {
|
||||
child, err := openWindowsACLChildDirectory(handles[depth], materialization.Chain[depth].Name)
|
||||
if err != nil {
|
||||
// Already removed by something else. Stop descending; whatever is
|
||||
// below it is gone with it.
|
||||
if isWindowsNotExist(err) {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("unwind windows ACL materialization under %s: %w", materialization.AnchorPath, err)
|
||||
}
|
||||
handles = append(handles, child)
|
||||
}
|
||||
|
||||
var errs []error
|
||||
// The file leaf lives inside the deepest chain directory, so it goes first
|
||||
// and only if the descent actually reached that far.
|
||||
if materialization.FileMade && depth == needed {
|
||||
if err := deleteWindowsACLChildFile(handles[len(handles)-1], materialization.File); err != nil {
|
||||
errs = append(errs, fmt.Errorf("remove materialized windows ACL file %s: %w", materialization.File, err))
|
||||
}
|
||||
}
|
||||
// Chain[i] is removed through handles[i], so the deepest one that can be
|
||||
// removed is bounded by how far the descent actually got.
|
||||
deepest := len(handles) - 1
|
||||
if last := len(materialization.Chain) - 1; deepest > last {
|
||||
deepest = last
|
||||
}
|
||||
for index := deepest; index >= 0; index-- {
|
||||
// Close the child's own handle, if the descent opened one, before asking
|
||||
// its parent to remove it. A directory with a live handle open still
|
||||
// counts as present, so the parent's delete would come back as non-empty.
|
||||
if index+1 < len(handles) && handles[index+1] != 0 {
|
||||
_ = windows.CloseHandle(handles[index+1])
|
||||
handles[index+1] = 0
|
||||
}
|
||||
if !materialization.Chain[index].Made {
|
||||
continue
|
||||
}
|
||||
if err := deleteWindowsACLChildDirectory(handles[index], materialization.Chain[index].Name); err != nil {
|
||||
errs = append(errs, fmt.Errorf("remove materialized windows ACL directory %s: %w", materialization.Chain[index].Name, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// windowsACLMaterializeSwapHook is a test seam and nothing else. It fires inside
|
||||
// makeWindowsACLDirChainNoFollow at the exact instant the race used to be
|
||||
// exploitable: the anchor is verified and pinned, and nothing has been created
|
||||
// yet. A race reproducible only by luck is not a regression test, so the instant
|
||||
// is made addressable rather than hoped for. Always nil in production.
|
||||
var windowsACLMaterializeSwapHook func(anchor string)
|
||||
|
||||
// makeWindowsACLDirChainNoFollow is an os.MkdirAll that never resolves a
|
||||
// pathname below its anchor.
|
||||
//
|
||||
// It walks UP to the deepest ancestor that already exists and opens it
|
||||
// no-follow. Because GetFinalPathNameByHandle answers for the whole resolved
|
||||
// path, that single check clears every ancestor above it too. Then it walks back
|
||||
// DOWN, creating one component at a time relative to the HANDLE of the level
|
||||
// above, so the tree it descends is pinned to objects rather than named by
|
||||
// strings.
|
||||
//
|
||||
// That is the difference that matters. This used to verify a component by
|
||||
// pathname, close the handle, and then hand the same string to os.Mkdir: two
|
||||
// independent kernel resolutions with a gap between them. A workspace owner who
|
||||
// swapped the verified ancestor for a junction inside that gap got the component
|
||||
// created outside the approved tree, as Administrator, and verifying again
|
||||
// afterwards cannot un-create it. Junctions need no privilege, so this was
|
||||
// reachable by exactly the unprivileged user the sandbox exists to contain.
|
||||
//
|
||||
// It deliberately does NOT re-verify each created component by pathname. A child
|
||||
// created relative to a pinned parent is in the right place by construction, so
|
||||
// comparing pathnames afterwards would add nothing and would reject correct
|
||||
// creates whenever the tree was legitimately renamed mid-setup.
|
||||
//
|
||||
// Returns what it created, plus an open handle to the deepest directory which
|
||||
// the caller must close.
|
||||
func makeWindowsACLDirChainNoFollow(dir string) (windowsACLMaterialization, windows.Handle, error) {
|
||||
cleaned := filepath.Clean(strings.TrimSpace(dir))
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return windowsACLMaterialization{}, 0, fmt.Errorf("materialize windows ACL target: empty directory path %q", dir)
|
||||
}
|
||||
|
||||
// Walk up to the deepest ancestor that exists, collecting the component
|
||||
// NAMES that are missing. Names, not paths: everything below the anchor is
|
||||
// addressed relative to a handle from here on.
|
||||
var missing []string
|
||||
current := cleaned
|
||||
var anchor windows.Handle
|
||||
var anchorID windowsFileIdentity
|
||||
for {
|
||||
handle, identity, err := openWindowsACLDirectoryNoFollowWithIdentity(current)
|
||||
if err == nil {
|
||||
anchor, anchorID = handle, identity
|
||||
break
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return windowsACLMaterialization{}, 0, err
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return windowsACLMaterialization{}, 0, fmt.Errorf("materialize windows ACL target %s: no existing ancestor to anchor on", dir)
|
||||
}
|
||||
missing = append(missing, filepath.Base(current))
|
||||
current = parent
|
||||
}
|
||||
|
||||
created := windowsACLMaterialization{AnchorPath: current, AnchorID: anchorID}
|
||||
|
||||
if hook := windowsACLMaterializeSwapHook; hook != nil {
|
||||
hook(current)
|
||||
}
|
||||
|
||||
// Walk back down, one component per handle. The anchor handle is released as
|
||||
// soon as its child is open, so at most two levels are held at once.
|
||||
parent := anchor
|
||||
for index := len(missing) - 1; index >= 0; index-- {
|
||||
name := missing[index]
|
||||
child, madeNow, err := createWindowsACLChildDirectory(parent, name)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(parent)
|
||||
return created, 0, err
|
||||
}
|
||||
created.Chain = append(created.Chain, windowsACLChainStep{Name: name, Made: madeNow})
|
||||
_ = windows.CloseHandle(parent)
|
||||
parent = child
|
||||
}
|
||||
return created, parent, nil
|
||||
}
|
||||
@@ -37,8 +37,15 @@ func TestApplyWindowsACLPathGroupHandleBasedRoundTrip(t *testing.T) {
|
||||
if !applied {
|
||||
t.Fatal("applied = false, want true for an existing directory target")
|
||||
}
|
||||
if snapshot.Path != dir || snapshot.Materialized {
|
||||
t.Fatalf("snapshot = %#v, want Path=%q Materialized=false", snapshot, dir)
|
||||
if snapshot.Path != dir {
|
||||
t.Fatalf("snapshot.Path = %q, want %q", snapshot.Path, dir)
|
||||
}
|
||||
// The target already existed, so nothing was created and rollback must have
|
||||
// nothing to remove. Asserting the chain rather than a bool matters: a
|
||||
// rewiring that recorded the walked components instead of only the created
|
||||
// ones would make rollback delete a directory the sandbox never made.
|
||||
if snapshot.Created.createdAnything() {
|
||||
t.Fatalf("snapshot recorded %#v as created for a target that already existed", snapshot.Created)
|
||||
}
|
||||
if snapshot.Descriptor == nil {
|
||||
t.Fatal("snapshot has no captured descriptor to roll back to")
|
||||
@@ -67,8 +74,16 @@ func TestApplyWindowsACLPathGroupMaterializes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("applyWindowsACLPathGroup: %v", err)
|
||||
}
|
||||
if !applied || !snapshot.Materialized {
|
||||
t.Fatalf("applied=%v materialized=%v, want both true", applied, snapshot.Materialized)
|
||||
if !applied {
|
||||
t.Fatal("applied = false, want true for a materialized target")
|
||||
}
|
||||
// Exactly one component was missing, so exactly one must be recorded as
|
||||
// created, and it must be the leaf's own name rather than a path.
|
||||
if len(snapshot.Created.Chain) != 1 || snapshot.Created.Chain[0] != (windowsACLChainStep{Name: "created", Made: true}) {
|
||||
t.Fatalf("created chain = %#v, want one step {created true}", snapshot.Created.Chain)
|
||||
}
|
||||
if snapshot.Created.AnchorPath != filepath.Dir(target) {
|
||||
t.Fatalf("anchor = %q, want the existing parent %q", snapshot.Created.AnchorPath, filepath.Dir(target))
|
||||
}
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Fatalf("materialized target not created: %v", err)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// gitGuardDenyDeleteApplied reports whether the applied DACL on path carries a
|
||||
// DENY ace granting DELETE to sid. Read back from the object rather than from
|
||||
// the plan, because the plan is what was already wrong.
|
||||
func gitGuardDenyDeleteApplied(t *testing.T, path string, sid string) bool {
|
||||
t.Helper()
|
||||
handle, _, err := openWindowsACLTarget(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
descriptor, err := windows.GetSecurityInfo(handle, 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 || dacl == nil {
|
||||
t.Fatalf("read the DACL of %s: %v", path, err)
|
||||
}
|
||||
wanted, err := windows.StringToSid(sid)
|
||||
if err != nil {
|
||||
t.Fatalf("parse the capability SID %q: %v", sid, err)
|
||||
}
|
||||
// GetAce hands back a generic header that is reinterpreted here. Sound only
|
||||
// for the fixed-layout ACE types: an object ACE carries Flags and two GUIDs
|
||||
// ahead of the trustee, so SidStart would land mid-structure. Allowed and
|
||||
// denied ACEs share that fixed layout, and nothing under test builds an
|
||||
// object ACE, so a type this does not recognise is skipped rather than
|
||||
// decoded into a nonsense SID.
|
||||
for index := uint32(0); index < uint32(dacl.AceCount); index++ {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(dacl, index, &ace); err != nil {
|
||||
continue
|
||||
}
|
||||
if ace.Header.AceType != windows.ACCESS_DENIED_ACE_TYPE {
|
||||
continue
|
||||
}
|
||||
aceSID := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
|
||||
if !aceSID.Equals(wanted) {
|
||||
continue
|
||||
}
|
||||
if ace.Mask&windows.DELETE != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// THE GUARD BELONGS TO THE GRANT, NOT TO ONE PLANNER.
|
||||
//
|
||||
// The allow-write mask both backends share includes DELETE, and it inherits from
|
||||
// the write root onto .git. What protects git is attached to .git/config and
|
||||
// .git/hooks as OBJECTS, so renaming .git aside and recreating it discards those
|
||||
// carveouts: the fresh config and hooks inherit the workspace allow with no deny
|
||||
// of their own, which hands back credential.helper and core.hooksPath.
|
||||
//
|
||||
// The principal planner denied DELETE on .git; the capability planner did not,
|
||||
// and the capability backend is the default. So on the reachable path the
|
||||
// caller's own token and the capability restricting SID could both authorise the
|
||||
// rename.
|
||||
//
|
||||
// Read back from the applied object, not asserted on the plan, because the plan
|
||||
// is exactly what was wrong. DACL edits on a user-owned directory need no
|
||||
// Administrator rights, so this runs unelevated.
|
||||
func TestCapabilityPlanDeniesDeleteOnGit(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
gitDir := filepath.Join(workspace, ".git")
|
||||
if err := os.MkdirAll(gitDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: workspace}},
|
||||
},
|
||||
},
|
||||
}
|
||||
plan, err := BuildWindowsACLPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsACLPlan: %v", err)
|
||||
}
|
||||
sid, err := windowsCapabilitySIDForWriteRoot(config, workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve the workspace capability SID: %v", err)
|
||||
}
|
||||
// SETUP: the plan must actually grant this root, or the guard below would be
|
||||
// vacuously satisfied by a plan that grants nothing.
|
||||
granted := false
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action == WindowsACLAllowWrite && entry.Path == workspace {
|
||||
granted = true
|
||||
}
|
||||
}
|
||||
if !granted {
|
||||
t.Fatalf("SETUP INVALID: the capability plan does not grant write on %s", workspace)
|
||||
}
|
||||
|
||||
if _, err := applyWindowsACLPlan(plan); err != nil {
|
||||
t.Skipf("cannot apply an ACL plan here: %v", err)
|
||||
}
|
||||
|
||||
if !gitGuardDenyDeleteApplied(t, gitDir, sid) {
|
||||
t.Fatalf(".git carries no deny-DELETE ace for the capability SID %s, so the sandboxed token can rename it and recreate it without the config and hooks carveouts", sid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// makeJunction points link at target, skipping the test when the environment
|
||||
// refuses to create one. A junction needs no privilege, which is exactly why
|
||||
// this attack is reachable by an ordinary workspace owner.
|
||||
func makeJunction(t *testing.T, link, target string) {
|
||||
t.Helper()
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil {
|
||||
t.Skipf("cannot create a junction here: %v: %s", err, out)
|
||||
}
|
||||
// Assert it actually redirects. A junction that silently did nothing would
|
||||
// green this test while proving nothing.
|
||||
probe := filepath.Join(link, "redirect-probe")
|
||||
if err := os.WriteFile(probe, []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("write through junction: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(target, "redirect-probe")); err != nil {
|
||||
t.Fatalf("junction does not redirect, so this test would prove nothing: %v", err)
|
||||
}
|
||||
if err := os.Remove(probe); err != nil {
|
||||
t.Fatalf("clean probe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Elevated setup must not create anything through a reparse-point ancestor.
|
||||
//
|
||||
// FILE_FLAG_OPEN_REPARSE_POINT only stops the FINAL component being followed.
|
||||
// materializeWindowsACLTarget used os.MkdirAll/os.OpenFile on the pathname, both
|
||||
// of which resolve ancestors, so a workspace owner who turned .git into a
|
||||
// junction before setup ran got objects created at a location of their choosing
|
||||
// — as Administrator — and the no-follow check only rejected it afterwards, far
|
||||
// too late to un-create them.
|
||||
func TestMaterializeRefusesAncestorJunctionBeforeCreating(t *testing.T) {
|
||||
for name, asFile := range map[string]bool{"file target": true, "directory target": false} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
external := t.TempDir()
|
||||
workspace := t.TempDir()
|
||||
gitDir := filepath.Join(workspace, ".git")
|
||||
makeJunction(t, gitDir, external)
|
||||
|
||||
target := filepath.Join(gitDir, "hooks", "config")
|
||||
_, err := materializeWindowsACLTarget(target, asFile)
|
||||
if err == nil {
|
||||
t.Fatalf("materialized %s through a junction ancestor instead of refusing", target)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reparse") {
|
||||
t.Fatalf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
// Nothing may survive on the other side of the junction. The old code
|
||||
// left every intermediate directory MkdirAll had created.
|
||||
leaked, lerr := os.ReadDir(external)
|
||||
if lerr != nil {
|
||||
t.Fatalf("read external dir: %v", lerr)
|
||||
}
|
||||
if len(leaked) != 0 {
|
||||
names := make([]string, 0, len(leaked))
|
||||
for _, entry := range leaked {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("created %v outside the workspace through the junction", names)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary path still works: no reparse point anywhere, target gets made.
|
||||
func TestMaterializeStillCreatesOrdinaryTargets(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for name, asFile := range map[string]bool{"file target": true, "directory target": false} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
target := filepath.Join(root, name, "nested", "deeper", "target")
|
||||
if _, err := materializeWindowsACLTarget(target, asFile); err != nil {
|
||||
t.Fatalf("materializeWindowsACLTarget: %v", err)
|
||||
}
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatalf("target was not created: %v", err)
|
||||
}
|
||||
if info.IsDir() == asFile {
|
||||
t.Fatalf("target isDir=%v, wanted file=%v", info.IsDir(), asFile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// swapAncestorAside moves approved out of the way and leaves a junction wearing
|
||||
// its name, pointing at elsewhere. This is the whole attack in three lines, and
|
||||
// it needs no privilege: junctions are creatable by any user, which is exactly
|
||||
// why an unprivileged workspace owner can aim elevated setup wherever they like.
|
||||
//
|
||||
// Returns the path the real directory now lives at.
|
||||
func swapAncestorAside(t *testing.T, approved, elsewhere string) string {
|
||||
t.Helper()
|
||||
moved := approved + "-moved"
|
||||
if err := os.Rename(approved, moved); err != nil {
|
||||
t.Skipf("cannot rename a directory with an open handle on this filesystem: %v", err)
|
||||
}
|
||||
makeJunction(t, approved, elsewhere)
|
||||
return moved
|
||||
}
|
||||
|
||||
// requireNothingEscaped fails when anything at all was created on the far side
|
||||
// of the junction.
|
||||
func requireNothingEscaped(t *testing.T, elsewhere string) {
|
||||
t.Helper()
|
||||
leaked, err := os.ReadDir(elsewhere)
|
||||
if err != nil {
|
||||
t.Fatalf("read the decoy directory: %v", err)
|
||||
}
|
||||
if len(leaked) == 0 {
|
||||
return
|
||||
}
|
||||
names := make([]string, 0, len(leaked))
|
||||
for _, entry := range leaked {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("ESCAPED: created %v outside the approved tree, as Administrator", names)
|
||||
}
|
||||
|
||||
// THE MATERIALIZATION RACE, ON THE PRODUCTION CALL PATH.
|
||||
//
|
||||
// The existing junction test plants its junction before the walk even starts, so
|
||||
// the very first check sees it and refuses. That proves the easy half. The half
|
||||
// the reviewer filed is the swap that happens AFTER a component has been
|
||||
// verified and BEFORE it is used, and no test reached it: a race nobody can
|
||||
// trigger on demand is not a regression test, so makeWindowsACLDirChainNoFollow
|
||||
// carries a seam that fires at exactly that instant.
|
||||
//
|
||||
// The control arm matters as much as the fixed one. It performs the identical
|
||||
// swap and then does what this code used to do, creating by pathname, and
|
||||
// asserts that the object DOES escape. Without it, the fixed arm passing proves
|
||||
// only that some code ran, not that the hole it closes was ever open.
|
||||
func TestMaterializeSurvivesAnAncestorSwappedMidWalk(t *testing.T) {
|
||||
t.Run("control: creating by pathname escapes", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
approved := filepath.Join(root, "ws")
|
||||
elsewhere := filepath.Join(root, "OUTSIDE")
|
||||
for _, dir := range []string{approved, elsewhere} {
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("seed %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
// Verified, exactly as the walk verifies its anchor.
|
||||
if err := verifyWindowsACLPathComponentNotRedirected(approved); err != nil {
|
||||
t.Fatalf("anchor did not verify before the swap: %v", err)
|
||||
}
|
||||
moved := swapAncestorAside(t, approved, elsewhere)
|
||||
|
||||
// The old create: a pathname, re-resolved by the kernel right now.
|
||||
if err := os.MkdirAll(filepath.Join(approved, "a", "b"), 0o700); err != nil {
|
||||
t.Fatalf("pathname create: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(elsewhere, "a", "b")); err != nil {
|
||||
t.Fatalf("the control arm did not reproduce the escape, so the fixed arm below proves nothing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(moved, "a", "b")); err == nil {
|
||||
t.Error("the control arm created inside the verified directory, which is not the behaviour being contrasted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixed: creating through the pinned handle stays put", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
approved := filepath.Join(root, "ws")
|
||||
elsewhere := filepath.Join(root, "OUTSIDE")
|
||||
for _, dir := range []string{approved, elsewhere} {
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("seed %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
var moved string
|
||||
swapped := false
|
||||
windowsACLMaterializeSwapHook = func(anchor string) {
|
||||
if swapped {
|
||||
return
|
||||
}
|
||||
swapped = true
|
||||
if anchor != approved {
|
||||
t.Errorf("anchored on %q, want the deepest existing ancestor %q", anchor, approved)
|
||||
}
|
||||
moved = swapAncestorAside(t, approved, elsewhere)
|
||||
}
|
||||
t.Cleanup(func() { windowsACLMaterializeSwapHook = nil })
|
||||
|
||||
created, err := materializeWindowsACLTarget(filepath.Join(approved, "a", "b"), false)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeWindowsACLTarget: %v", err)
|
||||
}
|
||||
if !swapped {
|
||||
t.Fatal("the seam never fired, so the swap never happened and this test proved nothing")
|
||||
}
|
||||
requireNothingEscaped(t, elsewhere)
|
||||
if _, err := os.Stat(filepath.Join(moved, "a", "b")); err != nil {
|
||||
t.Errorf("the target did not land in the directory that was verified: %v", err)
|
||||
}
|
||||
// And the record must describe what to unwind, in components rather than
|
||||
// paths, shallowest first.
|
||||
if len(created.Chain) != 2 || created.Chain[0].Name != "a" || created.Chain[1].Name != "b" {
|
||||
t.Fatalf("created chain = %#v, want [a b] shallow to deep", created.Chain)
|
||||
}
|
||||
for _, step := range created.Chain {
|
||||
if !step.Made {
|
||||
t.Errorf("component %q was not recorded as created, so rollback would leave it behind", step.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The FILE target has the same race, and it is the one that matters most:
|
||||
// .git/config is materialized as a file on every stock setup, and it is the file
|
||||
// whose credential.helper is worth stealing.
|
||||
func TestMaterializeFileSurvivesAnAncestorSwappedMidWalk(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
approved := filepath.Join(root, "ws")
|
||||
elsewhere := filepath.Join(root, "OUTSIDE")
|
||||
for _, dir := range []string{approved, elsewhere} {
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("seed %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
var moved string
|
||||
swapped := false
|
||||
windowsACLMaterializeSwapHook = func(string) {
|
||||
if swapped {
|
||||
return
|
||||
}
|
||||
swapped = true
|
||||
moved = swapAncestorAside(t, approved, elsewhere)
|
||||
}
|
||||
t.Cleanup(func() { windowsACLMaterializeSwapHook = nil })
|
||||
|
||||
created, err := materializeWindowsACLTarget(filepath.Join(approved, ".git", "config"), true)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeWindowsACLTarget: %v", err)
|
||||
}
|
||||
if !swapped {
|
||||
t.Fatal("the seam never fired, so this test proved nothing")
|
||||
}
|
||||
requireNothingEscaped(t, elsewhere)
|
||||
|
||||
landed := filepath.Join(moved, ".git", "config")
|
||||
info, err := os.Stat(landed)
|
||||
if err != nil {
|
||||
t.Fatalf("the file did not land in the directory that was verified: %v", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
t.Error("materialized .git/config as a directory, which breaks git init")
|
||||
}
|
||||
if !created.FileMade || created.File != "config" {
|
||||
t.Errorf("file record = %q made=%v, want config/true", created.File, created.FileMade)
|
||||
}
|
||||
}
|
||||
|
||||
// THE ROLLBACK RACE. The ancestor is swapped AFTER the target was created, which
|
||||
// is the window the teardown path lives in: minutes or hours, not microseconds.
|
||||
//
|
||||
// The bystander is the point. If the unwind resolves by pathname it walks into
|
||||
// the decoy and deletes what it finds there, recursively and elevated. Its
|
||||
// survival is the only thing that proves the unwind did not.
|
||||
func TestRollbackDoesNotFollowAnAncestorSwappedAfterCreation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
approved := filepath.Join(root, "ws")
|
||||
elsewhere := filepath.Join(root, "OUTSIDE")
|
||||
for _, dir := range []string{approved, elsewhere} {
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("seed %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
created, err := materializeWindowsACLTarget(filepath.Join(approved, "a", "b"), false)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeWindowsACLTarget: %v", err)
|
||||
}
|
||||
|
||||
// A bystander tree under the decoy, shaped exactly like what we created, so a
|
||||
// pathname unwind would find something to destroy at every level.
|
||||
bystander := filepath.Join(elsewhere, "a", "b")
|
||||
if err := os.MkdirAll(bystander, 0o700); err != nil {
|
||||
t.Fatalf("seed bystander: %v", err)
|
||||
}
|
||||
witness := filepath.Join(bystander, "irreplaceable.txt")
|
||||
if err := os.WriteFile(witness, []byte("not yours to delete"), 0o600); err != nil {
|
||||
t.Fatalf("seed witness: %v", err)
|
||||
}
|
||||
|
||||
moved := swapAncestorAside(t, approved, elsewhere)
|
||||
|
||||
// The anchor pathname now names the decoy, and the decoy is a junction, so
|
||||
// the unwind must refuse rather than proceed. Either way it must not delete.
|
||||
err = rollbackWindowsACLMaterialization(created)
|
||||
|
||||
if _, statErr := os.Stat(witness); statErr != nil {
|
||||
t.Fatalf("DESTRUCTIVE: rollback followed the junction and deleted a tree outside the approved directory: %v", statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(bystander); statErr != nil {
|
||||
t.Fatalf("DESTRUCTIVE: rollback removed the bystander directory outside the approved directory: %v", statErr)
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("rollback reported success while unwinding through a swapped ancestor; it must say it could not")
|
||||
}
|
||||
// Residue inside the real tree is the accepted price: leaving it is strictly
|
||||
// better than a recursive delete through a path someone else controls.
|
||||
if _, statErr := os.Stat(filepath.Join(moved, "a", "b")); statErr != nil {
|
||||
t.Logf("note: the real tree was also unwound (%v); leaving it would be acceptable too", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// A real directory wearing the anchor's name is a swap with NO reparse point
|
||||
// anywhere, so every no-follow check in this package passes it. Only the file
|
||||
// identity notices.
|
||||
func TestRollbackRefusesAnAnchorReplacedByARealDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
approved := filepath.Join(root, "ws")
|
||||
if err := os.Mkdir(approved, 0o700); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
created, err := materializeWindowsACLTarget(filepath.Join(approved, "a"), false)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeWindowsACLTarget: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(approved, approved+"-moved"); err != nil {
|
||||
t.Skipf("cannot rename here: %v", err)
|
||||
}
|
||||
// An ordinary directory. Nothing is a link; nothing is a reparse point.
|
||||
if err := os.Mkdir(approved, 0o700); err != nil {
|
||||
t.Fatalf("plant the replacement: %v", err)
|
||||
}
|
||||
decoy := filepath.Join(approved, "a")
|
||||
if err := os.Mkdir(decoy, 0o700); err != nil {
|
||||
t.Fatalf("plant the decoy child: %v", err)
|
||||
}
|
||||
|
||||
err = rollbackWindowsACLMaterialization(created)
|
||||
if err == nil {
|
||||
t.Error("rollback accepted a different directory wearing the anchor's name")
|
||||
} else if !strings.Contains(err.Error(), "no longer the directory") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(decoy); statErr != nil {
|
||||
t.Errorf("rollback deleted a directory it never created: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Rollback removes ONLY what this run created. A pre-existing ancestor is walked
|
||||
// through and left alone.
|
||||
func TestRollbackLeavesDirectoriesItDidNotCreate(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
existing := filepath.Join(root, "ws", "already-here")
|
||||
if err := os.MkdirAll(existing, 0o700); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
target := filepath.Join(existing, "made", "deeper")
|
||||
|
||||
created, err := materializeWindowsACLTarget(target, false)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeWindowsACLTarget: %v", err)
|
||||
}
|
||||
if created.AnchorPath != existing {
|
||||
t.Fatalf("anchor = %q, want the deepest pre-existing directory %q", created.AnchorPath, existing)
|
||||
}
|
||||
if err := rollbackWindowsACLMaterialization(created); err != nil {
|
||||
t.Fatalf("rollbackWindowsACLMaterialization: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(existing, "made")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("created directory survived rollback: stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(existing); err != nil {
|
||||
t.Errorf("rollback removed a directory that already existed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole apply path, through the closure callers actually hold, rather than
|
||||
// through rollbackWindowsACLSnapshots directly. Every other rollback test in
|
||||
// this package calls the unwind by hand, which cannot catch applyWindowsACLPlan
|
||||
// failing to carry the created record into the snapshots it hands over.
|
||||
func TestApplyWindowsACLPlanClosureRemovesWhatItMaterialized(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
directoryTarget := filepath.Join(root, "ws", "hooks")
|
||||
fileTarget := filepath.Join(root, "ws", "config")
|
||||
|
||||
plan := WindowsACLPlan{Entries: []WindowsACLEntry{
|
||||
{Action: WindowsACLDenyWrite, Path: directoryTarget, Capability: "S-1-1-0", Materialize: true},
|
||||
{Action: WindowsACLDenyWrite, Path: fileTarget, Capability: "S-1-1-0", Materialize: true, MaterializeFile: true},
|
||||
}}
|
||||
|
||||
rollback, err := applyWindowsACLPlan(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("applyWindowsACLPlan: %v", err)
|
||||
}
|
||||
for _, path := range []string{directoryTarget, fileTarget} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("%s was not materialized: %v", path, err)
|
||||
}
|
||||
}
|
||||
if err := rollback(); err != nil {
|
||||
t.Fatalf("rollback closure: %v", err)
|
||||
}
|
||||
for _, path := range []string{directoryTarget, fileTarget} {
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("%s survived the rollback closure: stat err = %v", path, err)
|
||||
}
|
||||
}
|
||||
// The shared prefix both targets needed must go too, and it is created by
|
||||
// whichever group runs first rather than being owned by both.
|
||||
if _, err := os.Stat(filepath.Join(root, "ws")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("the shared parent survived: stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A rollback that cannot remove something must SAY so. This is the regression
|
||||
// guard for the trap a naive handle-relative port walks straight into:
|
||||
// FILE_DELETE_ON_CLOSE reports success on a non-empty directory and leaves it
|
||||
// there, which turns a loud failure into a silent lie. The directory being
|
||||
// populated is not adversarial; .git/hooks fills up the moment git runs.
|
||||
func TestRollbackReportsWhatItCouldNotRemove(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "ws", "hooks")
|
||||
created, err := materializeWindowsACLTarget(target, false)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeWindowsACLTarget: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(target, "pre-commit"), []byte("#!/bin/sh\n"), 0o600); err != nil {
|
||||
t.Fatalf("populate: %v", err)
|
||||
}
|
||||
|
||||
err = rollbackWindowsACLMaterialization(created)
|
||||
if err == nil {
|
||||
t.Fatal("rollback reported success on a directory it could not empty, so callers cannot tell teardown failed")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "hooks") {
|
||||
t.Errorf("the error does not name what was left behind: %v", err)
|
||||
}
|
||||
// Left in place deliberately. Removing it would mean recursing, and recursion
|
||||
// through a path the workspace owner controls is the thing being avoided.
|
||||
if _, statErr := os.Stat(target); statErr != nil {
|
||||
t.Errorf("rollback recursed into a populated directory instead of reporting it: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// The primitives take a single component and the walk relies on that. A joined
|
||||
// name is resolved the ordinary way by the kernel, so an intermediate junction
|
||||
// inside it is followed and the object lands outside the anchor: the pinned
|
||||
// parent buys nothing if the name itself walks.
|
||||
func TestChildOperationsRefuseNamesThatAreNotSingleComponents(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
parent, err := openWindowsACLDirectoryNoFollow(root)
|
||||
if err != nil {
|
||||
t.Fatalf("open root: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
for _, name := range []string{`sub\child`, "sub/child", "..", ".", "", `C:\absolute`, "stream:name"} {
|
||||
t.Run("create dir "+name, func(t *testing.T) {
|
||||
handle, _, err := createWindowsACLChildDirectory(parent, name)
|
||||
if err == nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatalf("accepted %q, which the kernel would resolve through intermediate directories", name)
|
||||
}
|
||||
})
|
||||
t.Run("delete dir "+name, func(t *testing.T) {
|
||||
if err := deleteWindowsACLChildDirectory(parent, name); err == nil {
|
||||
t.Fatalf("accepted %q for deletion", name)
|
||||
}
|
||||
})
|
||||
t.Run("create file "+name, func(t *testing.T) {
|
||||
if _, err := createWindowsACLChildFile(parent, name); err == nil {
|
||||
t.Fatalf("accepted %q for file creation", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A junction sitting where a chain component should be must be refused when it
|
||||
// is OPENED, not merely when it is created. FILE_OPEN_REPARSE_POINT hands back a
|
||||
// handle to the junction itself, and using that as the next parent puts every
|
||||
// deeper create on the far side of it.
|
||||
func TestChildOperationsRefuseAnExistingJunction(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
elsewhere := t.TempDir()
|
||||
makeJunction(t, filepath.Join(root, "hop"), elsewhere)
|
||||
|
||||
parent, err := openWindowsACLDirectoryNoFollow(root)
|
||||
if err != nil {
|
||||
t.Fatalf("open root: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
handle, _, err := createWindowsACLChildDirectory(parent, "hop")
|
||||
if err == nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Error("createWindowsACLChildDirectory returned a junction as the next parent in the walk")
|
||||
}
|
||||
opened, err := openWindowsACLChildDirectory(parent, "hop")
|
||||
if err == nil {
|
||||
_ = windows.CloseHandle(opened)
|
||||
t.Error("openWindowsACLChildDirectory returned a junction to descend through")
|
||||
}
|
||||
}
|
||||
|
||||
// Rollback descends; it must never create. If a component was removed by
|
||||
// something else in the meantime, re-making it and then deleting it would remove
|
||||
// a directory the sandbox never made.
|
||||
func TestRollbackDescentNeverCreatesAMissingComponent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
parent, err := openWindowsACLDirectoryNoFollow(root)
|
||||
if err != nil {
|
||||
t.Fatalf("open root: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
handle, err := openWindowsACLChildDirectory(parent, "never-existed")
|
||||
if err == nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatal("the descent open created a directory that did not exist")
|
||||
}
|
||||
if !isWindowsNotExist(err) {
|
||||
t.Errorf("a missing component reported %v, which rollback cannot distinguish from a real failure", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(root, "never-existed")); statErr == nil {
|
||||
t.Error("a directory appeared on disk from an open that should never create")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Handle-relative directory operations.
|
||||
//
|
||||
// WHY THESE EXIST. Every pathname-based call re-resolves the whole path inside
|
||||
// the kernel at the moment it runs. So verifying a component and then creating
|
||||
// through it are two separate resolutions of the same string, and a workspace
|
||||
// owner can swap an ancestor for a junction in the gap between them: setup
|
||||
// verifies, the attacker swaps, setup creates, and the object lands outside the
|
||||
// approved tree. Checking again afterwards is too late, because the thing has
|
||||
// already been created somewhere it should not be.
|
||||
//
|
||||
// A HANDLE pins the object rather than the name. Once a directory is open, that
|
||||
// handle keeps referring to the same directory however the path is later
|
||||
// rearranged, so creating a child relative to it cannot be redirected. os.Mkdir,
|
||||
// os.OpenFile and os.RemoveAll are pathname-based by construction with no
|
||||
// relative form on Windows, which is why this drops to NtCreateFile with
|
||||
// OBJECT_ATTRIBUTES.RootDirectory.
|
||||
|
||||
// IO_STATUS_BLOCK.Information values for a create/open, named because "2 means
|
||||
// it was created" is not something a reader should have to look up.
|
||||
const (
|
||||
windowsFileOpened uintptr = 1
|
||||
windowsFileCreated uintptr = 2
|
||||
)
|
||||
|
||||
// windowsACLDirectoryShare is the share mode every open here uses. A sandbox
|
||||
// tree is live, so refusing to share would fail on any directory something else
|
||||
// happens to have open: a denial of service on ourselves rather than a security
|
||||
// property.
|
||||
const windowsACLDirectoryShare = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE | windows.FILE_SHARE_DELETE
|
||||
|
||||
// windowsFileIdentity is the kernel's own answer to "is this the same object",
|
||||
// independent of what it is currently called.
|
||||
//
|
||||
// It exists because rollback cannot hold the anchor handle open for the whole
|
||||
// sandbox lifetime (see rollbackWindowsACLSnapshots), so it has to re-open the
|
||||
// anchor by pathname, and a pathname can be made to name a different object.
|
||||
// Crucially that substitution needs NO reparse point: rename the real directory
|
||||
// aside and create an ordinary directory wearing its name, and every no-follow
|
||||
// check still passes because nothing anywhere is a link. Comparing the volume
|
||||
// and file index catches it, because those identify the object the kernel
|
||||
// actually opened.
|
||||
type windowsFileIdentity struct {
|
||||
Volume uint32
|
||||
IndexHigh uint32
|
||||
IndexLow uint32
|
||||
}
|
||||
|
||||
func (identity windowsFileIdentity) empty() bool {
|
||||
return identity == windowsFileIdentity{}
|
||||
}
|
||||
|
||||
// windowsIdentityOfHandle reads the identity of an already-open object.
|
||||
func windowsIdentityOfHandle(handle windows.Handle) (windowsFileIdentity, error) {
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return windowsFileIdentity{}, fmt.Errorf("read windows file identity: %w", err)
|
||||
}
|
||||
return windowsFileIdentity{
|
||||
Volume: info.VolumeSerialNumber,
|
||||
IndexHigh: info.FileIndexHigh,
|
||||
IndexLow: info.FileIndexLow,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// openWindowsACLDirectoryNoFollow opens an existing directory by pathname,
|
||||
// refusing to traverse or land on a reparse point.
|
||||
//
|
||||
// This is the ANCHOR for a handle-relative walk: the one pathname resolution
|
||||
// that has to happen, with everything below it relative to the handle it
|
||||
// returns. FILE_FLAG_OPEN_REPARSE_POINT stops the final component being
|
||||
// followed, and verifyWindowsACLTargetNotRedirected then confirms no ancestor
|
||||
// redirected either, because GetFinalPathNameByHandle answers for the whole
|
||||
// resolved path.
|
||||
func openWindowsACLDirectoryNoFollow(path string) (windows.Handle, error) {
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode windows ACL directory %s: %w", path, err)
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
utf16Path,
|
||||
windows.FILE_LIST_DIRECTORY|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
|
||||
windowsACLDirectoryShare,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
// Errno.Is maps the not-found codes to os.ErrNotExist, so a caller
|
||||
// walking up to find the deepest existing ancestor keeps working.
|
||||
return 0, fmt.Errorf("open windows ACL directory %s: %w", path, err)
|
||||
}
|
||||
if err := verifyWindowsACLHandleIsCleanDirectory(handle, path); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, err
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
// openWindowsACLDirectoryNoFollowWithIdentity is the anchor open plus the
|
||||
// identity a later rollback needs in order to prove it re-opened the same
|
||||
// object.
|
||||
func openWindowsACLDirectoryNoFollowWithIdentity(path string) (windows.Handle, windowsFileIdentity, error) {
|
||||
handle, err := openWindowsACLDirectoryNoFollow(path)
|
||||
if err != nil {
|
||||
return 0, windowsFileIdentity{}, err
|
||||
}
|
||||
identity, err := windowsIdentityOfHandle(handle)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, windowsFileIdentity{}, fmt.Errorf("identify windows ACL directory %s: %w", path, err)
|
||||
}
|
||||
return handle, identity, nil
|
||||
}
|
||||
|
||||
// reopenWindowsACLDirectoryAsIdentity re-opens an anchor by pathname and refuses
|
||||
// it unless the kernel says it is the object that was opened before.
|
||||
//
|
||||
// Used only by rollback. See windowsFileIdentity for why the pathname alone is
|
||||
// not enough, and rollbackWindowsACLSnapshots for why a handle cannot simply be
|
||||
// held instead.
|
||||
func reopenWindowsACLDirectoryAsIdentity(path string, want windowsFileIdentity) (windows.Handle, error) {
|
||||
handle, err := openWindowsACLDirectoryNoFollow(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if want.empty() {
|
||||
return handle, nil
|
||||
}
|
||||
got, err := windowsIdentityOfHandle(handle)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, fmt.Errorf("identify windows ACL directory %s: %w", path, err)
|
||||
}
|
||||
if got != want {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, fmt.Errorf("refusing to unwind under %s: it is no longer the directory setup created into, so something replaced it since (possible path swap during elevated setup)", path)
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
// createWindowsACLChildDirectory creates one directory directly beneath parent,
|
||||
// or opens it when it already exists, and reports which happened.
|
||||
//
|
||||
// name must be a single component; see validateWindowsACLComponent for why that
|
||||
// is enforced rather than assumed. The kernel resolves it relative to the parent
|
||||
// HANDLE, so nothing above it is consulted and nothing above it can be swapped
|
||||
// underneath us.
|
||||
//
|
||||
// created is true only when this call made the directory, which the rollback
|
||||
// needs: removing one that already existed would delete a user's data over a
|
||||
// failure that had nothing to do with it.
|
||||
func createWindowsACLChildDirectory(parent windows.Handle, name string) (handle windows.Handle, created bool, err error) {
|
||||
if err := validateWindowsACLComponent(name); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("encode windows ACL directory 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 status windows.IO_STATUS_BLOCK
|
||||
if err := windows.NtCreateFile(
|
||||
&handle,
|
||||
windows.FILE_LIST_DIRECTORY|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE|windows.DELETE,
|
||||
&attributes,
|
||||
&status,
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_DIRECTORY,
|
||||
windowsACLDirectoryShare,
|
||||
windows.FILE_OPEN_IF,
|
||||
windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT,
|
||||
0,
|
||||
0,
|
||||
); err != nil {
|
||||
return 0, false, fmt.Errorf("create windows ACL directory component %s: %w", name, err)
|
||||
}
|
||||
// FILE_OPEN_IF means an EXISTING child is opened rather than created, and
|
||||
// FILE_OPEN_REPARSE_POINT means a junction is opened AS the junction. Without
|
||||
// this check that junction becomes the parent of the next level and every
|
||||
// create beneath it lands wherever it points, which is the mid-walk swap this
|
||||
// whole file exists to stop.
|
||||
if err := rejectWindowsACLReparseHandle(handle, name); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, false, err
|
||||
}
|
||||
return handle, status.Information == windowsFileCreated, nil
|
||||
}
|
||||
|
||||
// openWindowsACLChildDirectory opens an EXISTING directory beneath parent and
|
||||
// never creates one.
|
||||
//
|
||||
// Rollback walks back down the chain it created, and it must not conjure a
|
||||
// component that has since been removed: FILE_OPEN_IF would recreate it, and
|
||||
// then the unwind would delete a directory setup never made. FILE_OPEN is the
|
||||
// whole difference from createWindowsACLChildDirectory.
|
||||
func openWindowsACLChildDirectory(parent windows.Handle, name string) (handle windows.Handle, err error) {
|
||||
if err := validateWindowsACLComponent(name); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode windows ACL directory component %s: %w", name, err)
|
||||
}
|
||||
attributes := windows.OBJECT_ATTRIBUTES{
|
||||
RootDirectory: parent,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE,
|
||||
}
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
|
||||
// Ask for the least that still allows passing through and checking the
|
||||
// reparse attribute. This runs during rollback, on directories whose ACEs
|
||||
// have already been applied, so every extra right is another way for the
|
||||
// unwind to be refused by the very ACL it is unwinding. Notably absent:
|
||||
// SYNCHRONIZE, and with it FILE_SYNCHRONOUS_IO_NONALERT, since nothing is
|
||||
// read or written through this handle.
|
||||
var status windows.IO_STATUS_BLOCK
|
||||
if err := windows.NtCreateFile(
|
||||
&handle,
|
||||
windows.FILE_TRAVERSE|windows.FILE_READ_ATTRIBUTES,
|
||||
&attributes,
|
||||
&status,
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_DIRECTORY,
|
||||
windowsACLDirectoryShare,
|
||||
windows.FILE_OPEN,
|
||||
windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
); err != nil {
|
||||
return 0, fmt.Errorf("open windows ACL directory component %s: %w", name, err)
|
||||
}
|
||||
if err := rejectWindowsACLReparseHandle(handle, name); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, err
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
// createWindowsACLChildFile creates an empty file directly beneath parent, or
|
||||
// opens it when it already exists, and reports which happened.
|
||||
//
|
||||
// The counterpart to createWindowsACLChildDirectory for the one materialized
|
||||
// target that must be a FILE: .git/config, where creating a directory instead
|
||||
// would break `git init` outright rather than merely mis-ACL it.
|
||||
//
|
||||
// FILE_OPEN_IF rather than FILE_CREATE deliberately. The pathname version this
|
||||
// replaces used O_CREATE|O_EXCL and then tolerated os.ErrExist, so a racing
|
||||
// creator winning was fine. FILE_CREATE's collision status is
|
||||
// STATUS_OBJECT_NAME_COLLISION, which errors.Is(err, os.ErrExist) does NOT
|
||||
// match, so porting it literally would have turned that tolerated race into a
|
||||
// hard failure. FILE_OPEN_IF keeps the old behaviour and reports the truth in
|
||||
// created, which rollback needs so it never deletes a file it did not make.
|
||||
func createWindowsACLChildFile(parent windows.Handle, name string) (created bool, err error) {
|
||||
if err := validateWindowsACLComponent(name); err != nil {
|
||||
return false, err
|
||||
}
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("encode windows ACL file 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 status windows.IO_STATUS_BLOCK
|
||||
if err := windows.NtCreateFile(
|
||||
&handle,
|
||||
windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
|
||||
&attributes,
|
||||
&status,
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_NORMAL,
|
||||
windowsACLDirectoryShare,
|
||||
windows.FILE_OPEN_IF,
|
||||
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT,
|
||||
0,
|
||||
0,
|
||||
); err != nil {
|
||||
return false, fmt.Errorf("create windows ACL file component %s: %w", name, err)
|
||||
}
|
||||
if err := rejectWindowsACLReparseHandle(handle, name); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return false, err
|
||||
}
|
||||
createdNow := status.Information == windowsFileCreated
|
||||
if err := windows.CloseHandle(handle); err != nil {
|
||||
return createdNow, fmt.Errorf("close windows ACL file component %s: %w", name, err)
|
||||
}
|
||||
return createdNow, nil
|
||||
}
|
||||
|
||||
// deleteWindowsACLChildDirectory removes one directory directly beneath parent.
|
||||
//
|
||||
// The counterpart to the create above, and the reason rollback cannot use
|
||||
// os.RemoveAll: that takes a pathname, so an ancestor swapped to a junction
|
||||
// AFTER the object was created sends the recursive delete somewhere else and
|
||||
// takes unrelated trees with it. Resolving relative to the parent handle makes
|
||||
// that impossible.
|
||||
//
|
||||
// A missing child is not an error: rollback runs on failure paths where the
|
||||
// object may never have been created.
|
||||
func deleteWindowsACLChildDirectory(parent windows.Handle, name string) error {
|
||||
return deleteWindowsACLChild(parent, name, true)
|
||||
}
|
||||
|
||||
// deleteWindowsACLChildFile removes one file directly beneath parent, for the
|
||||
// materialized .git/config carveout. Rollback picks between this and the
|
||||
// directory form from the shape recorded at materialization time rather than by
|
||||
// stat-ing the pathname, because a stat is another pathname resolution and this
|
||||
// whole file exists to avoid those.
|
||||
func deleteWindowsACLChildFile(parent windows.Handle, name string) error {
|
||||
return deleteWindowsACLChild(parent, name, false)
|
||||
}
|
||||
|
||||
// deleteWindowsACLChild opens one child relative to parent and deletes it by
|
||||
// SETTING ITS DISPOSITION, not with FILE_DELETE_ON_CLOSE.
|
||||
//
|
||||
// That distinction is the whole point, and it was measured rather than assumed.
|
||||
// FILE_DELETE_ON_CLOSE defers the removal to cleanup, where a non-empty
|
||||
// directory makes it fail with nothing to report it to: NtCreateFile returns
|
||||
// success, CloseHandle returns success, and the directory is still there. A
|
||||
// rollback built on it would report success while leaving materialized state on
|
||||
// disk, which is strictly worse than the os.RemoveAll it replaces, because
|
||||
// os.RemoveAll at least removed it.
|
||||
//
|
||||
// NtSetInformationFile answers synchronously and to the caller, so a non-empty
|
||||
// directory comes back as STATUS_DIRECTORY_NOT_EMPTY. Leaving residue is
|
||||
// acceptable, since the alternative is a recursive delete through a pathname an
|
||||
// attacker may control; lying about having removed it is not.
|
||||
//
|
||||
// FILE_DIRECTORY_FILE / FILE_NON_DIRECTORY_FILE also make the open refuse an
|
||||
// object of the wrong shape rather than deleting it.
|
||||
func deleteWindowsACLChild(parent windows.Handle, name string, directory bool) error {
|
||||
if err := validateWindowsACLComponent(name); err != nil {
|
||||
return err
|
||||
}
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode windows ACL component %s: %w", name, err)
|
||||
}
|
||||
attributes := windows.OBJECT_ATTRIBUTES{
|
||||
RootDirectory: parent,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE,
|
||||
}
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
|
||||
shapeOption := uint32(windows.FILE_NON_DIRECTORY_FILE)
|
||||
shapeAttribute := uint32(windows.FILE_ATTRIBUTE_NORMAL)
|
||||
if directory {
|
||||
shapeOption = windows.FILE_DIRECTORY_FILE
|
||||
shapeAttribute = windows.FILE_ATTRIBUTE_DIRECTORY
|
||||
}
|
||||
|
||||
// DELETE alone. Asking for SYNCHRONIZE as well would make this fail on any
|
||||
// object already carrying a deny-read ACE, because FILE_GENERIC_READ and
|
||||
// FILE_GENERIC_EXECUTE both include SYNCHRONIZE, and rollback exists
|
||||
// precisely to undo objects that have just been ACL'd. Nothing is read or
|
||||
// written through this handle, so synchronous IO is not needed either.
|
||||
var handle windows.Handle
|
||||
var status windows.IO_STATUS_BLOCK
|
||||
if err := windows.NtCreateFile(
|
||||
&handle,
|
||||
windows.DELETE,
|
||||
&attributes,
|
||||
&status,
|
||||
nil,
|
||||
shapeAttribute,
|
||||
windowsACLDirectoryShare,
|
||||
windows.FILE_OPEN,
|
||||
shapeOption|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
); err != nil {
|
||||
if isWindowsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("open windows ACL component %s for delete: %w", name, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
|
||||
// One BOOLEAN: FILE_DISPOSITION_INFORMATION.DeleteFile = TRUE.
|
||||
disposition := byte(1)
|
||||
var setStatus windows.IO_STATUS_BLOCK
|
||||
if err := windows.NtSetInformationFile(
|
||||
handle,
|
||||
&setStatus,
|
||||
&disposition,
|
||||
uint32(unsafe.Sizeof(disposition)),
|
||||
windows.FileDispositionInformation,
|
||||
); err != nil {
|
||||
return fmt.Errorf("delete windows ACL component %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rejectWindowsACLReparseHandle refuses a handle that landed on a reparse point.
|
||||
//
|
||||
// Deliberately NOT verifyWindowsACLTargetNotRedirected: that one compares the
|
||||
// handle's resolved path against an expected pathname, which is exactly the
|
||||
// pathname dependency the handle-relative walk removes. A child opened relative
|
||||
// to a pinned parent is in the right place by construction even when the
|
||||
// pathname no longer leads there, so comparing paths would reject correct, safe
|
||||
// creates whenever the tree was legitimately renamed. The attribute is the only
|
||||
// thing worth checking here.
|
||||
func rejectWindowsACLReparseHandle(handle windows.Handle, name string) error {
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return fmt.Errorf("inspect windows ACL component %s: %w", name, err)
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return fmt.Errorf("refusing to work through reparse-point component %s: possible path swap during elevated setup", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyWindowsACLHandleIsCleanDirectory rejects a handle that landed on a
|
||||
// reparse point, or on an object other than the path asked for.
|
||||
func verifyWindowsACLHandleIsCleanDirectory(handle windows.Handle, path string) error {
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return fmt.Errorf("inspect windows ACL directory %s: %w", path, err)
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return fmt.Errorf("refusing to materialize under reparse-point path component %s: possible path swap during elevated setup", path)
|
||||
}
|
||||
return verifyWindowsACLTargetNotRedirected(handle, path)
|
||||
}
|
||||
|
||||
// isWindowsNotExist reports a missing-object error from either side of the API.
|
||||
// NtCreateFile returns NTSTATUS values, which do not map to os.ErrNotExist the
|
||||
// way the Win32 error codes do.
|
||||
func isWindowsNotExist(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
var status windows.NTStatus
|
||||
if errors.As(err, &status) {
|
||||
return status == windows.STATUS_OBJECT_NAME_NOT_FOUND || status == windows.STATUS_OBJECT_PATH_NOT_FOUND
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// THE SWAP THAT PATHNAMES CANNOT SURVIVE.
|
||||
//
|
||||
// This is the race behind the materialization P1. A pathname-based create
|
||||
// resolves the whole path again at the moment it runs, so an ancestor replaced
|
||||
// between the verification and the create sends the new directory somewhere else
|
||||
// entirely. Verifying afterwards is too late: the object already exists in the
|
||||
// wrong place.
|
||||
//
|
||||
// A handle pins the OBJECT, not the name. This test performs the swap for real,
|
||||
// in the window that used to be exploitable, and asserts the child still lands
|
||||
// in the directory that was verified.
|
||||
//
|
||||
// Worth noting for anyone extending this: the fix is what makes the race
|
||||
// testable at all. Against the old code the swap had to be threaded into the
|
||||
// middle of a function; here it is three ordinary lines between an open and a
|
||||
// create, because the handle is held across them.
|
||||
func TestChildCreationFollowsTheHandleNotThePath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
approved := filepath.Join(root, "approved")
|
||||
elsewhere := filepath.Join(root, "elsewhere")
|
||||
for _, dir := range []string{approved, elsewhere} {
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("seed %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verified once, exactly as setup does before it materializes anything.
|
||||
parent, err := openWindowsACLDirectoryNoFollow(approved)
|
||||
if err != nil {
|
||||
t.Fatalf("open approved directory: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
// THE SWAP, in the window that used to be exploitable: move the verified
|
||||
// directory aside and leave a junction to somewhere else wearing its name.
|
||||
moved := filepath.Join(root, "approved-moved")
|
||||
if err := os.Rename(approved, moved); err != nil {
|
||||
t.Skipf("cannot rename a directory with an open handle on this filesystem: %v", err)
|
||||
}
|
||||
makeJunction(t, approved, elsewhere)
|
||||
|
||||
// The create resolves against the handle, so it must ignore the junction now
|
||||
// sitting at the original pathname.
|
||||
child, created, err := createWindowsACLChildDirectory(parent, "materialized")
|
||||
if err != nil {
|
||||
t.Fatalf("create child relative to the pinned handle: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(child) }()
|
||||
if !created {
|
||||
t.Error("created = false for a directory that did not exist")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(moved, "materialized")); err != nil {
|
||||
t.Errorf("the child did not land in the verified directory: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(elsewhere, "materialized")); err == nil {
|
||||
t.Fatal("ESCAPED: the child was created through the junction, outside the approved tree")
|
||||
}
|
||||
}
|
||||
|
||||
// Materialization runs on trees that may already be half-built, so creating an
|
||||
// existing directory has to be a no-op rather than a failure. created must still
|
||||
// report the truth, because rollback deletes only what this call made.
|
||||
func TestCreatingAnExistingChildOpensItInstead(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "already"), 0o700); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
parent, err := openWindowsACLDirectoryNoFollow(root)
|
||||
if err != nil {
|
||||
t.Fatalf("open root: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
handle, created, err := createWindowsACLChildDirectory(parent, "already")
|
||||
if err != nil {
|
||||
t.Fatalf("open existing child: %v", err)
|
||||
}
|
||||
_ = windows.CloseHandle(handle)
|
||||
if created {
|
||||
t.Error("created = true for a directory that already existed; rollback would delete a user's data")
|
||||
}
|
||||
}
|
||||
|
||||
// The rollback counterpart. os.RemoveAll on a pathname whose ancestor has since
|
||||
// become a junction recurses outside the workspace and deletes unrelated trees,
|
||||
// which is the second P1. Resolving relative to the parent handle cannot.
|
||||
func TestDeleteFollowsTheHandleNotThePath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
approved := filepath.Join(root, "approved")
|
||||
elsewhere := filepath.Join(root, "elsewhere")
|
||||
for _, dir := range []string{approved, elsewhere} {
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("seed %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
// A bystander under the decoy: if the delete ever resolves by pathname it is
|
||||
// reachable, and its survival is what proves the delete did not.
|
||||
if err := os.Mkdir(filepath.Join(elsewhere, "victim"), 0o700); err != nil {
|
||||
t.Fatalf("seed victim: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(approved, "victim"), 0o700); err != nil {
|
||||
t.Fatalf("seed target: %v", err)
|
||||
}
|
||||
|
||||
parent, err := openWindowsACLDirectoryNoFollow(approved)
|
||||
if err != nil {
|
||||
t.Fatalf("open approved directory: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
moved := filepath.Join(root, "approved-moved")
|
||||
if err := os.Rename(approved, moved); err != nil {
|
||||
t.Skipf("cannot rename a directory with an open handle on this filesystem: %v", err)
|
||||
}
|
||||
makeJunction(t, approved, elsewhere)
|
||||
|
||||
if err := deleteWindowsACLChildDirectory(parent, "victim"); err != nil {
|
||||
t.Fatalf("delete relative to the pinned handle: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(elsewhere, "victim")); err != nil {
|
||||
t.Fatal("DESTRUCTIVE: rollback followed the junction and deleted a directory outside the approved tree")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(moved, "victim")); err == nil {
|
||||
t.Error("the directory inside the approved tree was not removed")
|
||||
}
|
||||
}
|
||||
|
||||
// A delete that cannot happen must SAY so.
|
||||
//
|
||||
// This is the gap that let a silent bug ship in the first version of this file.
|
||||
// It deleted with FILE_DELETE_ON_CLOSE, which defers the removal to cleanup,
|
||||
// where a non-empty directory makes it fail with nowhere to report it: the open
|
||||
// returned success, the close returned success, and the directory was still
|
||||
// there. The only test covering deletion used an EMPTY directory, so it passed
|
||||
// throughout. Rollback built on that would have reported success while leaving
|
||||
// materialized state on disk, which is worse than the os.RemoveAll it replaced,
|
||||
// because os.RemoveAll actually removed it.
|
||||
func TestDeletingANonEmptyDirectoryIsReported(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
populated := filepath.Join(root, "populated")
|
||||
if err := os.Mkdir(populated, 0o700); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(populated, "occupant"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("seed occupant: %v", err)
|
||||
}
|
||||
parent, err := openWindowsACLDirectoryNoFollow(root)
|
||||
if err != nil {
|
||||
t.Fatalf("open root: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
err = deleteWindowsACLChildDirectory(parent, "populated")
|
||||
if _, statErr := os.Stat(populated); statErr != nil {
|
||||
t.Fatalf("the directory was removed with its contents, which this delete must never do: %v", statErr)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("reported success while leaving the directory in place")
|
||||
}
|
||||
}
|
||||
|
||||
// The directory form must refuse a file rather than delete it, and the file form
|
||||
// must handle the one materialized target that is a file.
|
||||
func TestDeleteDistinguishesFilesFromDirectories(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "config"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
parent, err := openWindowsACLDirectoryNoFollow(root)
|
||||
if err != nil {
|
||||
t.Fatalf("open root: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
if err := deleteWindowsACLChildDirectory(parent, "config"); err == nil {
|
||||
t.Error("the directory delete accepted a file")
|
||||
}
|
||||
if err := deleteWindowsACLChildFile(parent, "config"); err != nil {
|
||||
t.Fatalf("deleteWindowsACLChildFile: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "config")); err == nil {
|
||||
t.Error("the file survived its delete")
|
||||
}
|
||||
}
|
||||
|
||||
// Rollback runs on failure paths where the object may never have been created,
|
||||
// so a missing child is success rather than an error to report.
|
||||
func TestDeletingAMissingChildIsNotAnError(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
parent, err := openWindowsACLDirectoryNoFollow(root)
|
||||
if err != nil {
|
||||
t.Fatalf("open root: %v", err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
if err := deleteWindowsACLChildDirectory(parent, "never-existed"); err != nil {
|
||||
t.Fatalf("deleting a missing child reported an error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The anchor open is the one pathname resolution in the walk, so it has to
|
||||
// refuse a junction itself rather than leaving it to a later check.
|
||||
func TestOpeningAJunctionAnchorIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
real := filepath.Join(root, "real")
|
||||
if err := os.Mkdir(real, 0o700); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
link := filepath.Join(root, "link")
|
||||
makeJunction(t, link, real)
|
||||
|
||||
handle, err := openWindowsACLDirectoryNoFollow(link)
|
||||
if err == nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatal("opened a junction as the materialization anchor; every create beneath it would land outside the approved tree")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// GetFinalPathNameByHandle flags. x/sys/windows does not export these.
|
||||
const (
|
||||
windowsFileNameNormalized uint32 = 0x0
|
||||
windowsVolumeNameDOS uint32 = 0x0
|
||||
)
|
||||
|
||||
// verifyWindowsACLTargetNotRedirected fails when an opened handle resolved
|
||||
// somewhere other than the requested path, which means a component along the way
|
||||
// is a reparse point.
|
||||
//
|
||||
// openWindowsACLTarget's FILE_FLAG_OPEN_REPARSE_POINT check covers the FINAL
|
||||
// component only; CreateFile still resolves ANCESTORS. A user who controls the
|
||||
// workspace can turn an ancestor — .git, say — into a junction before elevated
|
||||
// setup runs, and setup would then apply its DACL change to an object outside
|
||||
// the approved tree while the final-component check still passed. Junctions need
|
||||
// no privilege to create, unlike symlinks, so this is reachable by exactly the
|
||||
// unprivileged user the sandbox exists to contain.
|
||||
//
|
||||
// GetFinalPathNameByHandle answers where the handle actually landed, covering
|
||||
// every component in one call rather than walking the path and re-checking each
|
||||
// component (which would also race between the checks).
|
||||
//
|
||||
// The comparison basis must NEVER follow a reparse point, and getting that wrong
|
||||
// is what made this guard mostly ornamental.
|
||||
//
|
||||
// It used to compare against canonicalSandboxWorkspaceRoot(path), which runs
|
||||
// filepath.EvalSymlinks — the same resolution the kernel just performed. So for a
|
||||
// directory SYMLINK the two sides agreed precisely BECAUSE the redirect happened:
|
||||
// GetFinalPathNameByHandle followed the link to produce one side, EvalSymlinks
|
||||
// followed the same link to produce the other, and elevated setup went on to
|
||||
// rewrite the DACL of an object outside the workspace.
|
||||
//
|
||||
// Junctions were rejected, but by accident rather than by design. Go reports a
|
||||
// junction as ModeIrregular rather than ModeSymlink, so EvalSymlinks refuses it,
|
||||
// canonicalSandboxWorkspaceRoot falls back to the lexical path, and the mismatch
|
||||
// surfaces. That is a property of the standard library's mode bits, not of this
|
||||
// check: a Go release that resolved mount points would silently disarm the one
|
||||
// case this was known to catch.
|
||||
//
|
||||
// So the expected side is now normalized WITHOUT resolving anything. Casing and
|
||||
// 8.3 short names still have to compare equal, since a legitimate target can be
|
||||
// spelled either way and still be the same object — EqualFold covers the first
|
||||
// and GetLongPathName the second, and neither follows a link.
|
||||
//
|
||||
// This does mean a target deliberately spelled through a symlink is now refused.
|
||||
// That is the intended direction: the whole question here is whether the object
|
||||
// the handle landed on is the object that was named, and a caller that wants a
|
||||
// link's target should name the target.
|
||||
func verifyWindowsACLTargetNotRedirected(handle windows.Handle, path string) error {
|
||||
buffer := make([]uint16, windows.MAX_LONG_PATH)
|
||||
n, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), windowsFileNameNormalized|windowsVolumeNameDOS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve windows ACL target %s: %w", path, err)
|
||||
}
|
||||
if int(n) < len(buffer) {
|
||||
buffer = buffer[:n]
|
||||
}
|
||||
actual := trimWindowsExtendedPathPrefix(windows.UTF16ToString(buffer))
|
||||
expected := trimWindowsExtendedPathPrefix(windowsACLComparablePath(path))
|
||||
if !strings.EqualFold(filepath.Clean(actual), filepath.Clean(expected)) {
|
||||
return fmt.Errorf("refusing to apply ACL to %s: it resolves to %s, so a parent directory is a reparse point (possible path swap during elevated setup)", path, actual)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// windowsACLComparablePath normalizes a path for comparison against where a
|
||||
// handle actually landed, WITHOUT resolving any reparse point.
|
||||
//
|
||||
// The two things that must still compare equal are casing and 8.3 short names,
|
||||
// because either can spell the same object. GetLongPathName expands the short
|
||||
// form by reading directory entries; it does not follow a symlink or a junction
|
||||
// to its target, which is the entire reason it is used here instead of
|
||||
// EvalSymlinks. Casing is left to the caller's EqualFold.
|
||||
//
|
||||
// Every failure degrades to the lexically cleaned path rather than to a resolved
|
||||
// one. A path this cannot expand is compared as written, which can only produce
|
||||
// a spurious MISMATCH — a refusal — and never a spurious match. That is the
|
||||
// direction a redirect check has to fail in.
|
||||
func windowsACLComparablePath(path string) string {
|
||||
cleaned := filepath.Clean(strings.TrimSpace(path))
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return cleaned
|
||||
}
|
||||
if absolute, err := filepath.Abs(cleaned); err == nil {
|
||||
cleaned = absolute
|
||||
}
|
||||
wide, err := windows.UTF16PtrFromString(cleaned)
|
||||
if err != nil {
|
||||
return cleaned
|
||||
}
|
||||
buffer := make([]uint16, windows.MAX_LONG_PATH)
|
||||
n, err := windows.GetLongPathName(wide, &buffer[0], uint32(len(buffer)))
|
||||
if err != nil || n == 0 || int(n) >= len(buffer) {
|
||||
return cleaned
|
||||
}
|
||||
return filepath.Clean(windows.UTF16ToString(buffer[:n]))
|
||||
}
|
||||
|
||||
// trimWindowsExtendedPathPrefix strips the \?\ form GetFinalPathNameByHandle
|
||||
// returns so it can be compared with an ordinary path.
|
||||
func trimWindowsExtendedPathPrefix(path string) string {
|
||||
// Built from filepath.Separator rather than written as literals so the
|
||||
// backslashes cannot be miscounted by whatever writes this file.
|
||||
sep := string(filepath.Separator)
|
||||
devicePrefix := sep + sep + "?" + sep
|
||||
uncPrefix := devicePrefix + "UNC" + sep
|
||||
if strings.HasPrefix(path, uncPrefix) {
|
||||
return sep + sep + strings.TrimPrefix(path, uncPrefix)
|
||||
}
|
||||
return strings.TrimPrefix(path, devicePrefix)
|
||||
}
|
||||
|
||||
// verifyWindowsACLPathComponentNotRedirected opens one path component no-follow
|
||||
// and refuses it if it is a reparse point or resolves anywhere other than its own
|
||||
// pathname. Because GetFinalPathNameByHandle answers for the WHOLE resolved path,
|
||||
// verifying a single existing component also clears every ancestor above it.
|
||||
//
|
||||
// A missing component surfaces as os.ErrNotExist so the caller can walk further
|
||||
// up. Only FILE_READ_ATTRIBUTES is requested: this inspects, it never writes, and
|
||||
// asking for more would fail on ancestors the setup process has no rights on.
|
||||
func verifyWindowsACLPathComponentNotRedirected(path string) error {
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode windows ACL path component %s: %w", path, err)
|
||||
}
|
||||
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 {
|
||||
// syscall.Errno.Is maps ERROR_FILE_NOT_FOUND/ERROR_PATH_NOT_FOUND to
|
||||
// os.ErrNotExist, so the caller's errors.Is check keeps working.
|
||||
return fmt.Errorf("open windows ACL path component %s: %w", path, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return fmt.Errorf("inspect windows ACL path component %s: %w", path, err)
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return fmt.Errorf("refusing to materialize under reparse-point path component %s: possible path swap during elevated setup", path)
|
||||
}
|
||||
return verifyWindowsACLTargetNotRedirected(handle, path)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// An unprivileged user who controls the workspace can turn an ancestor of a
|
||||
// configured ACL target into a junction before elevated setup runs. CreateFile
|
||||
// resolves ancestors even with FILE_FLAG_OPEN_REPARSE_POINT, so the
|
||||
// final-component check passes and setup would rewrite the DACL of an object
|
||||
// outside the approved tree.
|
||||
//
|
||||
// Junctions, unlike symlinks, need no privilege — which is what makes this
|
||||
// reachable by exactly the user the sandbox is containing.
|
||||
func TestOpenWindowsACLTargetRefusesAJunctionAncestor(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
workspace := filepath.Join(base, "workspace")
|
||||
outside := filepath.Join(base, "OUTSIDE")
|
||||
for _, dir := range []string{workspace, outside} {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
// The object the attacker wants setup to touch.
|
||||
victim := filepath.Join(outside, "hooks")
|
||||
if err := os.MkdirAll(victim, 0o700); err != nil {
|
||||
t.Fatalf("mkdir victim: %v", err)
|
||||
}
|
||||
|
||||
// .git is the ancestor, and it is a junction to OUTSIDE.
|
||||
gitDir := filepath.Join(workspace, ".git")
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", gitDir, outside).CombinedOutput(); err != nil {
|
||||
t.Skipf("cannot create a junction here: %v %s", err, out)
|
||||
}
|
||||
|
||||
// This is the path setup would be configured with.
|
||||
target := filepath.Join(gitDir, "hooks")
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Fatalf("precondition: the junction should make %s reachable: %v", target, err)
|
||||
}
|
||||
|
||||
handle, _, err := openWindowsACLTarget(target)
|
||||
if err == nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatal("opened an ACL target through a junction ancestor; setup would have rewritten a DACL outside the workspace")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reparse point") {
|
||||
t.Errorf("error = %v, want it to name the reparse point", err)
|
||||
}
|
||||
t.Logf("refused as expected: %v", err)
|
||||
}
|
||||
|
||||
// The guard must not reject ordinary targets, including ones spelled
|
||||
// non-canonically — a differently-cased path is the same object, not a redirect.
|
||||
func TestOpenWindowsACLTargetAcceptsAnOrdinaryTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
nested := filepath.Join(dir, "Nested")
|
||||
if err := os.MkdirAll(nested, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
for _, spelling := range []string{nested, strings.ToLower(nested)} {
|
||||
handle, isDir, err := openWindowsACLTarget(spelling)
|
||||
if err != nil {
|
||||
t.Fatalf("openWindowsACLTarget(%q): %v", spelling, err)
|
||||
}
|
||||
if !isDir {
|
||||
t.Errorf("%q reported as not a directory", spelling)
|
||||
}
|
||||
_ = windows.CloseHandle(handle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// THE RESTORE MUST LAND ON THE OBJECT THE DACL CAME FROM.
|
||||
//
|
||||
// Creation and the materialization unwind were both bound to handles, and the
|
||||
// restore branch was left re-resolving the target by pathname. Its comment
|
||||
// justified that with a no-follow re-open, which rules out a REPARSE POINT
|
||||
// swapped in since apply and nothing else. The other substitution passes it
|
||||
// untouched: rename the target aside and put an ordinary directory of the same
|
||||
// name in its place. Nothing in that decoy is a link, so the no-follow open
|
||||
// succeeds, the pre-apply DACL is written onto the attacker's object, and the
|
||||
// real one keeps the ACEs from the setup that just aborted.
|
||||
func TestRollbackRefusesToRestoreOntoAReplacedTarget(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
approved := filepath.Join(root, "ws")
|
||||
target := filepath.Join(approved, "target")
|
||||
if err := os.MkdirAll(target, 0o700); err != nil {
|
||||
t.Fatalf("seed the approved tree: %v", err)
|
||||
}
|
||||
|
||||
handle, _, err := openWindowsACLTarget(target)
|
||||
if err != nil {
|
||||
t.Fatalf("open the target: %v", err)
|
||||
}
|
||||
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatalf("read the target DACL: %v", err)
|
||||
}
|
||||
identity, err := windowsIdentityOfHandle(handle)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatalf("read the target identity: %v", err)
|
||||
}
|
||||
_ = windows.CloseHandle(handle)
|
||||
|
||||
// The swap, exactly as it would happen between apply and rollback. Ordinary
|
||||
// directories throughout: no links, no reparse points.
|
||||
if err := os.Rename(target, target+"-moved"); err != nil {
|
||||
t.Skipf("cannot rename here: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(target, 0o700); err != nil {
|
||||
t.Fatalf("plant the replacement: %v", err)
|
||||
}
|
||||
|
||||
snapshot := windowsACLSnapshot{Path: target, Descriptor: descriptor, TargetID: identity}
|
||||
err = rollbackWindowsACLSnapshots([]windowsACLSnapshot{snapshot})
|
||||
if err == nil {
|
||||
t.Fatal("rollback wrote the pre-apply DACL onto a different object wearing the target's name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no longer the object") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And an untouched target still restores, or the guard above would be satisfied
|
||||
// by a rollback that refuses everything.
|
||||
func TestRollbackRestoresAnUnchangedTarget(t *testing.T) {
|
||||
target := filepath.Join(t.TempDir(), "target")
|
||||
if err := os.Mkdir(target, 0o700); err != nil {
|
||||
t.Fatalf("seed the target: %v", err)
|
||||
}
|
||||
|
||||
handle, _, err := openWindowsACLTarget(target)
|
||||
if err != nil {
|
||||
t.Fatalf("open the target: %v", err)
|
||||
}
|
||||
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatalf("read the target DACL: %v", err)
|
||||
}
|
||||
identity, err := windowsIdentityOfHandle(handle)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatalf("read the target identity: %v", err)
|
||||
}
|
||||
_ = windows.CloseHandle(handle)
|
||||
|
||||
snapshot := windowsACLSnapshot{Path: target, Descriptor: descriptor, TargetID: identity}
|
||||
if err := rollbackWindowsACLSnapshots([]windowsACLSnapshot{snapshot}); err != nil {
|
||||
t.Fatalf("an unchanged target was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// THE COMMENT AT rollbackWindowsACLSnapshots CLAIMED A TEST THAT DID NOT EXIST.
|
||||
//
|
||||
// It said "TestRollbackUnwindsDescendantsBeforeAncestors pins it" about the
|
||||
// reverse iteration order, and grepping the repo found only that sentence. The
|
||||
// ordering is load-bearing twice over: a materialized directory must be empty
|
||||
// before its own removal is attempted, and SetSecurityInfo propagates
|
||||
// inheritable ACEs downward so the ancestor has to be restored last. Neither
|
||||
// was pinned by anything.
|
||||
func TestRollbackUnwindsDescendantsBeforeAncestors(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
ancestor := filepath.Join(root, "ws")
|
||||
descendant := filepath.Join(ancestor, "child")
|
||||
if err := os.MkdirAll(descendant, 0o700); err != nil {
|
||||
t.Fatalf("seed the tree: %v", err)
|
||||
}
|
||||
|
||||
snapshotFor := func(path string) windowsACLSnapshot {
|
||||
t.Helper()
|
||||
handle, _, err := openWindowsACLTarget(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", path, err)
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Fatalf("read the DACL for %s: %v", path, err)
|
||||
}
|
||||
identity, err := windowsIdentityOfHandle(handle)
|
||||
if err != nil {
|
||||
t.Fatalf("read the identity for %s: %v", path, err)
|
||||
}
|
||||
return windowsACLSnapshot{Path: path, Descriptor: descriptor, TargetID: identity}
|
||||
}
|
||||
|
||||
// Ascending by path key, the order applyWindowsACLPlan produces.
|
||||
snapshots := []windowsACLSnapshot{snapshotFor(ancestor), snapshotFor(descendant)}
|
||||
|
||||
var order []string
|
||||
restore := windowsACLRestoreHook
|
||||
windowsACLRestoreHook = func(path string) { order = append(order, path) }
|
||||
t.Cleanup(func() { windowsACLRestoreHook = restore })
|
||||
|
||||
if err := rollbackWindowsACLSnapshots(snapshots); err != nil {
|
||||
t.Fatalf("rollbackWindowsACLSnapshots: %v", err)
|
||||
}
|
||||
if len(order) != 2 {
|
||||
t.Fatalf("restored %d targets, want 2: %v", len(order), order)
|
||||
}
|
||||
if order[0] != descendant || order[1] != ancestor {
|
||||
t.Errorf("restore order was %v; the descendant must be restored before its ancestor, because SetSecurityInfo propagates inheritable ACEs downward", order)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// THE COMPARISON BASIS MUST NOT FOLLOW WHAT IT IS CHECKING FOR.
|
||||
//
|
||||
// verifyWindowsACLTargetNotRedirected asks GetFinalPathNameByHandle where the
|
||||
// handle landed and compares that against the requested path. It used to build
|
||||
// the second side with filepath.EvalSymlinks, which is the same resolution the
|
||||
// kernel had just performed, so for a directory symlink the two sides agreed
|
||||
// BECAUSE the redirect happened, and elevated setup rewrote the DACL of an
|
||||
// object outside the workspace.
|
||||
//
|
||||
// Junctions were caught, but only because Go reports one as ModeIrregular and
|
||||
// EvalSymlinks declines it, leaving the lexical path to disagree. That is a
|
||||
// standard-library detail, not a property of the guard.
|
||||
|
||||
// The property, testable with no privilege at all: the normalizer must not
|
||||
// resolve a reparse point.
|
||||
//
|
||||
// HONEST LIMIT, because it would be easy to read more into this than it proves.
|
||||
// A junction is used because creating one needs no privilege, and EvalSymlinks
|
||||
// does not resolve a junction either — so this test passes against BOTH the old
|
||||
// basis and the new one, and reverting the fix does not fail it. It is an
|
||||
// invariant test, not a regression for this change: it pins that normalization
|
||||
// leaves a reparse point alone, which is what would break first if a future Go
|
||||
// started resolving mount points and silently disarmed the junction case.
|
||||
//
|
||||
// The case that actually separates the two bases is the directory symlink below,
|
||||
// and that one skips without privilege. On this machine it skipped.
|
||||
func TestACLComparablePathDoesNotResolveAReparsePoint(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
outside := filepath.Join(base, "outside")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "hooks"), 0o700); err != nil {
|
||||
t.Fatalf("mkdir outside: %v", err)
|
||||
}
|
||||
link := filepath.Join(base, "link")
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, outside).CombinedOutput(); err != nil {
|
||||
t.Skipf("cannot create a junction here: %v (%s)", err, out)
|
||||
}
|
||||
|
||||
through := filepath.Join(link, "hooks")
|
||||
comparable := windowsACLComparablePath(through)
|
||||
|
||||
// Compared against the normalizer's OWN answer for each path, never against
|
||||
// the raw input. GetLongPathName legitimately rewrites the string: on a CI
|
||||
// runner the temp directory is an 8.3 short name, so expanding RUNNER~1 to
|
||||
// runneradmin makes the result differ from what was passed in while naming
|
||||
// exactly the same object. An earlier version of this test asserted equality
|
||||
// with the input and failed on Windows CI for that reason, which was the test
|
||||
// being wrong rather than the code.
|
||||
target := windowsACLComparablePath(filepath.Join(outside, "hooks"))
|
||||
|
||||
// The whole property: normalizing a path THROUGH the reparse point must not
|
||||
// produce the target's normalization. If it did, the guard would be comparing
|
||||
// a redirected handle against a redirected expectation and matching itself.
|
||||
if strings.EqualFold(filepath.Clean(comparable), filepath.Clean(target)) {
|
||||
t.Errorf("windowsACLComparablePath resolved through the reparse point:\n through %s\n target %s", comparable, target)
|
||||
}
|
||||
// And it still has to name the link component, or it resolved to something
|
||||
// else entirely rather than leaving the path alone.
|
||||
if !strings.Contains(strings.ToLower(comparable), "link") {
|
||||
t.Errorf("windowsACLComparablePath lost the link component: %s", comparable)
|
||||
}
|
||||
|
||||
// The old basis is checked alongside for contrast: where it resolves, it
|
||||
// could not have been the comparison basis.
|
||||
if resolved := canonicalSandboxWorkspaceRoot(through); strings.EqualFold(filepath.Clean(resolved), filepath.Clean(filepath.Join(outside, "hooks"))) {
|
||||
t.Logf("confirmed: canonicalSandboxWorkspaceRoot resolves %s to %s, which is why it cannot be the basis", through, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
// The end-to-end symlink case the finding names. Directory symlinks need either
|
||||
// Developer Mode or SeCreateSymbolicLinkPrivilege, so this skips where it cannot
|
||||
// build the fixture rather than passing vacuously.
|
||||
func TestOpenWindowsACLTargetRefusesASymlinkAncestor(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
outside := filepath.Join(base, "outside")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "hooks"), 0o700); err != nil {
|
||||
t.Fatalf("mkdir outside: %v", err)
|
||||
}
|
||||
workspace := filepath.Join(base, "workspace")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatalf("mkdir workspace: %v", err)
|
||||
}
|
||||
|
||||
// A DIRECTORY symlink, not a junction: os.Symlink maps to CreateSymbolicLinkW
|
||||
// and the directory flag is inferred from the existing target.
|
||||
link := filepath.Join(workspace, ".git")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Skipf("cannot create a directory symlink here (needs Developer Mode or SeCreateSymbolicLinkPrivilege): %v", err)
|
||||
}
|
||||
|
||||
target := filepath.Join(link, "hooks")
|
||||
handle, _, err := openWindowsACLTarget(target)
|
||||
if err == nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
t.Fatal("elevated setup opened a target through a symlinked ancestor; its DACL change would land outside the workspace")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reparse point") {
|
||||
t.Errorf("refusal does not name the cause: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -73,10 +73,19 @@ func TestBuildWindowsACLPlanUsesReadOnlySIDWithoutWriteRoots(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsACLPlan: %v", err)
|
||||
}
|
||||
if len(plan.Entries) != 1 {
|
||||
t.Fatalf("ACL entries = %#v, want one deny-read entry", plan.Entries)
|
||||
// Two deny entries, one per SID the token can carry here. ReadOnly is the
|
||||
// only capability a profile with no write roots gets, and ReadAllow is the
|
||||
// read grant a DenyRead profile's strict token carries: denying just one of
|
||||
// them leaves the carveout readable through the other.
|
||||
if len(plan.Entries) != 2 {
|
||||
t.Fatalf("ACL entries = %#v, want a deny-read entry per capability SID", plan.Entries)
|
||||
}
|
||||
assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, caps.ReadOnly, true)
|
||||
readSID, err := WindowsReadAllowSID(home)
|
||||
if err != nil {
|
||||
t.Fatalf("WindowsReadAllowSID: %v", err)
|
||||
}
|
||||
assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, readSID, true)
|
||||
}
|
||||
|
||||
func TestBuildWindowsACLPlanRejectsUnrestrictedProfiles(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ELEVATED SETUP MUST NOT CREATE THROUGH A JUNCTION THE CALLER PLANTED.
|
||||
//
|
||||
// ensureWindowsSandboxRuntimeCandidates runs as Administrator, and os.MkdirAll
|
||||
// follows links. Both candidate ancestries are prepared by the invoking user, so
|
||||
// a junction at a not-yet-created component below the cache directory — or at the
|
||||
// fallback anchor — made elevated setup build the runtime tree beneath a
|
||||
// redirected, Administrator-writable target. The ACL applier's no-follow check
|
||||
// runs later and spots the redirection, but the privileged create has already
|
||||
// happened by then, outside its rollback boundary.
|
||||
//
|
||||
// A junction needs no privilege on Windows, so this reproduces the caller's half
|
||||
// of the race on an ordinary unelevated box.
|
||||
func TestRuntimeCandidateCreationRefusesAJunctionedComponent(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
target := t.TempDir()
|
||||
|
||||
previous := sandboxUserCacheDir
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
|
||||
workspace := t.TempDir()
|
||||
candidate, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot))
|
||||
if !ok {
|
||||
t.Fatalf("SETUP INVALID: no deterministic candidate for %s under %s", workspace, cacheRoot)
|
||||
}
|
||||
// The component the caller controls: the first one Zero owns, below the
|
||||
// operator's cache directory.
|
||||
owned := filepath.Join(canonicalSandboxWorkspaceRoot(cacheRoot), "zero")
|
||||
if !strings.HasPrefix(strings.ToLower(candidate), strings.ToLower(owned)) {
|
||||
t.Fatalf("SETUP INVALID: candidate %s does not sit under the owned component %s", candidate, owned)
|
||||
}
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", owned, target).CombinedOutput(); err != nil {
|
||||
t.Fatalf("SETUP INVALID: mklink /J: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
err := ensureRuntimeCandidateDir(candidate)
|
||||
if err == nil {
|
||||
t.Fatal("elevated setup created the runtime tree through a junction the caller planted")
|
||||
}
|
||||
|
||||
// The whole point: nothing may appear beneath the redirected target.
|
||||
entries, readErr := os.ReadDir(target)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("setup created %v beneath the redirected target", names)
|
||||
}
|
||||
}
|
||||
|
||||
// And an ordinary cache directory still gets its candidate, or the refusal above
|
||||
// would be satisfied by a creator that refuses everything.
|
||||
func TestRuntimeCandidateCreationStillCreatesAnOrdinaryTree(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
previous := sandboxUserCacheDir
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
|
||||
workspace := t.TempDir()
|
||||
candidate, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot))
|
||||
if !ok {
|
||||
t.Fatalf("SETUP INVALID: no deterministic candidate for %s", workspace)
|
||||
}
|
||||
if err := ensureRuntimeCandidateDir(candidate); err != nil {
|
||||
t.Fatalf("an ordinary cache tree was refused: %v", err)
|
||||
}
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil || !info.IsDir() {
|
||||
t.Fatalf("the candidate was not created: err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writer) int {
|
||||
@@ -14,6 +17,15 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
// The marker attests to a PLAN. This attests to the OBJECT that plan was
|
||||
// applied to, which cleanup can reclaim and an ordinary run then recreates
|
||||
// without the capability ACE. Checked before the token is minted, so the
|
||||
// operator is told to rerun setup instead of watching every sandboxed write
|
||||
// fail with a bare access-denied.
|
||||
if err := verifyWindowsRuntimeRootCapability(config); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
case WindowsSandboxLevelUnelevated:
|
||||
if err := ensureWindowsUnelevatedSetup(config); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
@@ -59,7 +71,7 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
|
||||
// granting only the user, Administrators, and SYSTEM (msys2-runtime
|
||||
// sigproc.cc sigproc_init -> sec_user_nih -> __sec_user), and a
|
||||
// WRITE_RESTRICTED write check must ALSO match one of the token's
|
||||
// restricted SIDs (logon SID, Everyone, capability SIDs). None of the
|
||||
// restricted SIDs (logon SID and capability SIDs). None of the
|
||||
// granted SIDs can be added to the restricted list without collapsing the
|
||||
// write jail (each has write access nearly everywhere), so MSYS startup
|
||||
// dies with "couldn't create signal pipe" or "CreateFileMapping <SID>.1",
|
||||
@@ -75,6 +87,124 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
|
||||
// reads under that flag (#612). Profiles with DenyRead keep the fully
|
||||
// restricted token, trading spawn capability for read-deny enforcement.
|
||||
writeRestricted := len(config.PermissionProfile.FileSystem.DenyRead) == 0
|
||||
|
||||
// That strict token then needs the read capability in its SID list, because
|
||||
// the restricted-SID check covers reads once WRITE_RESTRICTED is gone. See
|
||||
// windowsRestrictedTokenSIDsForProfile.
|
||||
tokenSIDs, err = windowsRestrictedTokenSIDsForProfile(tokenSIDs, config.SandboxHome, writeRestricted)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
|
||||
// A provisioned sandbox principal replaces the restricted token entirely: it
|
||||
// is a separate account, so reads outside its granted roots are denied by the
|
||||
// filesystem rather than left open the way a same-user restricted token has
|
||||
// to leave them (#662). Absent, unprovisioned or opted-out, ok is false and
|
||||
// the restricted-token backend below runs exactly as before.
|
||||
principalToken, ok, err := windowsSandboxPrincipalToken(config)
|
||||
if err != nil {
|
||||
// The one path here that does not fall back, because a provisioned but
|
||||
// unusable principal means the sandbox is broken rather than absent. Say
|
||||
// how to get out of it, since the whole backend is opt-in.
|
||||
fmt.Fprintf(stderr, "%s: sandbox principal is provisioned but unusable: %v. Re-run `zero sandbox setup` from an elevated terminal, or unset %s to fall back to the restricted-token sandbox.\n",
|
||||
WindowsSandboxCommandRunnerName, err, windowsSandboxIdentityEnv)
|
||||
return 1
|
||||
}
|
||||
if ok {
|
||||
defer principalToken.Close()
|
||||
// The principal gets its own identity AND the write jail, not one or the
|
||||
// other. Its ACEs confine reads; without the restricted token it would
|
||||
// still hold every write its ambient memberships grant, so a profile
|
||||
// permitting writes only to the workspace could still write anywhere
|
||||
// BATCH or BUILTIN\Users may — C:\Users\Public\Documents, for one.
|
||||
//
|
||||
// The principal's own SID is deliberately NOT a restricting SID, and that
|
||||
// is the whole of the write jail on this path.
|
||||
//
|
||||
// WRITE_RESTRICTED allows a write only when BOTH the normal token and the
|
||||
// restricting-SID list allow it. The account SID is already enabled in the
|
||||
// normal token, so listing it as a restricting SID makes the second check
|
||||
// a formality for anything granted to that account: every path carrying a
|
||||
// direct principal ACE passes both halves and is writable wherever it
|
||||
// sits. The principal's own profile directory, which Windows creates on
|
||||
// first logon with exactly such an ACE, is outside every configured write
|
||||
// root and was writable for that reason.
|
||||
//
|
||||
// Same defect as the World SID in the restricted list (#865), with the
|
||||
// account SID in place of Everyone: a SID already carried by the normal
|
||||
// token cannot also serve as the restriction on it.
|
||||
//
|
||||
// Confining to the capability SIDs alone restores the intersection the
|
||||
// jail is supposed to be. A configured write root carries BOTH a principal
|
||||
// ACE, from the principal plan, satisfying the normal token, AND a
|
||||
// capability ACE, from BuildWindowsACLPlan which setup applies on every
|
||||
// path, satisfying the restriction. So the principal keeps the tree it
|
||||
// owns, while anything holding only a principal ACE now fails the
|
||||
// restricted check.
|
||||
principalUser, err := principalToken.GetTokenUser()
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": read sandbox principal SID: "+err.Error())
|
||||
return 1
|
||||
}
|
||||
jailSIDs := windowsPrincipalJailSIDs(tokenSIDs, principalUser.User.Sid.String())
|
||||
// A strict token restricts READS too, and the account SID that carries the
|
||||
// read roots was just removed above. Without the read capability the jail
|
||||
// stops being a write jail and becomes a total one: no read root, no
|
||||
// executable, nothing. BuildWindowsACLPlan grants this same SID on every
|
||||
// read root and denies it on every DenyRead path, so the allow-list and the
|
||||
// restriction stay one decision.
|
||||
if !writeRestricted {
|
||||
readSID, err := WindowsReadAllowSID(config.SandboxHome)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": resolve sandbox read capability: "+err.Error())
|
||||
return 1
|
||||
}
|
||||
jailSIDs = append(jailSIDs, readSID)
|
||||
}
|
||||
jailedToken, err := restrictWindowsTokenForCapabilitySIDs(principalToken, jailSIDs, writeRestricted)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
defer jailedToken.Close()
|
||||
// CreateProcessAsUser below is about to be handed a token for a DIFFERENT
|
||||
// account, which forfeits the own-token exemption the ordinary restricted
|
||||
// path relies on. Enable what that needs, and refuse with a specific reason
|
||||
// when this process cannot, rather than letting an unelevated run surface a
|
||||
// bare "Access is denied" that reads as the command being rejected.
|
||||
//
|
||||
// Refusing rather than falling back: an operator who set the opt-in to
|
||||
// confine reads must not be handed the same-user restricted token while
|
||||
// believing otherwise.
|
||||
if err := enableWindowsPrincipalLaunchPrivileges(); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
// Stop the child describing the CALLER. Everything identifying the account
|
||||
// survived from the invoking user's environment, so a principal command
|
||||
// resolved its per-user state through paths inside a profile it cannot
|
||||
// open. Applied here, on the principal path only, because this is the first
|
||||
// point that knows which account the command is about to run as.
|
||||
// The role has to be the one the token above was minted for, or the child
|
||||
// is told it is the other account and resolves its per-user state under a
|
||||
// profile it cannot open.
|
||||
config.Env = windowsPrincipalIdentityEnvironment(
|
||||
config.Env,
|
||||
windowsSandboxUserName(
|
||||
windowsSandboxPrincipalKey(config),
|
||||
windowsSandboxRoleForNetwork(config.PermissionProfile.Network.Mode),
|
||||
),
|
||||
config.PermissionProfile.Runtime,
|
||||
)
|
||||
exitCode, err := runWindowsCommandAsUser(jailedToken, config)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
|
||||
token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs, writeRestricted)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
@@ -111,9 +241,111 @@ func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error {
|
||||
if marker.contains(applied) {
|
||||
return nil
|
||||
}
|
||||
// THE TIER BOUNDARY, STATED BEFORE ANY MUTATION IS ATTEMPTED.
|
||||
//
|
||||
// One plan is consumed by two tiers with different authority. A profile that
|
||||
// carries DenyRead runs on a strict token, and the strict token applies the
|
||||
// restricted-SID check to READS, so the read capability has to be granted
|
||||
// wherever the command reads from -- including the volume root that
|
||||
// permissionProfileReadRoots seeds. Elevated setup can write that DACL. An
|
||||
// ordinary user cannot, and the common opener asks for WRITE_DAC on every
|
||||
// entry, so this tier fails on that one root every time.
|
||||
//
|
||||
// Dropping the root ACE instead is not an option: the strict token would then
|
||||
// fail its own read check for the executable and every ambient read. So the
|
||||
// tier is refused explicitly, naming the root and the reason, rather than
|
||||
// discovered as an ACCESS_DENIED after the fact. The real smoke test misses
|
||||
// this because it substitutes a user-owned temporary directory for the
|
||||
// production read root.
|
||||
if root := windowsPlanVolumeRootGrant(plan); root != "" {
|
||||
return fmt.Errorf("unelevated sandbox setup cannot grant the read capability at the volume root %s, which this profile needs because it configures denyRead and therefore runs on a fully restricted token: "+
|
||||
"changing that directory's permissions requires Administrator rights. "+
|
||||
"Run `zero sandbox setup` from an elevated (Administrator) terminal, "+
|
||||
"or remove denyRead from the sandbox configuration so the command can run on a write-restricted token instead", root)
|
||||
}
|
||||
if _, err := applyWindowsACLPlan(plan); err != nil {
|
||||
// Refusing to run is right: without these ACEs the write jail does not
|
||||
// exist, so continuing would run the command believing it is sandboxed
|
||||
// when it is not. What was wrong was the diagnosis. Every failure got the
|
||||
// same "the workspace may be on a filesystem you do not own" guess, and
|
||||
// the suggested remedy was elevated setup, which does not help at all
|
||||
// when the real problem is one root in the plan that nobody can ACL.
|
||||
//
|
||||
// Being precise matters because this failure repeats: the success marker
|
||||
// is only recorded on success, so the same plan fails identically on
|
||||
// every later command until the offending root leaves it. A reader who
|
||||
// cannot tell which root is at fault has no way out of that.
|
||||
// Every remedy named below is one the reader can actually carry out.
|
||||
// Both messages used to offer `--sandbox forbid`, which is not an option
|
||||
// at all: SandboxPreferenceForbid is an internal engine state with no flag
|
||||
// behind it, so acting on it produced an unknown option and left the reader
|
||||
// stuck on the failure they had just been told how to clear. Advice that
|
||||
// does not work costs more than none, since finding that out takes time.
|
||||
if denied := windowsACLPlanDeniedPath(err); denied != "" {
|
||||
return fmt.Errorf("apply unelevated workspace ACLs: %w; %s cannot have its permissions changed by this user, "+
|
||||
"so the sandbox cannot enforce a write boundary there and will not run the command. "+
|
||||
"That path is one of this workspace's sandbox roots, usually a system directory that arrived via TEMP or TMP. "+
|
||||
"Check those, or turn the sandbox off in your user config with "+
|
||||
`"sandbox": {"enabled": false}. `+
|
||||
"Running `zero sandbox setup` elevated will NOT fix this", err, denied)
|
||||
}
|
||||
return fmt.Errorf("apply unelevated workspace ACLs: %w — the workspace may be on a filesystem the current user does not own; "+
|
||||
"run `zero sandbox setup` from an elevated (Administrator) terminal, or re-run with `--sandbox forbid` to skip OS sandboxing", err)
|
||||
"run `zero sandbox setup` from an elevated (Administrator) terminal, "+
|
||||
`or turn the sandbox off in your user config with "sandbox": {"enabled": false}`, err)
|
||||
}
|
||||
return recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied)
|
||||
}
|
||||
|
||||
// windowsACLPlanDeniedPath pulls the target path out of an apply failure that
|
||||
// was an access denial, and returns "" for anything else.
|
||||
//
|
||||
// applyWindowsACLPathGroup already wraps the path into its error, so this reads
|
||||
// the message rather than threading a typed error through four layers for one
|
||||
// diagnostic. The string it matches is produced in the same package by
|
||||
// openWindowsACLTarget, and a test pins the pairing so the two cannot drift
|
||||
// apart silently.
|
||||
func windowsACLPlanDeniedPath(err error) string {
|
||||
if err == nil || !errors.Is(err, os.ErrPermission) {
|
||||
return ""
|
||||
}
|
||||
const marker = "open windows ACL target "
|
||||
message := err.Error()
|
||||
start := strings.Index(message, marker)
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
// Colon-SPACE, not colon. The wrapper is "...target %s: %w", and on Windows
|
||||
// the path itself starts with a drive colon, so splitting on the first colon
|
||||
// returns "C". A drive colon is always followed by a separator, never a
|
||||
// space, which makes ": " the only unambiguous boundary here.
|
||||
rest := message[start+len(marker):]
|
||||
end := strings.Index(rest, ": ")
|
||||
if end <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(rest[:end])
|
||||
}
|
||||
|
||||
// windowsPrincipalJailSIDs returns the restricting SIDs for a principal command.
|
||||
//
|
||||
// A named function with its own test rather than an inline slice literal,
|
||||
// because the defect it encodes was invisible: the production composition added
|
||||
// the account's own SID while the only jail test granted a GROUP, so the
|
||||
// configuration that shipped was never the configuration under test.
|
||||
//
|
||||
// accountSID is taken and DELIBERATELY EXCLUDED rather than simply not passed.
|
||||
// Naming it here makes the exclusion the function's stated contract instead of an
|
||||
// omission a later edit could undo without noticing, and it also removes the SID
|
||||
// should it ever arrive through capabilitySIDs, which is the same route the World
|
||||
// SID took in #865.
|
||||
func windowsPrincipalJailSIDs(capabilitySIDs []string, accountSID string) []string {
|
||||
account := strings.TrimSpace(accountSID)
|
||||
jail := make([]string, 0, len(capabilitySIDs))
|
||||
for _, sid := range capabilitySIDs {
|
||||
if account != "" && strings.EqualFold(strings.TrimSpace(sid), account) {
|
||||
continue
|
||||
}
|
||||
jail = append(jail, sid)
|
||||
}
|
||||
return jail
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// The outer rollback must only delete principals this run created.
|
||||
//
|
||||
// Dual-role setup provisions offline then online. If the offline role adopts an
|
||||
// account that already existed and anything afterwards fails, the outer rollback
|
||||
// used to delete it anyway, turning a partial re-setup failure into the loss of
|
||||
// a principal that was working before the run started. Two roles make that the
|
||||
// common case rather than an unlucky one: the first role usually succeeds, so
|
||||
// there is nearly always something for a later failure to destroy.
|
||||
func TestDualRoleSetupRollbackSparesAdoptedPrincipals(t *testing.T) {
|
||||
for name, testCase := range map[string]struct {
|
||||
createdByRole map[windowsSandboxRole]bool
|
||||
wantRemoved []windowsSandboxRole
|
||||
}{
|
||||
"offline adopted, online created": {
|
||||
createdByRole: map[windowsSandboxRole]bool{
|
||||
windowsSandboxRoleOffline: false,
|
||||
windowsSandboxRoleOnline: true,
|
||||
},
|
||||
// Only the one this run made. Deleting the adopted offline principal
|
||||
// is the data loss this guards against.
|
||||
wantRemoved: []windowsSandboxRole{windowsSandboxRoleOnline},
|
||||
},
|
||||
"both adopted": {
|
||||
createdByRole: map[windowsSandboxRole]bool{
|
||||
windowsSandboxRoleOffline: false,
|
||||
windowsSandboxRoleOnline: false,
|
||||
},
|
||||
wantRemoved: nil,
|
||||
},
|
||||
"both created": {
|
||||
createdByRole: map[windowsSandboxRole]bool{
|
||||
windowsSandboxRoleOffline: true,
|
||||
windowsSandboxRoleOnline: true,
|
||||
},
|
||||
wantRemoved: []windowsSandboxRole{windowsSandboxRoleOffline, windowsSandboxRoleOnline},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var removed []windowsSandboxRole
|
||||
restoreDualRoleSeams(t, testCase.createdByRole, &removed)
|
||||
|
||||
// Fail on the SECOND role, not the first. ACL application happens per
|
||||
// role inside the loop, so failing the first aborts before the second
|
||||
// is provisioned at all and the rollback never has more than one
|
||||
// principal to consider. The case worth testing is the one review
|
||||
// described: the first role succeeds, the second fails, and the
|
||||
// question is whether the first gets destroyed on the way out.
|
||||
// Count GRANT plans, not ACL calls. applyWindowsPrincipalACLs revokes
|
||||
// this trustee's existing ACEs before applying the new plan, so there
|
||||
// are two calls per role now. The contract under test is "the second
|
||||
// ROLE fails"; counting raw calls would fail the first role's grant
|
||||
// instead and prove something else entirely.
|
||||
grants := 0
|
||||
applyWindowsACLPlanFn = func(plan WindowsACLPlan) (func() error, error) {
|
||||
if len(plan.Entries) > 0 && plan.Entries[0].Action == windowsACLRevoke {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
grants++
|
||||
if grants < 2 {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
return nil, errors.New("ACL apply refused")
|
||||
}
|
||||
|
||||
if _, err := setupWindowsSandboxPrincipal(windowsSandboxTestConfig()); err == nil {
|
||||
t.Fatal("setup reported success despite an injected ACL failure")
|
||||
}
|
||||
assertRolesEqual(t, removed, testCase.wantRemoved)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The same contract on the success path: the rollback handed back to the caller
|
||||
// for a LATER setup step to invoke must be just as reluctant.
|
||||
func TestDualRoleSetupReturnedRollbackSparesAdoptedPrincipals(t *testing.T) {
|
||||
var removed []windowsSandboxRole
|
||||
restoreDualRoleSeams(t, map[windowsSandboxRole]bool{
|
||||
windowsSandboxRoleOffline: false,
|
||||
windowsSandboxRoleOnline: true,
|
||||
}, &removed)
|
||||
|
||||
rollback, err := setupWindowsSandboxPrincipal(windowsSandboxTestConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
if len(removed) != 0 {
|
||||
t.Fatalf("setup removed principals before anything failed: %v", removed)
|
||||
}
|
||||
if err := rollback(); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
assertRolesEqual(t, removed, []windowsSandboxRole{windowsSandboxRoleOnline})
|
||||
}
|
||||
|
||||
func restoreDualRoleSeams(t *testing.T, createdByRole map[windowsSandboxRole]bool, removed *[]windowsSandboxRole) {
|
||||
t.Helper()
|
||||
prevProvision := provisionWindowsSandboxPrincipalForSetupFn
|
||||
prevApply := applyWindowsACLPlanFn
|
||||
prevRemove := removeWindowsSandboxPrincipalForSetupFn
|
||||
t.Cleanup(func() {
|
||||
provisionWindowsSandboxPrincipalForSetupFn = prevProvision
|
||||
applyWindowsACLPlanFn = prevApply
|
||||
removeWindowsSandboxPrincipalForSetupFn = prevRemove
|
||||
})
|
||||
|
||||
provisionWindowsSandboxPrincipalForSetupFn = func(_ WindowsSandboxCommandConfig, role windowsSandboxRole) (windowsSandboxIdentity, bool, error) {
|
||||
sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
return windowsSandboxIdentity{}, false, err
|
||||
}
|
||||
return windowsSandboxIdentity{Username: "zero-sbx-test", SID: sid}, createdByRole[role], nil
|
||||
}
|
||||
applyWindowsACLPlanFn = func(WindowsACLPlan) (func() error, error) {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
removeWindowsSandboxPrincipalForSetupFn = func(_ WindowsSandboxCommandConfig, role windowsSandboxRole) error {
|
||||
*removed = append(*removed, role)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func windowsSandboxTestConfig() WindowsSandboxCommandConfig {
|
||||
return WindowsSandboxCommandConfig{
|
||||
SandboxHome: `C:\sandboxhome`,
|
||||
CommandCWD: `C:\ws`,
|
||||
WorkspaceRoots: []string{`C:\ws`},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: `C:\ws`}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func assertRolesEqual(t *testing.T, got []windowsSandboxRole, want []windowsSandboxRole) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("removed %v, want %v", got, want)
|
||||
}
|
||||
seen := map[windowsSandboxRole]bool{}
|
||||
for _, role := range got {
|
||||
seen[role] = true
|
||||
}
|
||||
for _, role := range want {
|
||||
if !seen[role] {
|
||||
t.Fatalf("removed %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The git control-plane carveouts are two different shapes: .git/hooks is a
|
||||
// directory, .git/config is a FILE. Materialization has to respect that.
|
||||
//
|
||||
// On a fresh workspace neither exists yet, which is precisely the case
|
||||
// Materialize was added for — so this is the common path, not a corner. Creating
|
||||
// .git/config as a directory does not just mis-ACL it: it makes the workspace
|
||||
// permanently unusable, because git refuses to initialise over a directory
|
||||
// where its config file belongs.
|
||||
func TestPrincipalACLPlanMaterializesGitConfigAsFile(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
// Guests, deliberately: the deny-write ACE must land on the sandbox
|
||||
// principal, not on whoever runs the test. With Everyone (S-1-1-0) the
|
||||
// ACE denies the test process too and `git init` fails with "Permission
|
||||
// denied" for a reason that has nothing to do with the shape bug.
|
||||
PrincipalSID: "S-1-5-32-546",
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: workspace,
|
||||
ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
|
||||
rollback, err := applyWindowsACLPlan(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("applyWindowsACLPlan: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if rollback != nil {
|
||||
_ = rollback()
|
||||
}
|
||||
})
|
||||
|
||||
configPath := filepath.Join(workspace, ".git", "config")
|
||||
if info, err := os.Stat(configPath); err == nil && info.IsDir() {
|
||||
t.Errorf(".git/config was materialized as a directory; git requires a file")
|
||||
}
|
||||
hooksPath := filepath.Join(workspace, ".git", "hooks")
|
||||
if info, err := os.Stat(hooksPath); err == nil && !info.IsDir() {
|
||||
t.Errorf(".git/hooks was materialized as a file; git requires a directory")
|
||||
}
|
||||
|
||||
// The failure users would actually hit.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not on PATH; shape assertions above still ran")
|
||||
}
|
||||
cmd := exec.Command("git", "init")
|
||||
cmd.Dir = workspace
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Errorf("git init failed on a workspace after sandbox setup: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// windowsPathDeniesDelete reports whether the object's real DACL carries a deny
|
||||
// ACE covering DELETE for the given SID.
|
||||
//
|
||||
// The plan-shape tests assert what the planner emits. This reads what actually
|
||||
// landed on disk, which is the gap that let the guard go missing: the plan was
|
||||
// right the whole time and the apply silently dropped it.
|
||||
func windowsPathDeniesDelete(t *testing.T, path, sid string) bool {
|
||||
t.Helper()
|
||||
handle, _, err := openWindowsACLTarget(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
|
||||
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Fatalf("read DACL for %s: %v", path, err)
|
||||
}
|
||||
// SDDL rather than walking the ACE buffer by hand: this x/sys does not export
|
||||
// an ACE enumerator, and unsafe pointer arithmetic in a test that exists to
|
||||
// catch a security regression is its own hazard.
|
||||
//
|
||||
// An ACE renders as (type;flags;rights;guid;inherit_guid;sid), so the deny
|
||||
// entries are the ones whose type field is D. Rights come back as SDDL
|
||||
// abbreviations when they fit and as a hex mask when they do not, so both are
|
||||
// accepted; SD is the abbreviation for DELETE.
|
||||
for _, ace := range strings.Split(descriptor.String(), "(") {
|
||||
fields := strings.Split(strings.TrimSuffix(strings.TrimSpace(ace), ")"), ";")
|
||||
if len(fields) != 6 || fields[0] != "D" || !strings.EqualFold(fields[5], sid) {
|
||||
continue
|
||||
}
|
||||
rights := fields[2]
|
||||
if strings.Contains(rights, "SD") {
|
||||
return true
|
||||
}
|
||||
if mask, err := strconv.ParseUint(strings.TrimPrefix(strings.ToLower(rights), "0x"), 16, 32); err == nil {
|
||||
if uint32(mask)&uint32(windows.DELETE) != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// THE GUARD MUST REACH DISK ON A WORKSPACE THAT HAD NO .git.
|
||||
//
|
||||
// This is the case the whole plan is built around and it was the one case where
|
||||
// the guard was absent. .git deliberately carries no Materialize, because an
|
||||
// empty .git breaks `git init`. Groups are applied in ascending path order, so
|
||||
// the .git group ran while .git did not yet exist, was skipped as a
|
||||
// non-materializing missing target, and nothing revisited it once the
|
||||
// .git\config carveout created .git as its parent chain moments later.
|
||||
//
|
||||
// Four tests already covered the deny-delete mask and the plan emitting it.
|
||||
// None of them applied the plan and looked at the object, which is exactly why
|
||||
// a green suite said nothing.
|
||||
func TestGitRenameGuardReachesDiskOnAWorkspaceWithoutGit(t *testing.T) {
|
||||
const principal = testPrincipalSID // Unaliased, so it renders literally in SDDL.
|
||||
workspace := t.TempDir()
|
||||
gitDir := filepath.Join(workspace, ".git")
|
||||
if _, err := os.Stat(gitDir); err == nil {
|
||||
t.Fatal("the workspace already has .git, so this proves nothing")
|
||||
}
|
||||
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: principal,
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: workspace,
|
||||
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
|
||||
ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
|
||||
rollback, err := applyWindowsACLPlan(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("applyWindowsACLPlan: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if rollback != nil {
|
||||
_ = rollback()
|
||||
}
|
||||
})
|
||||
|
||||
// The carveouts create .git on the way to .git\config.
|
||||
if _, err := os.Stat(gitDir); err != nil {
|
||||
t.Fatalf(".git was never created by the carveout materialization: %v", err)
|
||||
}
|
||||
if !windowsPathDeniesDelete(t, gitDir, principal) {
|
||||
t.Fatal("no deny-delete ACE on .git after applying the plan: the principal can rename it aside and recreate it without the config and hooks carveouts")
|
||||
}
|
||||
}
|
||||
|
||||
// The same guard on a workspace that already had .git must keep working. This
|
||||
// case was already correct, and it is kept so a fix aimed at the fresh
|
||||
// workspace cannot quietly trade one for the other.
|
||||
func TestGitRenameGuardStillReachesDiskWhenGitAlreadyExists(t *testing.T) {
|
||||
const principal = testPrincipalSID
|
||||
workspace := t.TempDir()
|
||||
gitDir := filepath.Join(workspace, ".git")
|
||||
if err := os.Mkdir(gitDir, 0o700); err != nil {
|
||||
t.Fatalf("seed .git: %v", err)
|
||||
}
|
||||
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: principal,
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: workspace,
|
||||
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
|
||||
ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
rollback, err := applyWindowsACLPlan(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("applyWindowsACLPlan: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if rollback != nil {
|
||||
_ = rollback()
|
||||
}
|
||||
})
|
||||
|
||||
if !windowsPathDeniesDelete(t, gitDir, principal) {
|
||||
t.Fatal("no deny-delete ACE on a .git that already existed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A sandbox principal must not be able to REPLACE .git.
|
||||
//
|
||||
// The write-denied carveouts are attached to .git/config and .git/hooks as
|
||||
// objects. Rename .git aside, recreate it, and those objects are gone: the fresh
|
||||
// config and hooks inherit the workspace allow with no deny of their own, which
|
||||
// hands back credential.helper and core.hooksPath, and with them arbitrary code
|
||||
// execution on the next git command.
|
||||
//
|
||||
// .git cannot join sandboxFullyProtectedMetadataNames to fix this, because that
|
||||
// list emits DenyWrite, whose mask includes FILE_GENERIC_WRITE. Git has to write
|
||||
// index, objects and refs. So the directory needs DELETE denied on itself while
|
||||
// staying writable underneath.
|
||||
func TestThePrincipalCannotRenameTheGitDirectory(t *testing.T) {
|
||||
root := filepath.FromSlash("/ws/repo")
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: "S-1-5-21-1-2-3-1001",
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: root,
|
||||
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
|
||||
ReadOnlySubpaths: gitMetadataWriteCarveouts(root),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
|
||||
// The plan normalizes every write root before it names an ACE, so the
|
||||
// expected path has to be normalized too or this compares two spellings of
|
||||
// the same directory. Hardcoding a drive letter instead would pass on
|
||||
// Windows and fail everywhere else, since the builder is portable code.
|
||||
gitDir := filepath.Join(normalizeProfilePath(root), ".git")
|
||||
var denyDelete *WindowsACLEntry
|
||||
for index := range plan.Entries {
|
||||
if plan.Entries[index].Action == WindowsACLDenyDelete && plan.Entries[index].Path == gitDir {
|
||||
denyDelete = &plan.Entries[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if denyDelete == nil {
|
||||
t.Fatalf("no deny-delete entry for %s, so the principal can rename .git and recreate it without the carveouts:\n%#v", gitDir, plan.Entries)
|
||||
}
|
||||
if denyDelete.Capability != "S-1-5-21-1-2-3-1001" {
|
||||
t.Errorf("deny-delete names %q, want the principal SID", denyDelete.Capability)
|
||||
}
|
||||
}
|
||||
|
||||
// The guard must not become a write ban. Git writes constantly inside .git, so a
|
||||
// deny that reached the children would break every commit rather than just the
|
||||
// rename. It also must not be materialized into existence: .git is git's to
|
||||
// create, and an empty .git directory made by setup breaks `git init`.
|
||||
func TestTheGitRenameGuardDoesNotBlockGitsOwnWrites(t *testing.T) {
|
||||
root := filepath.FromSlash("/ws/repo")
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: "S-1-5-21-1-2-3-1001",
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: root,
|
||||
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
|
||||
ReadOnlySubpaths: gitMetadataWriteCarveouts(root),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
|
||||
gitDir := filepath.Join(normalizeProfilePath(root), ".git")
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Path != gitDir {
|
||||
continue
|
||||
}
|
||||
if entry.Action == WindowsACLDenyWrite {
|
||||
t.Errorf("deny-write on %s would stop git writing index/objects/refs", gitDir)
|
||||
}
|
||||
if entry.Action == WindowsACLDenyDelete && entry.Materialize {
|
||||
t.Errorf("the rename guard materializes %s; git must create it, an empty .git breaks git init", gitDir)
|
||||
}
|
||||
}
|
||||
|
||||
// The existing carveouts must survive unchanged.
|
||||
for _, want := range []string{filepath.Join(gitDir, "config"), filepath.Join(gitDir, "hooks")} {
|
||||
found := false
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action == WindowsACLDenyWrite && entry.Path == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the write-deny carveout for %s disappeared", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// The mask is the whole point of the action, so it is asserted bit by bit.
|
||||
//
|
||||
// Renaming a directory needs DELETE on the directory itself, so denying DELETE
|
||||
// is what stops .git being replaced. Everything else in the mask is there to
|
||||
// stop the principal removing the guard: WRITE_DAC would let it rewrite the
|
||||
// DACL, WRITE_OWNER would let it take ownership and then rewrite the DACL.
|
||||
//
|
||||
// What must NOT be in it matters just as much. FILE_GENERIC_WRITE would stop git
|
||||
// writing index, objects and refs. FILE_DELETE_CHILD would stop git deleting its
|
||||
// own lock files and refs. Either one turns a rename guard into a broken repo.
|
||||
func TestDenyDeleteMaskStopsRenameWithoutStoppingGit(t *testing.T) {
|
||||
mode, mask, err := windowsACLAccess(WindowsACLDenyDelete)
|
||||
if err != nil {
|
||||
t.Fatalf("windowsACLAccess(deny-delete): %v", err)
|
||||
}
|
||||
if mode != windows.DENY_ACCESS {
|
||||
t.Fatalf("access mode = %v, want DENY_ACCESS", mode)
|
||||
}
|
||||
|
||||
for _, required := range []struct {
|
||||
name string
|
||||
bit windows.ACCESS_MASK
|
||||
}{
|
||||
{"DELETE", windows.DELETE},
|
||||
{"WRITE_DAC", windows.WRITE_DAC},
|
||||
{"WRITE_OWNER", windows.WRITE_OWNER},
|
||||
} {
|
||||
if mask&required.bit == 0 {
|
||||
t.Errorf("mask %#x is missing %s, so the guard can be removed or bypassed", mask, required.name)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []struct {
|
||||
name string
|
||||
bit windows.ACCESS_MASK
|
||||
breaks string
|
||||
}{
|
||||
{"FILE_GENERIC_WRITE", windows.FILE_GENERIC_WRITE, "git writing index/objects/refs"},
|
||||
{"FILE_DELETE_CHILD", windowsFileDeleteChild, "git deleting its own lock files and refs"},
|
||||
} {
|
||||
if mask&forbidden.bit != 0 {
|
||||
t.Errorf("mask %#x includes %s, which breaks %s", mask, forbidden.name, forbidden.breaks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inheritance is the other half. An inherited deny would reach every file inside
|
||||
// .git and stop git deleting anything at all, so this ACE has to apply to the
|
||||
// directory object alone while the other actions keep inheriting as before.
|
||||
func TestDenyDeleteDoesNotInheritWhileOtherActionsStillDo(t *testing.T) {
|
||||
entries := []WindowsACLEntry{
|
||||
{Action: WindowsACLDenyDelete, Path: `C:\work\repo\.git`, Capability: "S-1-5-32-9999"},
|
||||
{Action: WindowsACLDenyWrite, Path: `C:\work\repo\.zero`, Capability: "S-1-5-32-9999"},
|
||||
{Action: WindowsACLAllowWrite, Path: `C:\work\repo`, Capability: "S-1-5-32-9999"},
|
||||
}
|
||||
|
||||
access, err := windowsExplicitAccessEntries(entries, true)
|
||||
if err != nil {
|
||||
t.Fatalf("windowsExplicitAccessEntries: %v", err)
|
||||
}
|
||||
if len(access) != len(entries) {
|
||||
t.Fatalf("got %d access entries, want %d", len(access), len(entries))
|
||||
}
|
||||
if access[0].Inheritance != windows.NO_INHERITANCE {
|
||||
t.Errorf("deny-delete inheritance = %#x, want NO_INHERITANCE; an inherited deny would stop git deleting inside .git", access[0].Inheritance)
|
||||
}
|
||||
for index, entry := range entries[1:] {
|
||||
if got := access[index+1].Inheritance; got != windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT {
|
||||
t.Errorf("%s inheritance = %#x, want the directory default to be unchanged", entry.Action, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A NAME IS NOT PROOF OF PROVENANCE, for the USERS group specifically.
|
||||
//
|
||||
// ensureWindowsLocalGroup covers both managed groups, and the sibling test in
|
||||
// windows_offline_group_ownership_windows_test.go pins the offline one. This
|
||||
// pins ZeroSandboxUsers, which is the group anandh8x reported: it holds every
|
||||
// sandbox principal, so adopting a same-named group created by an installer or
|
||||
// by policy would hand the principal whatever that group already grants.
|
||||
//
|
||||
// Driven through the seams so it needs no Administrator and leaves no real local
|
||||
// group on the machine running the suite.
|
||||
func TestAdoptingAForeignUsersGroupIsRefused(t *testing.T) {
|
||||
for _, status := range []uintptr{nerrGroupExists, errorAliasExists} {
|
||||
prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn
|
||||
t.Cleanup(func() { addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn = prevAdd, prevOwned })
|
||||
|
||||
addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return status, nil }
|
||||
windowsLocalGroupOwnedByZeroFn = func(string, string) (bool, error) { return false, nil }
|
||||
|
||||
err := ensureWindowsSandboxGroup()
|
||||
if err == nil {
|
||||
t.Fatalf("status %d adopted a group Zero did not create, so the principal inherits whatever it already grants", status)
|
||||
}
|
||||
if !strings.Contains(err.Error(), windowsSandboxGroupName) {
|
||||
t.Errorf("the refusal must name the group in the way, got %q", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Our own group is still adopted, or re-running setup would fail on the group it
|
||||
// created a moment ago and provisioning would never converge.
|
||||
func TestAdoptingOurOwnUsersGroupSucceeds(t *testing.T) {
|
||||
prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn
|
||||
t.Cleanup(func() { addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn = prevAdd, prevOwned })
|
||||
|
||||
addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return nerrGroupExists, nil }
|
||||
windowsLocalGroupOwnedByZeroFn = func(name, comment string) (bool, error) {
|
||||
if name != windowsSandboxGroupName || comment != windowsSandboxGroupComment {
|
||||
t.Errorf("ownership probed for %q/%q, want the users group", name, comment)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
if err := ensureWindowsSandboxGroup(); err != nil {
|
||||
t.Errorf("refused a group carrying Zero's own comment: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A freshly created group is ours by construction, so no ownership probe runs.
|
||||
func TestCreatingTheUsersGroupDoesNotProbeOwnership(t *testing.T) {
|
||||
prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn
|
||||
t.Cleanup(func() { addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn = prevAdd, prevOwned })
|
||||
|
||||
probed := false
|
||||
addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return nerrSuccess, nil }
|
||||
windowsLocalGroupOwnedByZeroFn = func(string, string) (bool, error) { probed = true; return false, nil }
|
||||
|
||||
if err := ensureWindowsSandboxGroup(); err != nil {
|
||||
t.Fatalf("a successful create was rejected: %v", err)
|
||||
}
|
||||
if probed {
|
||||
t.Error("ownership was probed for a group we had just created ourselves")
|
||||
}
|
||||
}
|
||||
|
||||
// A failed ownership probe is not read as "ours" and not as "not ours" either.
|
||||
func TestAnUnreadableUsersGroupIsNotGuessedEitherWay(t *testing.T) {
|
||||
prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn
|
||||
t.Cleanup(func() { addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn = prevAdd, prevOwned })
|
||||
|
||||
sentinel := errors.New("NetLocalGroupGetInfo: status 5")
|
||||
addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return nerrGroupExists, nil }
|
||||
windowsLocalGroupOwnedByZeroFn = func(string, string) (bool, error) { return false, sentinel }
|
||||
|
||||
if err := ensureWindowsSandboxGroup(); !errors.Is(err, sentinel) {
|
||||
t.Fatalf("a failed ownership probe was swallowed, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package sandbox
|
||||
|
||||
// ACLs for a sandbox principal.
|
||||
//
|
||||
// The capability-SID model this sits beside starts from "the caller can already
|
||||
// read everything" and narrows writes, because the sandboxed child runs as the
|
||||
// caller. A principal inverts that: a separate local account has no access to
|
||||
// the caller's profile at all, so the interesting direction is what to GRANT.
|
||||
//
|
||||
// That inversion is the point. Credential stores under the user's profile are
|
||||
// unreachable because the principal is a different account, not because a deny
|
||||
// rule enumerated them, which is what makes this able to close #662 and #675 on
|
||||
// Windows where a deny-read ACE against the caller's own SID never could. Deny
|
||||
// rules stay useful only for objects that are readable by everyone.
|
||||
//
|
||||
// Grants are explicit and narrow: the workspace and any extra write roots get
|
||||
// read+write, declared read-only roots get read, and the protected metadata
|
||||
// carve-outs the profile already defines stay denied so .git internals and
|
||||
// .zero/.agents cannot be rewritten from inside the sandbox.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// WindowsACLAllowRead grants read and execute without write. It exists for the
|
||||
// principal model, where a read root must be granted rather than assumed.
|
||||
const WindowsACLAllowRead WindowsACLAction = "allow-read"
|
||||
|
||||
// windowsPrincipalACLInput is everything needed to describe a principal's
|
||||
// access. It is deliberately a plain struct rather than the full command config
|
||||
// so the plan can be built and tested without a live sandbox.
|
||||
type windowsPrincipalACLInput struct {
|
||||
// PrincipalSID is the string SID of the sandbox account every ACE names.
|
||||
PrincipalSID string
|
||||
// WriteRoots receive read+write+execute. The workspace lives here.
|
||||
WriteRoots []WritableRoot
|
||||
// ReadRoots receive read+execute only.
|
||||
ReadRoots []string
|
||||
// DenyRead covers objects a principal could otherwise reach because they are
|
||||
// world-readable; per-user secrets need no entry.
|
||||
DenyRead []string
|
||||
// DenyWrite carries the policy's own deny-write paths. The capability plan
|
||||
// has always emitted these; the principal plan denied write only on
|
||||
// protected metadata and read-only subpaths inside write roots, so a policy
|
||||
// deny sitting anywhere else was simply not enforced once the runner used a
|
||||
// principal token, and a shell child could write where the restricted-token
|
||||
// backend would have blocked it.
|
||||
DenyWrite []string
|
||||
}
|
||||
|
||||
// buildWindowsPrincipalACLPlan turns a principal's access into ACL entries.
|
||||
//
|
||||
// Ordering matters at apply time: deny entries are emitted before allows so a
|
||||
// carve-out inside a granted root survives, which mirrors how Windows evaluates
|
||||
// an explicit DACL (deny ACEs first).
|
||||
func buildWindowsPrincipalACLPlan(input windowsPrincipalACLInput) (WindowsACLPlan, error) {
|
||||
if input.PrincipalSID == "" {
|
||||
return WindowsACLPlan{}, errors.New("windows principal ACL plan requires a principal SID")
|
||||
}
|
||||
if len(input.WriteRoots) == 0 && len(input.ReadRoots) == 0 {
|
||||
return WindowsACLPlan{}, errors.New("windows principal ACL plan requires at least one root")
|
||||
}
|
||||
|
||||
entries := make([]WindowsACLEntry, 0, len(input.WriteRoots)*2+len(input.ReadRoots)+len(input.DenyRead))
|
||||
|
||||
// Deny first. A deny ACE inside a write root (protected metadata, git
|
||||
// internals) has to win over the grant that follows it.
|
||||
// Materialized, matching the capability plan. applyWindowsACLPlan skips a
|
||||
// target that does not exist, so without this a deny-read path created after
|
||||
// setup ran never got an ACE at all and the principal could read it. The
|
||||
// deny has to be in place before the object is.
|
||||
for _, path := range normalizeProfilePaths(input.DenyRead) {
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLDenyRead,
|
||||
Path: path,
|
||||
Capability: input.PrincipalSID,
|
||||
Materialize: true,
|
||||
})
|
||||
}
|
||||
for _, path := range normalizeProfilePaths(input.DenyWrite) {
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLDenyWrite,
|
||||
Path: path,
|
||||
Capability: input.PrincipalSID,
|
||||
Materialize: true,
|
||||
})
|
||||
}
|
||||
for _, root := range input.WriteRoots {
|
||||
// Normalized the same way as read and deny paths: a write root may arrive
|
||||
// with "~" or as a relative path, and an ACE has to name the same absolute,
|
||||
// symlink-resolved object the deny entries do or the two disagree.
|
||||
cleaned := normalizeProfilePath(root.Root)
|
||||
if cleaned == "" {
|
||||
return WindowsACLPlan{}, fmt.Errorf("windows principal ACL plan: unusable write root %q", root.Root)
|
||||
}
|
||||
// Materialized, like the metadata and policy denies below and above.
|
||||
//
|
||||
// These are the git control-plane carveouts (.git/config, .git/hooks). On a
|
||||
// workspace where git has not run yet they do not exist at setup time, and
|
||||
// applyWindowsACLPlan skips a target that is absent, so the ACEs were never
|
||||
// written. Once git created those paths the principal still held inherited
|
||||
// write access to the workspace and could install a hook or rewrite
|
||||
// credential.helper. The capability plan gets away without this because its
|
||||
// child runs as the caller; a separate principal account does not.
|
||||
// Which carveouts are files rather than directories comes from the same
|
||||
// spec list the profile built ReadOnlySubpaths from, so a new carveout
|
||||
// cannot be added without its shape coming along.
|
||||
for _, subpath := range normalizeProfilePaths(root.ReadOnlySubpaths) {
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLDenyWrite,
|
||||
Path: subpath,
|
||||
Capability: input.PrincipalSID,
|
||||
Materialize: true,
|
||||
MaterializeFile: gitMetadataCarveoutIsFile(subpath),
|
||||
})
|
||||
}
|
||||
for _, name := range root.ProtectedMetadataNames {
|
||||
// These are documented as NAMES, and the join below is what makes that
|
||||
// documentation load-bearing rather than descriptive: ".." or a
|
||||
// separator would place this deny ACE, and the directory it
|
||||
// materializes, outside the write root entirely. Today every caller
|
||||
// passes a package constant, so this is unreachable; it is here so that
|
||||
// stays true when a future caller sources these from config.
|
||||
if err := validateWindowsACLComponent(name); err != nil {
|
||||
return WindowsACLPlan{}, fmt.Errorf("windows principal ACL plan: protected metadata name: %w", err)
|
||||
}
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLDenyWrite,
|
||||
Path: filepath.Join(cleaned, name),
|
||||
Capability: input.PrincipalSID,
|
||||
Materialize: true,
|
||||
})
|
||||
}
|
||||
// .git gets DELETE denied on the directory itself, because the carveouts
|
||||
// that protect it are attached to .git/config and .git/hooks as OBJECTS.
|
||||
// Rename .git aside and recreate it and those objects are gone, so the
|
||||
// fresh config and hooks inherit the workspace allow with no deny of their
|
||||
// own, handing back credential.helper and core.hooksPath.
|
||||
//
|
||||
// Not DenyWrite (git writes index, objects and refs), not materialized
|
||||
// (git creates .git, and an empty one breaks git init), and not inherited,
|
||||
// so everything underneath stays writable.
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLDenyDelete,
|
||||
Path: windowsRenameProtectedObject(cleaned),
|
||||
Capability: input.PrincipalSID,
|
||||
})
|
||||
}
|
||||
|
||||
// Then the grants the principal cannot work without.
|
||||
for _, root := range input.WriteRoots {
|
||||
cleaned := normalizeProfilePath(root.Root)
|
||||
if cleaned == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLAllowWrite,
|
||||
Path: cleaned,
|
||||
Capability: input.PrincipalSID,
|
||||
})
|
||||
}
|
||||
for _, path := range normalizeProfilePaths(input.ReadRoots) {
|
||||
// NEVER grant at a volume root.
|
||||
//
|
||||
// permissionProfileReadRoots seeds its list with profileRootPath(), which
|
||||
// is the separator alone, because the workspace-write posture is
|
||||
// read-all/write-jail. That is harmless for the capability backend, whose
|
||||
// child runs as the CALLER and therefore reads what the caller could read
|
||||
// anyway. It is not harmless here: a principal is a separate local
|
||||
// account, so this loop turns that synthetic entry into a real,
|
||||
// persistent, inheritable allow-read ACE for that account at the root of
|
||||
// the current drive, reaching every directory that does not block
|
||||
// inheritance.
|
||||
//
|
||||
// Dropping it does not take away the reads the principal needs to run
|
||||
// commands. NetUserAdd with USER_PRIV_USER puts the account in the
|
||||
// built-in Users group (see usrPrivUser in windows_identity_windows.go),
|
||||
// and the machine's own ACLs already grant Users read on the system and
|
||||
// program directories. What the grant added on top was read access to the
|
||||
// places Users are deliberately kept out of, which is the opposite of what
|
||||
// a sandbox is for.
|
||||
//
|
||||
// Note this inherits rather than asserts: nothing here checks that those
|
||||
// default ACLs are actually in place, so a hardened image that strips
|
||||
// Users read would need an explicit bounded read set instead.
|
||||
if isWindowsVolumeRoot(path) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLAllowRead,
|
||||
Path: path,
|
||||
Capability: input.PrincipalSID,
|
||||
})
|
||||
}
|
||||
|
||||
return WindowsACLPlan{Entries: dedupeWindowsACLEntries(entries)}, nil
|
||||
}
|
||||
|
||||
// windowsPrincipalRevokePlan returns the entries whose ACEs should be removed
|
||||
// when a principal is retired. Revocation is by TRUSTEE rather than by path:
|
||||
// every ACE naming this principal is dropped, so cleanup does not depend on
|
||||
// remembering which paths were granted, and a grant added by an older version
|
||||
// is still removed.
|
||||
//
|
||||
// This is the removal path the capability-SID model never had, where a synthetic
|
||||
// SID left ACEs behind on the user's tree with nothing to match them against.
|
||||
func windowsPrincipalRevokePlan(principalSID string, paths []string) (WindowsACLPlan, error) {
|
||||
if principalSID == "" {
|
||||
return WindowsACLPlan{}, errors.New("windows principal revoke plan requires a principal SID")
|
||||
}
|
||||
cleaned := normalizeProfilePaths(paths)
|
||||
entries := make([]WindowsACLEntry, 0, len(cleaned))
|
||||
for _, path := range cleaned {
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: windowsACLRevoke,
|
||||
Path: path,
|
||||
Capability: principalSID,
|
||||
})
|
||||
}
|
||||
return WindowsACLPlan{Entries: entries}, nil
|
||||
}
|
||||
|
||||
// windowsACLRevoke removes every ACE naming the trustee on a path, whatever
|
||||
// access it granted or denied.
|
||||
const windowsACLRevoke WindowsACLAction = "revoke"
|
||||
|
||||
// windowsACLPlanPaths lives in windows_identity_runtime_windows.go, beside its
|
||||
// only callers. It was here, in the portable file, which made it dead code on
|
||||
// every non-Windows build and failed the static analysis gate.
|
||||
@@ -0,0 +1,275 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testPrincipalSID = "S-1-5-21-1111111111-2222222222-3333333333-1005"
|
||||
|
||||
func testPrincipalInput() windowsPrincipalACLInput {
|
||||
return windowsPrincipalACLInput{
|
||||
PrincipalSID: testPrincipalSID,
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: filepath.FromSlash("/ws/project"),
|
||||
ReadOnlySubpaths: []string{filepath.FromSlash("/ws/project/.git/config")},
|
||||
ProtectedMetadataNames: []string{".zero", ".agents"},
|
||||
}},
|
||||
ReadRoots: []string{filepath.FromSlash("/usr/lib")},
|
||||
DenyRead: []string{filepath.FromSlash("/shared/secrets")},
|
||||
}
|
||||
}
|
||||
|
||||
// Windows evaluates an explicit DACL deny-before-allow, so a carve-out inside a
|
||||
// granted root only survives if its deny ACE is written first. If the grant on
|
||||
// the workspace landed before the deny on .zero, the protected metadata would be
|
||||
// writable from inside the sandbox.
|
||||
func TestPrincipalACLPlanEmitsDeniesBeforeAllows(t *testing.T) {
|
||||
plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput())
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
lastDeny, firstAllow := -1, -1
|
||||
for index, entry := range plan.Entries {
|
||||
switch entry.Action {
|
||||
case WindowsACLDenyRead, WindowsACLDenyWrite:
|
||||
lastDeny = index
|
||||
case WindowsACLAllowWrite, WindowsACLAllowRead:
|
||||
if firstAllow == -1 {
|
||||
firstAllow = index
|
||||
}
|
||||
}
|
||||
}
|
||||
if firstAllow == -1 || lastDeny == -1 {
|
||||
t.Fatalf("plan is missing a deny or an allow: %+v", plan.Entries)
|
||||
}
|
||||
if lastDeny > firstAllow {
|
||||
t.Fatalf("deny at %d comes after allow at %d; carve-outs would be overridden", lastDeny, firstAllow)
|
||||
}
|
||||
}
|
||||
|
||||
// Every ACE must name the sandbox principal. An entry with any other trustee
|
||||
// would change access for a real user.
|
||||
func TestPrincipalACLPlanNamesOnlyThePrincipal(t *testing.T) {
|
||||
plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput())
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Capability != testPrincipalSID {
|
||||
t.Fatalf("entry %+v names %q, want the principal SID", entry, entry.Capability)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A principal is a separate account with no inherent access, so a write root
|
||||
// must be granted read+write and a read root granted read. Without the grant the
|
||||
// sandbox cannot open its own workspace.
|
||||
func TestPrincipalACLPlanGrantsRoots(t *testing.T) {
|
||||
plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput())
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
var grantedWrite, grantedRead bool
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action == WindowsACLAllowWrite && entry.Path == normalizeProfilePath(filepath.FromSlash("/ws/project")) {
|
||||
grantedWrite = true
|
||||
}
|
||||
if entry.Action == WindowsACLAllowRead && entry.Path == normalizeProfilePath(filepath.FromSlash("/usr/lib")) {
|
||||
grantedRead = true
|
||||
}
|
||||
}
|
||||
if !grantedWrite {
|
||||
t.Fatal("write root was not granted; the sandbox could not write its workspace")
|
||||
}
|
||||
if !grantedRead {
|
||||
t.Fatal("read root was not granted; the sandbox could not read it")
|
||||
}
|
||||
}
|
||||
|
||||
// Protected metadata is denied write and marked Materialize so the ACE is
|
||||
// created even when the directory does not exist yet, closing the window where
|
||||
// a sandboxed command creates .zero before the deny lands.
|
||||
func TestPrincipalACLPlanProtectsMetadata(t *testing.T) {
|
||||
plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput())
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
found := map[string]WindowsACLEntry{}
|
||||
for _, entry := range plan.Entries {
|
||||
found[entry.Path] = entry
|
||||
}
|
||||
for _, name := range []string{".zero", ".agents"} {
|
||||
path := filepath.Join(normalizeProfilePath(filepath.FromSlash("/ws/project")), name)
|
||||
entry, ok := found[path]
|
||||
if !ok {
|
||||
t.Fatalf("no entry protecting %s", path)
|
||||
}
|
||||
if entry.Action != WindowsACLDenyWrite {
|
||||
t.Fatalf("%s has action %q, want deny-write", path, entry.Action)
|
||||
}
|
||||
if !entry.Materialize {
|
||||
t.Fatalf("%s must be materialized so the deny exists before the directory does", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A missing principal SID must be a hard error: an empty trustee would either
|
||||
// fail at apply time or, worse, be interpreted as some other account.
|
||||
func TestPrincipalACLPlanRequiresSID(t *testing.T) {
|
||||
input := testPrincipalInput()
|
||||
input.PrincipalSID = ""
|
||||
if _, err := buildWindowsPrincipalACLPlan(input); err == nil {
|
||||
t.Fatal("an empty principal SID must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// A plan with no roots at all is a caller mistake rather than a valid empty
|
||||
// grant, since the resulting sandbox could not run anything.
|
||||
func TestPrincipalACLPlanRequiresRoots(t *testing.T) {
|
||||
input := windowsPrincipalACLInput{PrincipalSID: testPrincipalSID}
|
||||
if _, err := buildWindowsPrincipalACLPlan(input); err == nil {
|
||||
t.Fatal("a plan with no roots must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// Revocation is keyed to the trustee, so retiring a principal removes every ACE
|
||||
// naming it without having to remember what was granted. This is the cleanup
|
||||
// path the capability-SID model lacks.
|
||||
func TestPrincipalRevokePlanTargetsTrustee(t *testing.T) {
|
||||
paths := []string{filepath.FromSlash("/ws/project"), filepath.FromSlash("/usr/lib")}
|
||||
plan, err := windowsPrincipalRevokePlan(testPrincipalSID, paths)
|
||||
if err != nil {
|
||||
t.Fatalf("revoke plan: %v", err)
|
||||
}
|
||||
if len(plan.Entries) != len(paths) {
|
||||
t.Fatalf("got %d entries, want %d", len(plan.Entries), len(paths))
|
||||
}
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action != windowsACLRevoke {
|
||||
t.Fatalf("entry %+v is not a revoke", entry)
|
||||
}
|
||||
if entry.Capability != testPrincipalSID {
|
||||
t.Fatalf("revoke names %q, want the principal", entry.Capability)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrincipalRevokePlanRequiresSID(t *testing.T) {
|
||||
if _, err := windowsPrincipalRevokePlan("", []string{"/ws"}); err == nil {
|
||||
t.Fatal("revoking without a principal SID must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// The action strings end up in a serialized plan consumed by the elevated
|
||||
// helper, so they must stay stable and distinct from the existing actions.
|
||||
func TestPrincipalACLActionsAreDistinct(t *testing.T) {
|
||||
actions := []WindowsACLAction{
|
||||
WindowsACLAllowWrite, WindowsACLAllowRead,
|
||||
WindowsACLDenyRead, WindowsACLDenyWrite, windowsACLRevoke,
|
||||
}
|
||||
seen := map[WindowsACLAction]bool{}
|
||||
for _, action := range actions {
|
||||
if strings.TrimSpace(string(action)) == "" {
|
||||
t.Fatal("an action string is empty")
|
||||
}
|
||||
if seen[action] {
|
||||
t.Fatalf("duplicate action %q", action)
|
||||
}
|
||||
seen[action] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ProtectedMetadataNames is joined onto the write root to place a deny ACE and to
|
||||
// materialize the directory it names. A value that is not a single component
|
||||
// therefore puts both OUTSIDE the workspace: ".." walks up out of it, and a
|
||||
// separator reaches through whatever sits in between. Every caller passes a
|
||||
// package constant today, so this is the guard that keeps it true if one ever
|
||||
// sources these from config.
|
||||
func TestPrincipalACLPlanRefusesProtectedNamesThatEscapeTheWriteRoot(t *testing.T) {
|
||||
root := filepath.FromSlash("/ws/project")
|
||||
for _, name := range []string{"..", ".", "", `..\..\Windows\System32`, "nested/child", `nested\child`, "C:", "stream:name"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
input := testPrincipalInput()
|
||||
input.WriteRoots = []WritableRoot{{
|
||||
Root: root,
|
||||
ProtectedMetadataNames: []string{name},
|
||||
}}
|
||||
plan, err := buildWindowsPrincipalACLPlan(input)
|
||||
if err == nil {
|
||||
t.Fatalf("accepted protected metadata name %q, which would place a deny ACE outside %s:\n%#v", name, root, plan.Entries)
|
||||
}
|
||||
if len(plan.Entries) != 0 {
|
||||
t.Errorf("returned %d entries alongside the error, so a caller ignoring err would still apply them", len(plan.Entries))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A PRINCIPAL MUST NEVER BE GRANTED READ AT A VOLUME ROOT.
|
||||
//
|
||||
// permissionProfileReadRoots seeds its list with the bare separator, because
|
||||
// the workspace-write posture is read-all with a write jail. That costs nothing
|
||||
// for the capability backend, whose child runs as the caller and could read
|
||||
// those paths anyway. For a principal it is a real, persistent, inheritable
|
||||
// allow-read ACE for a separate local account at the root of the drive.
|
||||
//
|
||||
// Built from the production profile rather than a synthetic fixture, because
|
||||
// the whole point is that the shipped configuration produced it.
|
||||
func TestPrincipalACLPlanNeverGrantsReadAtAVolumeRoot(t *testing.T) {
|
||||
workspace := filepath.FromSlash("/ws/project")
|
||||
profile := DefaultPermissionProfile(workspace)
|
||||
|
||||
// If the profile ever stops carrying a volume root, this test proves nothing
|
||||
// and should be retired rather than left passing vacuously.
|
||||
seeded := false
|
||||
for _, root := range profile.FileSystem.ReadRoots {
|
||||
if isWindowsVolumeRoot(normalizeProfilePath(root)) {
|
||||
seeded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !seeded {
|
||||
t.Skipf("the production profile no longer contains a volume read root: %v", profile.FileSystem.ReadRoots)
|
||||
}
|
||||
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: testPrincipalSID,
|
||||
ReadRoots: profile.FileSystem.ReadRoots,
|
||||
WriteRoots: profile.FileSystem.WriteRoots,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action == WindowsACLAllowRead && isWindowsVolumeRoot(entry.Path) {
|
||||
t.Errorf("plan grants the principal read at the volume root %q, which inherits into every directory on the drive", entry.Path)
|
||||
}
|
||||
}
|
||||
// And the workspace itself must still be reachable, or this traded a real
|
||||
// grant for a broken sandbox.
|
||||
wantWorkspace := normalizeProfilePath(workspace)
|
||||
reachable := false
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Path == wantWorkspace && (entry.Action == WindowsACLAllowRead || entry.Action == WindowsACLAllowWrite) {
|
||||
reachable = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !reachable {
|
||||
t.Errorf("no grant for the workspace root %q, so the principal could not read its own workspace", wantWorkspace)
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary names must keep working, or the guard above is just a break.
|
||||
func TestPrincipalACLPlanStillAcceptsTheRealProtectedNames(t *testing.T) {
|
||||
input := testPrincipalInput()
|
||||
input.WriteRoots = []WritableRoot{{
|
||||
Root: filepath.FromSlash("/ws/project"),
|
||||
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
|
||||
}}
|
||||
if _, err := buildWindowsPrincipalACLPlan(input); err != nil {
|
||||
t.Fatalf("the shipped protected names were rejected: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
// DPAPI wrapping for the stored sandbox principal password.
|
||||
//
|
||||
// The file ACL is the primary control and remains the thing that keeps the
|
||||
// sandbox principal itself from reading its own credential. This adds the layer
|
||||
// the ACL cannot: an ACL is only meaningful while the filesystem is being asked
|
||||
// to enforce it, so a backup, a mounted disk image, or a copy taken by anyone
|
||||
// who can bypass the DACL yields the password in the clear. CryptProtectData
|
||||
// binds the ciphertext to the invoking user's logon secret, so an offline copy
|
||||
// is inert without that user's credentials.
|
||||
//
|
||||
// The principal name is passed as optional entropy, which makes a blob usable
|
||||
// only for the account it was minted for; moving one secret file over another
|
||||
// then fails to decrypt instead of silently authenticating the wrong principal.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// protectWindowsSecret encrypts a password to the current user.
|
||||
//
|
||||
// CRYPTPROTECT_UI_FORBIDDEN matters here: this runs inside a CLI and, on the
|
||||
// setup path, potentially without an interactive desktop, so DPAPI must fail
|
||||
// rather than try to prompt.
|
||||
func protectWindowsSecret(plaintext string, entropy string) ([]byte, error) {
|
||||
if plaintext == "" {
|
||||
return nil, errors.New("windows sandbox secret: refusing to protect an empty password")
|
||||
}
|
||||
in := windows.DataBlob{
|
||||
Size: uint32(len(plaintext)),
|
||||
Data: &[]byte(plaintext)[0],
|
||||
}
|
||||
entropyBytes := []byte(entropy)
|
||||
var entropyBlob *windows.DataBlob
|
||||
if len(entropyBytes) > 0 {
|
||||
entropyBlob = &windows.DataBlob{Size: uint32(len(entropyBytes)), Data: &entropyBytes[0]}
|
||||
}
|
||||
var out windows.DataBlob
|
||||
if err := windows.CryptProtectData(&in, nil, entropyBlob, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out); err != nil {
|
||||
return nil, fmt.Errorf("protect sandbox secret: %w", err)
|
||||
}
|
||||
// DPAPI allocates the output with LocalAlloc; copy it out and hand it back.
|
||||
defer windows.LocalFree(windows.Handle(unsafe.Pointer(out.Data)))
|
||||
return append([]byte(nil), unsafe.Slice(out.Data, out.Size)...), nil
|
||||
}
|
||||
|
||||
// unprotectWindowsSecret reverses protectWindowsSecret. It fails for any user
|
||||
// other than the one that wrote the blob, and for a blob minted with a different
|
||||
// principal name as entropy.
|
||||
func unprotectWindowsSecret(ciphertext []byte, entropy string) (string, error) {
|
||||
if len(ciphertext) == 0 {
|
||||
return "", errWindowsSandboxIdentityUnavailable
|
||||
}
|
||||
in := windows.DataBlob{
|
||||
Size: uint32(len(ciphertext)),
|
||||
Data: &ciphertext[0],
|
||||
}
|
||||
entropyBytes := []byte(entropy)
|
||||
var entropyBlob *windows.DataBlob
|
||||
if len(entropyBytes) > 0 {
|
||||
entropyBlob = &windows.DataBlob{Size: uint32(len(entropyBytes)), Data: &entropyBytes[0]}
|
||||
}
|
||||
var out windows.DataBlob
|
||||
if err := windows.CryptUnprotectData(&in, nil, entropyBlob, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out); err != nil {
|
||||
return "", fmt.Errorf("unprotect sandbox secret: %w", err)
|
||||
}
|
||||
defer windows.LocalFree(windows.Handle(unsafe.Pointer(out.Data)))
|
||||
return string(unsafe.Slice(out.Data, out.Size)), nil
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
// Minting a token for a sandbox principal.
|
||||
//
|
||||
// A provisioned account is inert until something can log on as it. Windows
|
||||
// gates that behind account rights held in the local security policy, so setup
|
||||
// grants the principal exactly one: the right to be logged on as a batch job,
|
||||
// which is what a non-interactive service-style logon needs. It is deliberately
|
||||
// NOT granted interactive, network or remote-interactive logon, and those three
|
||||
// are explicitly DENIED, so the account cannot be used to sign in at the
|
||||
// console, over SMB, or through RDP even if its password leaked. The password
|
||||
// exists only so LogonUser can mint a token; nobody is meant to type it.
|
||||
//
|
||||
// Rights are granted at setup (elevated) because LsaAddAccountRights requires
|
||||
// administrator privileges. The per-command path only calls LogonUser, which
|
||||
// needs no special privilege once the batch right is in place.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
// Logon type/provider for a non-interactive token. Batch is the closest
|
||||
// match for "run this command as a service-like principal": it produces a
|
||||
// full token without a desktop or network-credential footprint.
|
||||
logon32LogonBatch = 4
|
||||
logon32ProviderDefault = 0
|
||||
|
||||
// LSA policy access rights needed to add account rights.
|
||||
policyCreateAccount = 0x00000010
|
||||
policyLookupNames = 0x00000800
|
||||
|
||||
// Account rights. The sandbox principal gets the batch right and is denied
|
||||
// every interactive path.
|
||||
seBatchLogonRight = "SeBatchLogonRight"
|
||||
seDenyInteractiveLogonRight = "SeDenyInteractiveLogonRight"
|
||||
seDenyNetworkLogonRight = "SeDenyNetworkLogonRight"
|
||||
seDenyRemoteInteractiveRight = "SeDenyRemoteInteractiveLogonRight"
|
||||
seDenyServiceLogonRightName = "SeDenyServiceLogonRight"
|
||||
windowsIdentityLogonRightsNote = "granted by `zero sandbox setup`"
|
||||
)
|
||||
|
||||
var (
|
||||
procLogonUserW = windows.NewLazySystemDLL("advapi32.dll").NewProc("LogonUserW")
|
||||
procLsaOpenPolicy = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaOpenPolicy")
|
||||
procLsaClose = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaClose")
|
||||
procLsaAddAccountRights = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaAddAccountRights")
|
||||
// Retiring a principal has to drop its rights as well as its account, or the
|
||||
// LSA policy database keeps an entry keyed to a SID that no longer resolves.
|
||||
procLsaRemoveAccountRights = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaRemoveAccountRights")
|
||||
procLsaNtStatusToWinErr = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaNtStatusToWinError")
|
||||
)
|
||||
|
||||
// lsaUnicodeString mirrors LSA_UNICODE_STRING. Length and MaximumLength are
|
||||
// BYTE counts, not rune counts, which is the usual source of bugs here.
|
||||
type lsaUnicodeString struct {
|
||||
Length uint16
|
||||
MaximumLength uint16
|
||||
Buffer *uint16
|
||||
}
|
||||
|
||||
// lsaObjectAttributes mirrors LSA_OBJECT_ATTRIBUTES. Every field except Length
|
||||
// is unused for LsaOpenPolicy, but the struct must still be the right size.
|
||||
type lsaObjectAttributes struct {
|
||||
Length uint32
|
||||
RootDirectory windows.Handle
|
||||
ObjectName *lsaUnicodeString
|
||||
Attributes uint32
|
||||
SecurityDescriptor unsafe.Pointer
|
||||
SecurityQualityOfService unsafe.Pointer
|
||||
}
|
||||
|
||||
// newLSAString builds an LSA_UNICODE_STRING over a UTF-16 buffer the caller
|
||||
// keeps alive. The returned value borrows that buffer, so the buffer must
|
||||
// outlive every use of the string.
|
||||
func newLSAString(buffer []uint16) lsaUnicodeString {
|
||||
if len(buffer) == 0 {
|
||||
return lsaUnicodeString{}
|
||||
}
|
||||
// The buffer is NUL-terminated; the LSA length counts bytes WITHOUT the
|
||||
// terminator, while MaximumLength counts bytes WITH it.
|
||||
runes := len(buffer) - 1
|
||||
return lsaUnicodeString{
|
||||
Length: uint16(runes * 2),
|
||||
MaximumLength: uint16(len(buffer) * 2),
|
||||
Buffer: &buffer[0],
|
||||
}
|
||||
}
|
||||
|
||||
// lsaStatusError converts an NTSTATUS from an Lsa* call into a Go error, going
|
||||
// through LsaNtStatusToWinError so the message is the familiar Win32 one rather
|
||||
// than a raw NTSTATUS.
|
||||
func lsaStatusError(call string, status uintptr) error {
|
||||
if status == 0 {
|
||||
return nil
|
||||
}
|
||||
winErr, _, _ := procLsaNtStatusToWinErr.Call(status)
|
||||
return fmt.Errorf("%s: %w", call, windows.Errno(winErr))
|
||||
}
|
||||
|
||||
// grantWindowsSandboxLogonRights gives the principal the batch logon right and
|
||||
// denies every interactive logon path. Idempotent: LsaAddAccountRights silently
|
||||
// succeeds when the account already holds a right, so setup can re-run.
|
||||
//
|
||||
// Requires an elevated caller.
|
||||
func grantWindowsSandboxLogonRights(sid *windows.SID) error {
|
||||
if sid == nil {
|
||||
return fmt.Errorf("grant sandbox logon rights: nil SID")
|
||||
}
|
||||
var attributes lsaObjectAttributes
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
var policy windows.Handle
|
||||
status, _, _ := procLsaOpenPolicy.Call(
|
||||
0, // local system
|
||||
uintptr(unsafe.Pointer(&attributes)),
|
||||
uintptr(policyCreateAccount|policyLookupNames),
|
||||
uintptr(unsafe.Pointer(&policy)),
|
||||
)
|
||||
// LsaOpenPolicy borrows the attributes struct by address, so it has to stay
|
||||
// reachable until the call has returned.
|
||||
runtime.KeepAlive(attributes)
|
||||
if err := lsaStatusError("LsaOpenPolicy", status); err != nil {
|
||||
return err
|
||||
}
|
||||
defer procLsaClose.Call(uintptr(policy))
|
||||
|
||||
rights := []string{
|
||||
seBatchLogonRight,
|
||||
seDenyInteractiveLogonRight,
|
||||
seDenyNetworkLogonRight,
|
||||
seDenyRemoteInteractiveRight,
|
||||
seDenyServiceLogonRightName,
|
||||
}
|
||||
// Each right is added on its own call so one unsupported name on an odd SKU
|
||||
// cannot silently drop the others.
|
||||
for _, right := range rights {
|
||||
buffer, err := windows.UTF16FromString(right)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry := newLSAString(buffer)
|
||||
status, _, _ := procLsaAddAccountRights.Call(
|
||||
uintptr(policy),
|
||||
uintptr(unsafe.Pointer(sid)),
|
||||
uintptr(unsafe.Pointer(&entry)),
|
||||
1,
|
||||
)
|
||||
// Both the descriptor and the buffer it points at are borrowed by the
|
||||
// call. Kept alive before the error check, not after, so the failure path
|
||||
// does not return with them already collectable.
|
||||
runtime.KeepAlive(entry)
|
||||
runtimeKeepAliveUint16(buffer)
|
||||
if err := lsaStatusError("LsaAddAccountRights("+right+")", status); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// revokeWindowsSandboxLogonRights drops every account right held by a principal
|
||||
// and removes its entry from the LSA policy database.
|
||||
//
|
||||
// This is the logon-rights counterpart to revoking ACEs by trustee, and it has
|
||||
// the same reason to exist: deleting the account on its own leaves the rights
|
||||
// behind, keyed to a SID that no longer resolves, which is the orphaned residue
|
||||
// this model is supposed to avoid. It must therefore run BEFORE the account is
|
||||
// deleted, while the SID is still resolvable.
|
||||
//
|
||||
// Removing all rights rather than naming them is deliberate. The principal is
|
||||
// being retired, so anything keyed to it should go, including rights a previous
|
||||
// version of setup granted and this one no longer knows about.
|
||||
//
|
||||
// Requires an elevated caller. A principal that holds no rights is not an error:
|
||||
// LsaRemoveAccountRights reports ERROR_FILE_NOT_FOUND for an account with no LSA
|
||||
// entry, which is the state teardown is trying to reach anyway.
|
||||
func revokeWindowsSandboxLogonRights(sid *windows.SID) error {
|
||||
if sid == nil {
|
||||
return fmt.Errorf("revoke sandbox logon rights: nil SID")
|
||||
}
|
||||
var attributes lsaObjectAttributes
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
var policy windows.Handle
|
||||
status, _, _ := procLsaOpenPolicy.Call(
|
||||
0, // local system
|
||||
uintptr(unsafe.Pointer(&attributes)),
|
||||
uintptr(policyCreateAccount|policyLookupNames),
|
||||
uintptr(unsafe.Pointer(&policy)),
|
||||
)
|
||||
runtime.KeepAlive(attributes)
|
||||
if err := lsaStatusError("LsaOpenPolicy", status); err != nil {
|
||||
return err
|
||||
}
|
||||
defer procLsaClose.Call(uintptr(policy))
|
||||
|
||||
status, _, _ = procLsaRemoveAccountRights.Call(
|
||||
uintptr(policy),
|
||||
uintptr(unsafe.Pointer(sid)),
|
||||
1, // AllRights: drop everything and delete the LSA account object
|
||||
0, // UserRights ignored when AllRights is set
|
||||
0, // CountOfRights likewise
|
||||
)
|
||||
runtime.KeepAlive(sid)
|
||||
if err := lsaStatusError("LsaRemoveAccountRights", status); err != nil {
|
||||
if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logonWindowsSandboxPrincipal mints a primary token for the sandbox account.
|
||||
// The caller owns the returned token and must Close it.
|
||||
//
|
||||
// This needs no elevation: the batch logon right granted at setup is what makes
|
||||
// it work, which is why the per-command path can run unelevated once setup has
|
||||
// been done once.
|
||||
func logonWindowsSandboxPrincipal(username string, password string) (windows.Token, error) {
|
||||
user, err := windows.UTF16PtrFromString(username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// "." is the local machine, so the lookup never leaves this host even if the
|
||||
// machine is domain-joined and a same-named domain account exists.
|
||||
domain, err := windows.UTF16PtrFromString(".")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
secret, err := windows.UTF16PtrFromString(password)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var token windows.Token
|
||||
result, _, callErr := procLogonUserW.Call(
|
||||
uintptr(unsafe.Pointer(user)),
|
||||
uintptr(unsafe.Pointer(domain)),
|
||||
uintptr(unsafe.Pointer(secret)),
|
||||
logon32LogonBatch,
|
||||
logon32ProviderDefault,
|
||||
uintptr(unsafe.Pointer(&token)),
|
||||
)
|
||||
// The three strings are borrowed for the duration of the call.
|
||||
runtime.KeepAlive(user)
|
||||
runtime.KeepAlive(domain)
|
||||
runtime.KeepAlive(secret)
|
||||
if result == 0 {
|
||||
if callErr != nil && callErr != windows.ERROR_SUCCESS {
|
||||
return 0, fmt.Errorf("LogonUser(%s): %w", username, callErr)
|
||||
}
|
||||
return 0, fmt.Errorf("LogonUser(%s) failed", username)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// runtimeKeepAliveUint16 keeps a UTF-16 buffer reachable across a syscall that
|
||||
// borrows it. Declared rather than inlined so the intent is explicit at each
|
||||
// call site; the compiler must not free the slice while LSA holds the pointer.
|
||||
func runtimeKeepAliveUint16(buffer []uint16) {
|
||||
if len(buffer) == 0 {
|
||||
return
|
||||
}
|
||||
_ = buffer[0]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// revokeWindowsSandboxLogonRights treats "this account holds no rights" as
|
||||
// success, because that is the state teardown is trying to reach anyway. That
|
||||
// relies on the NTSTATUS for it surviving the trip through LsaNtStatusToWinError
|
||||
// as an error errors.Is can still match, which is exactly the kind of Windows
|
||||
// errno assumption that quietly turns out to be false. Assert it rather than
|
||||
// trust it.
|
||||
//
|
||||
// Needs no privilege: LsaNtStatusToWinError is a pure status translation, so
|
||||
// this runs everywhere rather than joining the gated set.
|
||||
func TestLsaStatusErrorMapsObjectNameNotFound(t *testing.T) {
|
||||
// STATUS_OBJECT_NAME_NOT_FOUND, what LsaRemoveAccountRights reports for an
|
||||
// account that has no LSA entry.
|
||||
const statusObjectNameNotFound = 0xC0000034
|
||||
|
||||
err := lsaStatusError("LsaRemoveAccountRights", statusObjectNameNotFound)
|
||||
if err == nil {
|
||||
t.Fatal("a nonzero NTSTATUS produced no error")
|
||||
}
|
||||
if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
|
||||
t.Fatalf("error = %v, want one errors.Is matches against ERROR_FILE_NOT_FOUND; "+
|
||||
"without that, revoking a principal that simply holds no rights fails teardown", err)
|
||||
}
|
||||
|
||||
// The tolerance must be specific. If any failure matched it, revoke would
|
||||
// swallow a real one and teardown would report success having done nothing.
|
||||
const statusAccessDenied = 0xC0000022
|
||||
if other := lsaStatusError("LsaRemoveAccountRights", statusAccessDenied); errors.Is(other, windows.ERROR_FILE_NOT_FOUND) {
|
||||
t.Fatalf("access denied matched the not-found tolerance: %v", other)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Policy deny-write has to reach the principal plan.
|
||||
//
|
||||
// The capability plan has always emitted these. The principal plan denied write
|
||||
// only on protected metadata and read-only subpaths inside write roots, so once
|
||||
// the runner used a principal token a policy deny sitting anywhere else was not
|
||||
// enforced at the OS layer at all, and a shell child could write where the
|
||||
// restricted-token backend would have stopped it.
|
||||
func TestPrincipalACLPlanCarriesPolicyDenyWrite(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "workspace")
|
||||
denied := filepath.Join(root, "protected", "keep-out")
|
||||
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: "S-1-5-21-0-0-0-1001",
|
||||
WriteRoots: []WritableRoot{{Root: root}},
|
||||
DenyWrite: []string{denied},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
|
||||
entry, ok := findPrincipalACLEntry(plan, WindowsACLDenyWrite, denied)
|
||||
if !ok {
|
||||
t.Fatalf("no deny-write ACE for the policy path; plan = %+v", plan.Entries)
|
||||
}
|
||||
// Materialized for the same reason the capability plan does it: the applier
|
||||
// skips targets that do not exist, so a deny on a path created after setup
|
||||
// would never be written.
|
||||
if !entry.Materialize {
|
||||
t.Error("policy deny-write ACE is not materialized, so it is skipped when the path does not exist yet")
|
||||
}
|
||||
}
|
||||
|
||||
// Deny-read has to be materialized too, which it was not.
|
||||
func TestPrincipalACLPlanMaterializesDenyRead(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "workspace")
|
||||
secret := filepath.Join(t.TempDir(), "elsewhere", "creds")
|
||||
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: "S-1-5-21-0-0-0-1001",
|
||||
WriteRoots: []WritableRoot{{Root: root}},
|
||||
DenyRead: []string{secret},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
entry, ok := findPrincipalACLEntry(plan, WindowsACLDenyRead, secret)
|
||||
if !ok {
|
||||
t.Fatalf("no deny-read ACE emitted; plan = %+v", plan.Entries)
|
||||
}
|
||||
if !entry.Materialize {
|
||||
t.Fatal("deny-read ACE is not materialized, so a path created after setup never gets one")
|
||||
}
|
||||
}
|
||||
|
||||
// Deny entries must still precede the grants they carve out of, which is what
|
||||
// makes them win under Windows DACL evaluation. Adding deny-write to the plan is
|
||||
// only safe if it did not disturb that ordering.
|
||||
func TestPrincipalACLPlanKeepsDeniesBeforeGrants(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "workspace")
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: "S-1-5-21-0-0-0-1001",
|
||||
WriteRoots: []WritableRoot{{Root: root}},
|
||||
DenyWrite: []string{filepath.Join(root, "nope")},
|
||||
DenyRead: []string{filepath.Join(root, "secret")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
firstGrant := -1
|
||||
for i, entry := range plan.Entries {
|
||||
switch entry.Action {
|
||||
case WindowsACLAllowWrite, WindowsACLAllowRead:
|
||||
if firstGrant == -1 {
|
||||
firstGrant = i
|
||||
}
|
||||
case WindowsACLDenyRead, WindowsACLDenyWrite:
|
||||
if firstGrant != -1 {
|
||||
t.Fatalf("deny entry at %d follows a grant at %d; the grant would win", i, firstGrant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findPrincipalACLEntry(plan WindowsACLPlan, action WindowsACLAction, path string) (WindowsACLEntry, bool) {
|
||||
want := normalizeProfilePath(path)
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action == action && entry.Path == want {
|
||||
return entry, true
|
||||
}
|
||||
}
|
||||
return WindowsACLEntry{}, false
|
||||
}
|
||||
|
||||
// An adopted account must not have its password rotated during provisioning.
|
||||
//
|
||||
// Rotating there left every later step running against an account whose password
|
||||
// had already been replaced with nothing on disk holding it. Any failure in
|
||||
// between stranded a working principal: the rollback correctly declined to
|
||||
// delete an account it had not created, so what remained was a live account
|
||||
// authenticated by a password no longer stored anywhere, and the command path
|
||||
// read the absent secret as "not provisioned" and quietly fell back to the
|
||||
// weaker backend.
|
||||
func TestProvisionWindowsSandboxIdentityDefersPasswordRotation(t *testing.T) {
|
||||
stubWindowsProvisioning(t, true, nil, nil, nil)
|
||||
|
||||
rotated := false
|
||||
previous := resetWindowsSandboxUserPasswordFn
|
||||
t.Cleanup(func() { resetWindowsSandboxUserPasswordFn = previous })
|
||||
resetWindowsSandboxUserPasswordFn = func(string, string) error {
|
||||
rotated = true
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline); err != nil {
|
||||
t.Fatalf("provisioning an adopted account: %v", err)
|
||||
}
|
||||
if rotated {
|
||||
t.Fatal("provisioning rotated the password; the window this closes lasts until the secret is committed")
|
||||
}
|
||||
}
|
||||
|
||||
// The ownership comment carries the full workspace key, so two workspaces whose
|
||||
// digests collide in the 11 characters the account name can hold are refused
|
||||
// rather than silently sharing one account, one secret and one ACL identity.
|
||||
func TestWindowsSandboxUserCommentDistinguishesWorkspaces(t *testing.T) {
|
||||
first := windowsSandboxUserCommentFor("aaaaaaaaaaaabbbbbbbb")
|
||||
second := windowsSandboxUserCommentFor("aaaaaaaaaaaacccccccc")
|
||||
if first == second {
|
||||
t.Fatal("two workspaces produced the same ownership comment, so a name collision would be adopted")
|
||||
}
|
||||
if !strings.HasPrefix(first, windowsSandboxUserComment) {
|
||||
t.Fatalf("comment %q lost the marker prefix that identifies it as ours", first)
|
||||
}
|
||||
// The names DO collide, which is the whole reason the comment has to carry
|
||||
// the key. If this stops being true the test is no longer covering anything.
|
||||
if windowsSandboxUserName("aaaaaaaaaaaabbbbbbbb", windowsSandboxRoleOffline) != windowsSandboxUserName("aaaaaaaaaaaacccccccc", windowsSandboxRoleOffline) {
|
||||
t.Skip("account names no longer collide for these keys; revisit what this test is for")
|
||||
}
|
||||
}
|
||||
|
||||
// Rollback must not strip an adopted principal's logon rights.
|
||||
//
|
||||
// revokeWindowsSandboxLogonRights passes AllRights, which drops every right the
|
||||
// account holds and deletes its LSA object. On an account this run created that
|
||||
// is a rollback; on one it adopted it destroys the SeBatchLogonRight and
|
||||
// deny-logon rights an earlier setup established, which is the working
|
||||
// principal this path exists to preserve.
|
||||
func TestSetupRollbackRevokesRightsOnlyForCreatedPrincipals(t *testing.T) {
|
||||
for name, testCase := range map[string]struct {
|
||||
existed bool
|
||||
wantRevoked bool
|
||||
}{
|
||||
"adopted principal": {existed: true, wantRevoked: false},
|
||||
"created principal": {existed: false, wantRevoked: true},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
stubWindowsProvisioning(t, testCase.existed, nil, nil, nil)
|
||||
|
||||
revoked := false
|
||||
prevGrant, prevRevoke := grantWindowsSandboxLogonRightsFn, revokeWindowsSandboxLogonRightsFn
|
||||
t.Cleanup(func() {
|
||||
grantWindowsSandboxLogonRightsFn, revokeWindowsSandboxLogonRightsFn = prevGrant, prevRevoke
|
||||
})
|
||||
// Fail the grant so the undo path runs with rights already attempted,
|
||||
// which is the state that used to revoke unconditionally.
|
||||
grantWindowsSandboxLogonRightsFn = func(*windows.SID) error {
|
||||
return errors.New("LSA grant refused by policy")
|
||||
}
|
||||
revokeWindowsSandboxLogonRightsFn = func(*windows.SID) error {
|
||||
revoked = true
|
||||
return nil
|
||||
}
|
||||
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
WorkspaceRoots: []string{`C:\ws`},
|
||||
}
|
||||
if _, _, err := provisionWindowsSandboxPrincipalForSetup(config, windowsSandboxRoleOffline); err == nil {
|
||||
t.Fatal("provisioning reported success despite an injected grant failure")
|
||||
}
|
||||
if revoked != testCase.wantRevoked {
|
||||
if testCase.wantRevoked {
|
||||
t.Fatal("rights were not revoked for an account this run created, leaving LSA entries keyed to a SID about to be deleted")
|
||||
}
|
||||
t.Fatal("rights were revoked for an adopted account; AllRights drops its pre-existing rights and deletes the LSA object")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A secret the current user cannot read is unavailability, not breakage.
|
||||
//
|
||||
// The secret's DACL names whoever ran setup. An operator who elevated with a
|
||||
// separate administrative account, via runas or an over-the-shoulder UAC
|
||||
// prompt, leaves a secret their ordinary account cannot open. Treating that as a
|
||||
// hard error made every sandboxed command fail on a machine that was merely set
|
||||
// up by a different admin; it belongs in the same fail-soft path as a missing
|
||||
// secret, so the warning fires and the restricted token takes over.
|
||||
func TestReadWindowsSandboxSecretTreatsPermissionDeniedAsUnavailable(t *testing.T) {
|
||||
previous := readWindowsSandboxSecretFile
|
||||
t.Cleanup(func() { readWindowsSandboxSecretFile = previous })
|
||||
readWindowsSandboxSecretFile = func(string) ([]byte, error) {
|
||||
return nil, &os.PathError{Op: "open", Path: "secret", Err: windows.ERROR_ACCESS_DENIED}
|
||||
}
|
||||
|
||||
if _, err := readWindowsSandboxSecret(`C:\anything.secret`); err != errWindowsSandboxIdentityUnavailable {
|
||||
t.Fatalf("permission-denied read returned %v, want errWindowsSandboxIdentityUnavailable so the command falls back", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace must not bind to another workspace's principal.
|
||||
//
|
||||
// The account name carries only 11 characters of the workspace digest, so two
|
||||
// workspaces can derive the same name. Provisioning refuses that case by
|
||||
// checking the full key in the account comment, but the command path resolved
|
||||
// the name straight to a SID. The workspace that lost the race would have failed
|
||||
// setup and then quietly run as the other one's principal, using its secret and
|
||||
// its ACL identity.
|
||||
func TestLookupWindowsSandboxIdentityRejectsForeignWorkspace(t *testing.T) {
|
||||
previous := windowsSandboxUserIsManagedFn
|
||||
t.Cleanup(func() { windowsSandboxUserIsManagedFn = previous })
|
||||
|
||||
prevSID := resolveWindowsSandboxSIDFn
|
||||
t.Cleanup(func() { resolveWindowsSandboxSIDFn = prevSID })
|
||||
// The account resolves; whether it BELONGS to this workspace is the question.
|
||||
resolveWindowsSandboxSIDFn = func(string) (*windows.SID, error) {
|
||||
return windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
}
|
||||
|
||||
var askedKey string
|
||||
windowsSandboxUserIsManagedFn = func(_ string, workspaceKey string) (bool, error) {
|
||||
askedKey = workspaceKey
|
||||
return false, nil
|
||||
}
|
||||
_, err := lookupWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline)
|
||||
if err == nil {
|
||||
t.Fatal("lookup accepted an account belonging to another workspace")
|
||||
}
|
||||
if errors.Is(err, errWindowsSandboxIdentityUnavailable) {
|
||||
t.Fatal("a foreign account must not read as unprovisioned; that would silently fall back instead of reporting the conflict")
|
||||
}
|
||||
if askedKey != "" && askedKey != "workspacekey" {
|
||||
t.Fatalf("ownership was checked against %q, want the caller's workspace key", askedKey)
|
||||
}
|
||||
}
|
||||
|
||||
// An account that does not exist must stay the unavailable sentinel rather than
|
||||
// becoming a collision error, since that is the ordinary not-set-up state.
|
||||
func TestLookupWindowsSandboxIdentityAbsentAccountIsUnavailable(t *testing.T) {
|
||||
previous := windowsSandboxUserIsManagedFn
|
||||
t.Cleanup(func() { windowsSandboxUserIsManagedFn = previous })
|
||||
windowsSandboxUserIsManagedFn = func(string, string) (bool, error) {
|
||||
t.Fatal("ownership must not be consulted for an account that does not resolve")
|
||||
return false, nil
|
||||
}
|
||||
if _, err := lookupWindowsSandboxIdentity("nosuchworkspacekey", windowsSandboxRoleOffline); !errors.Is(err, errWindowsSandboxIdentityUnavailable) {
|
||||
t.Fatalf("absent account returned %v, want errWindowsSandboxIdentityUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The git control-plane carveouts must be materialized.
|
||||
//
|
||||
// gitMetadataWriteCarveouts supplies .git/config and .git/hooks as
|
||||
// ReadOnlySubpaths of the workspace. On a workspace where git has not run yet
|
||||
// they do not exist when setup applies the plan, and applyWindowsACLPlan skips
|
||||
// an absent target, so the deny ACEs were never written. Once git created those
|
||||
// paths the principal still held inherited write access and could install a
|
||||
// hook or rewrite credential.helper.
|
||||
func TestPrincipalACLPlanMaterializesReadOnlySubpaths(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "workspace")
|
||||
carveout := filepath.Join(root, ".git", "config")
|
||||
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: "S-1-5-21-0-0-0-1001",
|
||||
WriteRoots: []WritableRoot{{Root: root, ReadOnlySubpaths: []string{carveout}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsPrincipalACLPlan: %v", err)
|
||||
}
|
||||
entry, ok := findPrincipalACLEntry(plan, WindowsACLDenyWrite, carveout)
|
||||
if !ok {
|
||||
t.Fatalf("no deny-write ACE for the git carveout; plan = %+v", plan.Entries)
|
||||
}
|
||||
if !entry.Materialize {
|
||||
t.Fatal("git carveout deny-write is not materialized, so it is skipped on a workspace where .git does not exist yet")
|
||||
}
|
||||
}
|
||||
|
||||
// Setup must grant the principal the same runtime root that commands write to.
|
||||
//
|
||||
// permissionProfileWithRuntime appends this root to WriteRoots on every command
|
||||
// and redirects HOME, GOCACHE and npm_config_cache into it, but it lives under
|
||||
// the user cache rather than the workspace, so the profile setup sees never
|
||||
// contains it. A principal is a separate account with no rights there, so
|
||||
// without a grant every npm install or go build fails on a cache write.
|
||||
//
|
||||
// The assertion that matters is that the two derivations agree. If they drift,
|
||||
// setup writes the ACE on one directory while commands use another, and the
|
||||
// symptom is a bare ACCESS_DENIED with nothing pointing at the sandbox.
|
||||
func TestSetupGrantsTheRuntimeRootCommandsActuallyUse(t *testing.T) {
|
||||
workspace := filepath.Join(t.TempDir(), "ws")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
granted, err := setupWindowsSandboxRuntimeRoot(WindowsSandboxCommandConfig{
|
||||
WorkspaceRoots: []string{workspace},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("setupWindowsSandboxRuntimeRoot: %v", err)
|
||||
}
|
||||
if len(granted) == 0 {
|
||||
t.Fatal("no runtime root resolved for a configured workspace")
|
||||
}
|
||||
// EVERY granted candidate has to exist, not just the one this process would
|
||||
// pick: applyWindowsACLPlan fails the whole run on a target that is absent.
|
||||
for _, root := range granted {
|
||||
if info, err := os.Stat(root); err != nil || !info.IsDir() {
|
||||
t.Fatalf("runtime root %q was not created; applyWindowsACLPlan skips absent targets so the grant would no-op (stat err %v)", root, err)
|
||||
}
|
||||
}
|
||||
|
||||
// What a command would actually use.
|
||||
runtimeState, release, err := prepareSandboxRuntime(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("prepareSandboxRuntime: %v", err)
|
||||
}
|
||||
if release != nil {
|
||||
defer release()
|
||||
}
|
||||
// The command's choice has to be one setup provisioned. Setup grants the whole
|
||||
// candidate set precisely because the choice is made per process, so the
|
||||
// contract is membership rather than equality.
|
||||
if !grantedRuntimeRootsCover(granted, runtimeState.Root) {
|
||||
t.Fatalf("setup granted %q but commands write to %q", granted, runtimeState.Root)
|
||||
}
|
||||
}
|
||||
|
||||
// grantedRuntimeRootsCover reports whether the root a command selected is one of
|
||||
// the roots setup granted an ACE on.
|
||||
func grantedRuntimeRootsCover(granted []string, selected string) bool {
|
||||
for _, root := range granted {
|
||||
if filepath.Clean(root) == filepath.Clean(selected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// No workspace root means nothing to grant, which is not an error.
|
||||
func TestSetupRuntimeRootWithoutWorkspaceIsNotAnError(t *testing.T) {
|
||||
granted, err := setupWindowsSandboxRuntimeRoot(WindowsSandboxCommandConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("no workspace root should not error: %v", err)
|
||||
}
|
||||
if len(granted) != 0 {
|
||||
t.Fatalf("granted %q with no workspace configured", granted)
|
||||
}
|
||||
}
|
||||
|
||||
// An account in a privileged group must not be adopted, even when name and
|
||||
// ownership comment say it is ours.
|
||||
//
|
||||
// Adoption resets the password and hands the account to the sandbox. If that
|
||||
// account is also in Administrators, the sandbox gains the rights the sandbox
|
||||
// exists to withhold: rewriting the ACLs confining it, reading the secret locked
|
||||
// to the invoking user, and stopping Zero.
|
||||
func TestProvisionWindowsSandboxIdentityRefusesPrivilegedAccount(t *testing.T) {
|
||||
stubWindowsProvisioning(t, true, nil, nil, nil)
|
||||
|
||||
previous := windowsSandboxUserIsPrivilegedFn
|
||||
t.Cleanup(func() { windowsSandboxUserIsPrivilegedFn = previous })
|
||||
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return true, nil }
|
||||
|
||||
_, _, created, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline)
|
||||
if !errors.Is(err, errWindowsSandboxPrivilegedAccount) {
|
||||
t.Fatalf("provisioning adopted a privileged account, err = %v", err)
|
||||
}
|
||||
if created {
|
||||
t.Fatal("created must stay false for an account this run refused to adopt")
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary adopted account is unaffected.
|
||||
func TestProvisionWindowsSandboxIdentityAdoptsUnprivilegedAccount(t *testing.T) {
|
||||
stubWindowsProvisioning(t, true, nil, nil, nil)
|
||||
|
||||
previous := windowsSandboxUserIsPrivilegedFn
|
||||
t.Cleanup(func() { windowsSandboxUserIsPrivilegedFn = previous })
|
||||
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return false, nil }
|
||||
|
||||
if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline); err != nil {
|
||||
t.Fatalf("an unprivileged managed account must still be adopted: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Group membership is not frozen at setup. An account provisioned clean can be
|
||||
// added to Administrators afterwards — by an operator, or by an attacker who
|
||||
// already has that access and wants the sandbox to hand it back. Provisioning's
|
||||
// refusal cannot see that; only the path that mints the token can.
|
||||
func TestLookupPrincipalForCommandRefusesAnAccountThatBecamePrivileged(t *testing.T) {
|
||||
sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWellKnownSid: %v", err)
|
||||
}
|
||||
restoreLookupSeams(t, sid)
|
||||
|
||||
privilegedCalls := 0
|
||||
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) {
|
||||
privilegedCalls++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
_, err = lookupWindowsSandboxPrincipalForCommand("workspace-key", windowsSandboxRoleOnline)
|
||||
if !errors.Is(err, errWindowsSandboxPrivilegedAccount) {
|
||||
t.Fatalf("err = %v, want errWindowsSandboxPrivilegedAccount", err)
|
||||
}
|
||||
if privilegedCalls != 1 {
|
||||
t.Errorf("privileged check ran %d times, want exactly 1", privilegedCalls)
|
||||
}
|
||||
// It must be a hard refusal, not the unavailable sentinel — that one is the
|
||||
// quiet "not provisioned" fallback and would silently drop the sandbox back
|
||||
// to the restricted token instead of telling the operator.
|
||||
if errors.Is(err, errWindowsSandboxIdentityUnavailable) {
|
||||
t.Error("privileged refusal must not read as the not-provisioned fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupPrincipalForCommandAcceptsAnUnprivilegedAccount(t *testing.T) {
|
||||
sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWellKnownSid: %v", err)
|
||||
}
|
||||
restoreLookupSeams(t, sid)
|
||||
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return false, nil }
|
||||
|
||||
identity, err := lookupWindowsSandboxPrincipalForCommand("workspace-key", windowsSandboxRoleOnline)
|
||||
if err != nil {
|
||||
t.Fatalf("lookupWindowsSandboxPrincipalForCommand: %v", err)
|
||||
}
|
||||
if identity.Username == "" {
|
||||
t.Error("expected the resolved principal")
|
||||
}
|
||||
}
|
||||
|
||||
// Teardown must stay able to clean up an account that has become privileged.
|
||||
// If the refusal lived inside lookupWindowsSandboxIdentity, the account this
|
||||
// guard exists to catch would become undeletable by Zero.
|
||||
func TestLookupIdentityItselfDoesNotConsultPrivilege(t *testing.T) {
|
||||
sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWellKnownSid: %v", err)
|
||||
}
|
||||
restoreLookupSeams(t, sid)
|
||||
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) {
|
||||
t.Error("teardown's lookup must not be gated on privilege")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if _, err := lookupWindowsSandboxIdentity("workspace-key", windowsSandboxRoleOnline); err != nil {
|
||||
t.Fatalf("lookupWindowsSandboxIdentity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func restoreLookupSeams(t *testing.T, sid *windows.SID) {
|
||||
t.Helper()
|
||||
prevResolve := resolveWindowsSandboxSIDFn
|
||||
prevManaged := windowsSandboxUserIsManagedFn
|
||||
prevPrivileged := windowsSandboxUserIsPrivilegedFn
|
||||
t.Cleanup(func() {
|
||||
resolveWindowsSandboxSIDFn = prevResolve
|
||||
windowsSandboxUserIsManagedFn = prevManaged
|
||||
windowsSandboxUserIsPrivilegedFn = prevPrivileged
|
||||
})
|
||||
resolveWindowsSandboxSIDFn = func(string) (*windows.SID, error) { return sid, nil }
|
||||
windowsSandboxUserIsManagedFn = func(string, string) (bool, error) { return true, nil }
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// stubWindowsProvisioning replaces the four provisioning syscalls so the
|
||||
// function can run on an ordinary machine. Every one of them needs an elevated
|
||||
// caller and a real local account, so without this the test would stop at the
|
||||
// first call and never reach the behaviour it is named for.
|
||||
func stubWindowsProvisioning(t *testing.T, existed bool, groupErr error, offlineErr error, sidErr error) {
|
||||
t.Helper()
|
||||
prevGroup, prevUser := ensureWindowsSandboxGroupFn, ensureWindowsSandboxUserFn
|
||||
prevOfflineGroup := ensureWindowsSandboxOfflineGroupFn
|
||||
prevAdd, prevSID := addWindowsSandboxUserToGroupFn, resolveWindowsSandboxSIDFn
|
||||
prevManaged := windowsSandboxUserIsManagedFn
|
||||
prevOffline := addWindowsSandboxUserToOfflineGroupFn
|
||||
prevReset := resetWindowsSandboxUserPasswordFn
|
||||
t.Cleanup(func() {
|
||||
ensureWindowsSandboxGroupFn, ensureWindowsSandboxUserFn = prevGroup, prevUser
|
||||
ensureWindowsSandboxOfflineGroupFn = prevOfflineGroup
|
||||
addWindowsSandboxUserToGroupFn, resolveWindowsSandboxSIDFn = prevAdd, prevSID
|
||||
windowsSandboxUserIsManagedFn = prevManaged
|
||||
addWindowsSandboxUserToOfflineGroupFn = prevOffline
|
||||
resetWindowsSandboxUserPasswordFn = prevReset
|
||||
})
|
||||
|
||||
ensureWindowsSandboxGroupFn = func() error { return nil }
|
||||
ensureWindowsSandboxOfflineGroupFn = func() error { return nil }
|
||||
// Adopted accounts are ours in these tests; the ownership check is a real
|
||||
// syscall and would otherwise refuse before the code under test runs.
|
||||
windowsSandboxUserIsManagedFn = func(string, string) (bool, error) { return true, nil }
|
||||
// Stubbed even though provisioning no longer rotates: rotation moved to the
|
||||
// caller, so nothing under test reaches this today. Left in so that if it
|
||||
// ever moves back, these tests fail rather than resetting the password of a
|
||||
// real managed account that happens to exist on the machine running them.
|
||||
resetWindowsSandboxUserPasswordFn = func(string, string) error {
|
||||
t.Fatal("provisioning must not rotate an account password; rotation belongs to the caller, immediately before the secret is written")
|
||||
return nil
|
||||
}
|
||||
ensureWindowsSandboxUserFn = func(string, string, string) (bool, error) { return existed, nil }
|
||||
addWindowsSandboxUserToGroupFn = func(string) error { return groupErr }
|
||||
addWindowsSandboxUserToOfflineGroupFn = func(string) error { return offlineErr }
|
||||
resolveWindowsSandboxSIDFn = func(username string) (*windows.SID, error) {
|
||||
if sidErr != nil {
|
||||
return nil, sidErr
|
||||
}
|
||||
return windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
}
|
||||
}
|
||||
|
||||
// A failure after the account has been created must still hand back the name.
|
||||
//
|
||||
// The caller's rollback deletes by identity.Username, so returning a zero
|
||||
// identity alongside created=true asked it to delete "" and quietly left the
|
||||
// account this run had just made. Group attachment is the case that matters
|
||||
// most: it is the enforcement boundary and it can fail under local policy.
|
||||
func TestProvisionWindowsSandboxIdentityReturnsNameForRollback(t *testing.T) {
|
||||
groupFailure := errors.New("group attachment refused by policy")
|
||||
offlineFailure := errors.New("offline group attachment refused by policy")
|
||||
sidFailure := errors.New("sid lookup failed")
|
||||
|
||||
for name, testCase := range map[string]struct {
|
||||
groupErr error
|
||||
offlineErr error
|
||||
sidErr error
|
||||
}{
|
||||
"group attachment fails": {groupErr: groupFailure},
|
||||
"offline group attachment fails": {offlineErr: offlineFailure},
|
||||
"sid resolution fails": {sidErr: sidFailure},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
stubWindowsProvisioning(t, false, testCase.groupErr, testCase.offlineErr, testCase.sidErr)
|
||||
|
||||
identity, _, created, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline)
|
||||
if err == nil {
|
||||
t.Fatal("provisioning reported success despite an injected failure")
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("created = false, so the rollback would skip an account this run made")
|
||||
}
|
||||
// The whole point: without a name there is nothing to delete.
|
||||
if identity.Username == "" {
|
||||
t.Fatal("identity carries no username, so the rollback deletes \"\" and strands the account")
|
||||
}
|
||||
if want := windowsSandboxUserName("workspacekey", windowsSandboxRoleOffline); identity.Username != want {
|
||||
t.Fatalf("username = %q, want %q", identity.Username, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An account that already existed must not be deleted because a later step
|
||||
// failed. created=false is what stops the rollback turning a partial failure
|
||||
// into the loss of a working principal from an earlier setup.
|
||||
func TestProvisionWindowsSandboxIdentityDoesNotClaimPreexistingAccount(t *testing.T) {
|
||||
stubWindowsProvisioning(t, true, errors.New("group attachment refused"), nil, nil)
|
||||
|
||||
_, _, created, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline)
|
||||
if err == nil {
|
||||
t.Fatal("provisioning reported success despite an injected failure")
|
||||
}
|
||||
// created is the whole assertion. It is what stops the rollback deleting an
|
||||
// account it did not make, and it must stay false however provisioning
|
||||
// fails. The identity is deliberately not checked: an adopted account exits
|
||||
// early at the ownership check, which is a real syscall and not stubbed
|
||||
// here, so asserting on the name would be testing the stub rather than the
|
||||
// contract.
|
||||
if created {
|
||||
t.Fatal("created = true for an account this run did not create; rollback would delete a working principal")
|
||||
}
|
||||
}
|
||||
|
||||
// The write grant has to include delete.
|
||||
//
|
||||
// FILE_GENERIC_WRITE covers creating and modifying but not removing or
|
||||
// renaming, and a rename needs delete on the source. The old same-user token hid
|
||||
// this because the caller already held inherited rights on its own tree; a
|
||||
// principal is a separate account with none, so without these it can write files
|
||||
// it can never remove, which fails ordinary editing and most git operations
|
||||
// rather than an edge case.
|
||||
func TestWindowsACLAllowWriteGrantsDelete(t *testing.T) {
|
||||
mode, mask, err := windowsACLAccess(WindowsACLAllowWrite)
|
||||
if err != nil {
|
||||
t.Fatalf("windowsACLAccess: %v", err)
|
||||
}
|
||||
if mode != windows.GRANT_ACCESS {
|
||||
t.Fatalf("mode = %v, want GRANT_ACCESS", mode)
|
||||
}
|
||||
// Atomic bits only. FILE_GENERIC_READ and FILE_GENERIC_WRITE both carry
|
||||
// READ_CONTROL and SYNCHRONIZE, so testing a composite constant with & is
|
||||
// satisfied by any grant at all and proves nothing.
|
||||
for label, bit := range map[string]windows.ACCESS_MASK{
|
||||
"DELETE": windows.DELETE,
|
||||
"FILE_WRITE_DATA": windows.FILE_WRITE_DATA,
|
||||
} {
|
||||
if mask&bit == 0 {
|
||||
t.Errorf("write grant is missing %s", label)
|
||||
}
|
||||
}
|
||||
// Granting these would let the principal rewrite the very restrictions
|
||||
// placed on it. They are in the deny mask for that reason and must not
|
||||
// appear here.
|
||||
//
|
||||
// FILE_DELETE_CHILD belongs in this set and was originally in the one
|
||||
// above, on the reasoning that the grant should mirror the deny mask. On a
|
||||
// parent it authorises deleting a child whatever the child's own DACL says,
|
||||
// so on a write root it hands back the write-denied carve-outs underneath:
|
||||
// delete .git/config, recreate it, and the replacement inherits the grant
|
||||
// with no deny of its own. Mirroring the deny mask is the wrong instinct —
|
||||
// denying a capability is not a reason to grant it.
|
||||
for label, bit := range map[string]windows.ACCESS_MASK{
|
||||
"WRITE_DAC": windows.WRITE_DAC,
|
||||
"WRITE_OWNER": windows.WRITE_OWNER,
|
||||
"FILE_DELETE_CHILD": windowsFileDeleteChild,
|
||||
} {
|
||||
if mask&bit != 0 {
|
||||
t.Errorf("write grant unexpectedly includes %s", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The read grant must stay read-only. Widening the write mask above is only
|
||||
// safe if this one did not move with it.
|
||||
func TestWindowsACLAllowReadGrantsNoDelete(t *testing.T) {
|
||||
_, mask, err := windowsACLAccess(WindowsACLAllowRead)
|
||||
if err != nil {
|
||||
t.Fatalf("windowsACLAccess: %v", err)
|
||||
}
|
||||
// Atomic bits, for the same reason as above: the read and write composites
|
||||
// overlap on the standard rights, so a composite check here would report a
|
||||
// failure that is not real.
|
||||
for label, bit := range map[string]windows.ACCESS_MASK{
|
||||
"DELETE": windows.DELETE,
|
||||
"FILE_DELETE_CHILD": windowsFileDeleteChild,
|
||||
"FILE_WRITE_DATA": windows.FILE_WRITE_DATA,
|
||||
} {
|
||||
if mask&bit != 0 {
|
||||
t.Errorf("read grant unexpectedly includes %s", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,292 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An opted-in command that ends up on the restricted token anyway must say so.
|
||||
// Both cases below are correct fallbacks, not errors — but silence leaves the
|
||||
// operator believing an account boundary is isolating them when it is not, which
|
||||
// is the same failure the setup-protocol opt-in check exists to prevent, reached
|
||||
// from the other side. The deny case matters most: deny is the DEFAULT network
|
||||
// mode, so a fully provisioned, fully agreeing setup still never uses the
|
||||
// principal for an ordinary command.
|
||||
func TestWindowsSandboxPrincipalFallbackIsAnnounced(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
mode NetworkMode
|
||||
reason string
|
||||
}{
|
||||
// The network-deny case is deliberately absent. It used to warn here, but
|
||||
// this runner is re-exec'd per command, so the sync.Once guarding the notice
|
||||
// is once per COMMAND — and deny is the default mode, so the warning landed
|
||||
// on nearly every tool call. That fact belongs to `zero doctor` now, which is
|
||||
// read once. The deny-mode BEHAVIOUR is still pinned, by
|
||||
// TestPrincipalBackendDefersToRestrictedTokenWhenNetworkDenied below.
|
||||
{name: "no principal provisioned on this machine", mode: NetworkAllow, reason: "no sandbox principal is provisioned"},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var warned []string
|
||||
originalWarn := warnWindowsSandboxPrincipalNotUsed
|
||||
warnWindowsSandboxPrincipalNotUsed = func(reason string) { warned = append(warned, reason) }
|
||||
windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{}
|
||||
t.Cleanup(func() {
|
||||
warnWindowsSandboxPrincipalNotUsed = originalWarn
|
||||
windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{}
|
||||
})
|
||||
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: `C:\workspace`,
|
||||
WorkspaceRoots: []string{`C:\workspace`},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}},
|
||||
Network: NetworkPolicy{Mode: testCase.mode},
|
||||
},
|
||||
Env: map[string]string{windowsSandboxIdentityEnv: "1"},
|
||||
SandboxLevel: WindowsSandboxLevelRestrictedToken,
|
||||
Command: []string{"cmd.exe", "/c", "echo"},
|
||||
}
|
||||
// Assert the precondition rather than assume it: this must be the quiet
|
||||
// fallback path, not a token this host actually minted and not an error.
|
||||
token, ok, err := windowsSandboxPrincipalToken(config)
|
||||
if ok {
|
||||
token.Close()
|
||||
t.Fatalf("host unexpectedly provisioned a principal; this test cannot measure the fallback")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("windowsSandboxPrincipalToken error = %v, want the quiet fallback", err)
|
||||
}
|
||||
if len(warned) != 1 {
|
||||
t.Fatalf("opted-in fallback warnings = %v, want exactly one naming %q", warned, testCase.reason)
|
||||
}
|
||||
if !strings.Contains(warned[0], testCase.reason) {
|
||||
t.Fatalf("warning = %q, want it to name %q", warned[0], testCase.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The opt-out must stay silent, or the warning becomes noise every user learns
|
||||
// to ignore.
|
||||
func TestWindowsSandboxPrincipalFallbackIsSilentWhenOptedOut(t *testing.T) {
|
||||
var warned []string
|
||||
originalWarn := warnWindowsSandboxPrincipalNotUsed
|
||||
warnWindowsSandboxPrincipalNotUsed = func(reason string) { warned = append(warned, reason) }
|
||||
windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{}
|
||||
t.Cleanup(func() {
|
||||
warnWindowsSandboxPrincipalNotUsed = originalWarn
|
||||
windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{}
|
||||
})
|
||||
|
||||
config := WindowsSandboxCommandConfig{
|
||||
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},
|
||||
},
|
||||
Env: map[string]string{windowsSandboxIdentityEnv: "0"},
|
||||
SandboxLevel: WindowsSandboxLevelRestrictedToken,
|
||||
Command: []string{"cmd.exe", "/c", "echo"},
|
||||
}
|
||||
if _, ok, err := windowsSandboxPrincipalToken(config); ok || err != nil {
|
||||
t.Fatalf("windowsSandboxPrincipalToken ok=%v err=%v, want the quiet opted-out fallback", ok, err)
|
||||
}
|
||||
if len(warned) != 0 {
|
||||
t.Fatalf("opted-out command warned %v, want silence", warned)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup must stay inert unless the principal backend is explicitly opted into.
|
||||
// This is the property that makes the branch safe to merge while the privileged
|
||||
// paths are still being validated: without the opt-in, `zero sandbox setup`
|
||||
// creates no local account and the capability-SID backend is the whole of setup.
|
||||
func TestWindowsSandboxIdentityGating(t *testing.T) {
|
||||
for name, testCase := range map[string]struct {
|
||||
env map[string]string
|
||||
want bool
|
||||
}{
|
||||
// An explicit map entry is authoritative; these cases never reach the
|
||||
// process environment.
|
||||
"empty": {env: map[string]string{windowsSandboxIdentityEnv: ""}, want: false},
|
||||
"zero": {env: map[string]string{windowsSandboxIdentityEnv: "0"}, want: false},
|
||||
"true not one": {env: map[string]string{windowsSandboxIdentityEnv: "true"}, want: false},
|
||||
"one": {env: map[string]string{windowsSandboxIdentityEnv: "1"}, want: true},
|
||||
"one with space": {env: map[string]string{windowsSandboxIdentityEnv: " 1 "}, want: true},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// Pin the process variable too. Every case here supplies an explicit
|
||||
// map entry so none of them should consult it, and pinning proves
|
||||
// that rather than assuming it: without this a developer who exports
|
||||
// the opt-in would see different results from CI.
|
||||
t.Setenv(windowsSandboxIdentityEnv, "1")
|
||||
if got := windowsSandboxIdentityEnabled(testCase.env); got != testCase.want {
|
||||
t.Fatalf("enabled = %v, want %v for %q", got, testCase.want, testCase.env[windowsSandboxIdentityEnv])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// With no map entry the process environment decides. That fallback is what the
|
||||
// elevated setup path actually runs on — it passes no Env — so it needs its own
|
||||
// coverage rather than riding on a case that also has a map entry.
|
||||
func TestWindowsSandboxIdentityGatingFallsBackToTheProcessEnvironment(t *testing.T) {
|
||||
for name, testCase := range map[string]struct {
|
||||
value string
|
||||
set bool
|
||||
want bool
|
||||
}{
|
||||
"unset": {set: false, want: false},
|
||||
"empty": {value: "", set: true, want: false},
|
||||
"zero": {value: "0", set: true, want: false},
|
||||
"one": {value: "1", set: true, want: true},
|
||||
"one with space": {value: " 1 ", set: true, want: true},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// t.Setenv registers the restore even when the variable is then
|
||||
// cleared, which is the only way to test a genuinely absent variable
|
||||
// without leaking that state into the rest of the package.
|
||||
t.Setenv(windowsSandboxIdentityEnv, testCase.value)
|
||||
if !testCase.set {
|
||||
if err := os.Unsetenv(windowsSandboxIdentityEnv); err != nil {
|
||||
t.Fatalf("unset %s: %v", windowsSandboxIdentityEnv, err)
|
||||
}
|
||||
}
|
||||
if got := windowsSandboxIdentityEnabled(nil); got != testCase.want {
|
||||
t.Fatalf("enabled = %v, want %v (set=%v value=%q)", got, testCase.want, testCase.set, testCase.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The command environment wins over the process environment, so a run can opt in
|
||||
// or out without depending on how the parent shell was launched.
|
||||
func TestWindowsSandboxIdentityEnvOverridesProcess(t *testing.T) {
|
||||
t.Setenv(windowsSandboxIdentityEnv, "1")
|
||||
if windowsSandboxIdentityEnabled(map[string]string{windowsSandboxIdentityEnv: "0"}) {
|
||||
t.Fatal("command env set to 0 must override a process env of 1")
|
||||
}
|
||||
if !windowsSandboxIdentityEnabled(map[string]string{}) {
|
||||
t.Fatal("with no command-env entry the process env should apply")
|
||||
}
|
||||
}
|
||||
|
||||
// One workspace maps to one principal, and different workspaces must not share
|
||||
// an account, or two projects would run under the same identity and could reach
|
||||
// each other's granted roots.
|
||||
func TestWindowsSandboxWorkspaceKeyIsStableAndDistinct(t *testing.T) {
|
||||
first := windowsSandboxWorkspaceKey([]string{`C:\ws\alpha`})
|
||||
again := windowsSandboxWorkspaceKey([]string{`C:\ws\alpha`})
|
||||
other := windowsSandboxWorkspaceKey([]string{`C:\ws\beta`})
|
||||
if first != again {
|
||||
t.Fatalf("key is not stable: %q vs %q", first, again)
|
||||
}
|
||||
if first == other {
|
||||
t.Fatal("two different workspaces produced the same principal key")
|
||||
}
|
||||
if first == "" {
|
||||
t.Fatal("empty key")
|
||||
}
|
||||
// An empty root list still has to yield a usable key rather than a blank one.
|
||||
if windowsSandboxWorkspaceKey(nil) == "" {
|
||||
t.Fatal("no workspace roots produced an empty key")
|
||||
}
|
||||
}
|
||||
|
||||
// Network denial is enforced by WFP filters keyed to the offline-marker SID,
|
||||
// which only the restricted token carries. A principal token would leave those
|
||||
// filters matching nothing, so the principal backend must stand down whenever
|
||||
// the network is denied rather than silently trading network enforcement for
|
||||
// read confinement.
|
||||
// The backend now runs under both network modes, so the opt-in is the only thing
|
||||
// eligibility turns on. The mode selects which principal is used, not whether one
|
||||
// is used at all.
|
||||
func TestPrincipalBackendEligibilityTurnsOnlyOnTheOptIn(t *testing.T) {
|
||||
eligible := func(mode NetworkMode, optIn string) bool {
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
WorkspaceRoots: []string{t.TempDir()},
|
||||
Env: map[string]string{windowsSandboxIdentityEnv: optIn},
|
||||
}
|
||||
config.PermissionProfile.Network.Mode = mode
|
||||
return windowsSandboxPrincipalEligible(config)
|
||||
}
|
||||
|
||||
for _, mode := range []NetworkMode{NetworkDeny, NetworkAllow} {
|
||||
if !eligible(mode, "1") {
|
||||
t.Fatalf("principal backend refused with network %q; the mode should choose a principal, not disable the backend", mode)
|
||||
}
|
||||
if eligible(mode, "0") {
|
||||
t.Fatalf("principal backend eligible without the opt-in for network %q", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The network mode is enforced by WHICH principal runs, because the offline one
|
||||
// is a member of the group the block filters match. Picking the wrong role is
|
||||
// therefore a silent loss of network enforcement, so the mapping is asserted
|
||||
// directly rather than inferred from a token that only exists on a provisioned
|
||||
// machine.
|
||||
func TestPrincipalRoleFollowsNetworkMode(t *testing.T) {
|
||||
if got := windowsSandboxRoleForNetwork(NetworkDeny); got != windowsSandboxRoleOffline {
|
||||
t.Fatalf("deny selected %q, want the offline principal; the online one is not in the blocked group and would have the network", got)
|
||||
}
|
||||
if got := windowsSandboxRoleForNetwork(NetworkAllow); got != windowsSandboxRoleOnline {
|
||||
t.Fatalf("allow selected %q, want the online principal", got)
|
||||
}
|
||||
// Fail closed. An unrecognised mode must lose the network rather than keep it,
|
||||
// so anything that is not an explicit allow maps to the offline principal.
|
||||
for _, mode := range []NetworkMode{NetworkMode(""), NetworkMode("bogus"), NetworkMode("ALLOW")} {
|
||||
if got := windowsSandboxRoleForNetwork(mode); got != windowsSandboxRoleOffline {
|
||||
t.Fatalf("unrecognised mode %q selected %q, want the offline principal so an unknown mode fails closed", mode, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The account key has to separate USERS, not only workspaces.
|
||||
//
|
||||
// The name is machine-wide while the DPAPI secret and the ACL ledger both live
|
||||
// under the invoking user's sandbox home. Keyed on the workspace alone, two
|
||||
// Windows users sharing one path derive the same account with private
|
||||
// bookkeeping: the second finds no ledger of its own and either retires the
|
||||
// first user's working principal or adopts it and rotates the password while
|
||||
// storing only its own secret. The first user's marker still validates and its
|
||||
// next command cannot log on.
|
||||
//
|
||||
// Asserting the principal key DIFFERS from the workspace key is what pins that.
|
||||
// It is the cheapest observable difference; a test that only checked the key was
|
||||
// stable would pass against the broken version.
|
||||
func TestPrincipalKeyIsScopedToTheUserNotOnlyTheWorkspace(t *testing.T) {
|
||||
roots := []string{`C:\ws\shared`}
|
||||
workspace := windowsSandboxWorkspaceKey(roots)
|
||||
config := WindowsSandboxCommandConfig{WorkspaceRoots: roots}
|
||||
principal := windowsSandboxPrincipalKey(config)
|
||||
|
||||
if principal == workspace {
|
||||
t.Fatal("the principal key equals the workspace key, so two users sharing this workspace would derive one account with separate secrets and ledgers")
|
||||
}
|
||||
if principal != windowsSandboxPrincipalKey(config) {
|
||||
t.Error("the principal key is not stable for one user, so a second setup would not find its own account")
|
||||
}
|
||||
if other := windowsSandboxPrincipalKey(WindowsSandboxCommandConfig{WorkspaceRoots: []string{`C:\ws\other`}}); other == principal {
|
||||
t.Error("two workspaces derived the same principal key, so they would share one account")
|
||||
}
|
||||
}
|
||||
|
||||
// The setup lock must stay keyed to the WORKSPACE alone. Two users setting up
|
||||
// one shared workspace now provision separate accounts, but they still write
|
||||
// DACLs on the same paths, so they have to keep serializing against each other.
|
||||
func TestSetupLockStaysWorkspaceScoped(t *testing.T) {
|
||||
roots := []string{`C:\ws\shared`}
|
||||
if windowsSandboxWorkspaceKey(roots) == windowsSandboxPrincipalKey(WindowsSandboxCommandConfig{WorkspaceRoots: roots}) {
|
||||
t.Fatal("the lock key and the principal key are the same, so making the account per-user also made the lock per-user")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// createWindowsSecretFileNoFollow creates (or replaces) the secret file as a
|
||||
// CHILD of a pinned parent handle and returns a handle to it.
|
||||
//
|
||||
// The pathname version this replaces resolved the path four separate times in an
|
||||
// elevated process: MkdirAll, an O_TRUNC open, SetNamedSecurityInfo by name, then
|
||||
// WriteFile by name. The sandbox home belongs to the invoking user, who is the
|
||||
// party this sandbox exists to contain, so each of those resolutions was a place
|
||||
// to swap a component. Worst case an attacker-chosen file gets truncated and then
|
||||
// has its DACL rewritten by an Administrator; the milder junction-only case still
|
||||
// plants the deterministic secret somewhere the caller then owns.
|
||||
//
|
||||
// One handle, one resolution. The parent is opened no-follow, the leaf is created
|
||||
// relative to that handle so its name can never be resolved again, and both the
|
||||
// DACL and the bytes are applied to the handle rather than to a path.
|
||||
//
|
||||
// FILE_OVERWRITE_IF, not FILE_CREATE: an existing secret must be replaced, since
|
||||
// a stale password makes LogonUser fail in a way that reads as a sandbox bug.
|
||||
// FILE_OPEN_REPARSE_POINT means a leaf that IS a reparse point comes back as
|
||||
// itself rather than being followed, and the attribute check below then refuses
|
||||
// it instead of writing through it.
|
||||
func createWindowsSecretFileNoFollow(path string) (windows.Handle, error) {
|
||||
parentPath := filepath.Dir(path)
|
||||
name := filepath.Base(path)
|
||||
if err := validateWindowsACLComponent(name); err != nil {
|
||||
return 0, fmt.Errorf("secret file name: %w", err)
|
||||
}
|
||||
parent, err := openWindowsACLDirectoryNoFollow(parentPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open secret directory %s: %w", parentPath, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode secret file name %s: %w", name, err)
|
||||
}
|
||||
attributes := windows.OBJECT_ATTRIBUTES{
|
||||
RootDirectory: parent,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE,
|
||||
}
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
|
||||
var handle windows.Handle
|
||||
var status windows.IO_STATUS_BLOCK
|
||||
if err := windows.NtCreateFile(
|
||||
&handle,
|
||||
// WRITE_DAC to lock it down and FILE_WRITE_DATA to fill it, both through
|
||||
// this one handle. READ_CONTROL because SetSecurityInfo reads the existing
|
||||
// descriptor before replacing it, and FILE_READ_ATTRIBUTES because the
|
||||
// reparse-point check below asks the handle what it landed on.
|
||||
windows.FILE_WRITE_DATA|windows.FILE_READ_ATTRIBUTES|windows.WRITE_DAC|windows.READ_CONTROL|windows.SYNCHRONIZE,
|
||||
&attributes,
|
||||
&status,
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_NORMAL,
|
||||
windows.FILE_SHARE_READ,
|
||||
windows.FILE_OVERWRITE_IF,
|
||||
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT,
|
||||
0,
|
||||
0,
|
||||
); err != nil {
|
||||
return 0, fmt.Errorf("create secret file %s: %w", path, err)
|
||||
}
|
||||
if err := rejectWindowsACLReparseHandle(handle, name); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, err
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
// lockWindowsSecretHandleToOwner applies the owner-and-SYSTEM-only DACL to an
|
||||
// open handle instead of to a pathname, so the object being locked is provably
|
||||
// the object that was just created rather than whatever the name resolves to now.
|
||||
func lockWindowsSecretHandleToOwner(handle windows.Handle, owner *windows.SID) error {
|
||||
acl, err := windowsSecretOwnerACL(owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := windows.SetSecurityInfo(
|
||||
handle,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil,
|
||||
nil,
|
||||
acl,
|
||||
nil,
|
||||
); err != nil {
|
||||
return fmt.Errorf("lock secret to owner: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeWindowsSecretHandle writes the whole payload through the handle.
|
||||
func writeWindowsSecretHandle(handle windows.Handle, payload []byte) error {
|
||||
for written := 0; written < len(payload); {
|
||||
var n uint32
|
||||
if err := windows.WriteFile(handle, payload[written:], &n, nil); err != nil {
|
||||
return fmt.Errorf("write secret: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("write secret: wrote no bytes with %d remaining", len(payload)-written)
|
||||
}
|
||||
written += int(n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE SECRET MUST NOT BE WRITTEN THROUGH A REPARSE POINT.
|
||||
//
|
||||
// The sandbox home belongs to the invoking user, who is the party this sandbox
|
||||
// exists to contain, so they can put a junction where the secret directory is
|
||||
// expected before elevated setup runs. The pathname version resolved the path
|
||||
// four times over (MkdirAll, an O_TRUNC open, SetNamedSecurityInfo by name, then
|
||||
// WriteFile by name) and every one of them followed it, so an Administrator
|
||||
// process created the principal's password in a directory of the caller's
|
||||
// choosing and then granted them control of it.
|
||||
//
|
||||
// A junction rather than a symlink on purpose: it needs no privilege, so it is
|
||||
// reachable by exactly the unprivileged user this guards against. os.Symlink
|
||||
// would need SeCreateSymbolicLinkPrivilege and would skip on an ordinary
|
||||
// developer or CI account, which is where this most needs to run.
|
||||
func TestWriteWindowsSandboxSecretRefusesAJunctionedDirectory(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
outside := filepath.Join(base, "outside")
|
||||
if err := os.MkdirAll(outside, 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
// The swap: the directory the secret is addressed into is a junction that
|
||||
// leads out of the sandbox home. The pathname never changes.
|
||||
cfg := filepath.Join(base, "cfg")
|
||||
makeJunction(t, cfg, outside)
|
||||
|
||||
path := filepath.Join(cfg, "zero-sbx-test.secret")
|
||||
err := writeWindowsSandboxSecret(path, "Zs1!EXAMPLEPASSWORDVALUE")
|
||||
if err == nil {
|
||||
t.Fatal("wrote the principal secret through a junction, so an elevated setup would have placed it in a directory the caller controls")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reparse") {
|
||||
t.Fatalf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
// Refusing is only half of it. Nothing may survive on the other side, or the
|
||||
// caller still ends up holding a file the sandbox created for them.
|
||||
entries, readErr := os.ReadDir(outside)
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadDir: %v", readErr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("junction target holds %d entries after the refusal, want none", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary path must still work, or the refusal above proves only that the
|
||||
// function fails.
|
||||
func TestWriteWindowsSandboxSecretStillWritesAnOrdinaryDirectory(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cfg", "zero-sbx-test.secret")
|
||||
const password = "Zs1!EXAMPLEPASSWORDVALUE"
|
||||
if err := writeWindowsSandboxSecret(path, password); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
got, err := readWindowsSandboxSecret(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if got != password {
|
||||
t.Fatalf("read %q, want the stored password", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
// Storing a sandbox principal's password.
|
||||
//
|
||||
// The elevated setup path provisions the account, but the per-command path runs
|
||||
// UNELEVATED and needs the password to call LogonUser. So the secret has to
|
||||
// cross that boundary on disk, and the only thing standing between it and the
|
||||
// sandboxed child is the file's ACL.
|
||||
//
|
||||
// The file is locked to the invoking user: an explicit, INHERITANCE-PROTECTED
|
||||
// DACL granting that user and SYSTEM, and nobody else. The sandbox principal is
|
||||
// deliberately absent from it, which is the property that matters, because a
|
||||
// principal that could read this file could mint its own token and the whole
|
||||
// identity boundary would be decorative. Administrators are not added either;
|
||||
// an admin can already take ownership, so naming them buys nothing and widens
|
||||
// the visible grant.
|
||||
//
|
||||
// Ordering is load-bearing: the ACL is applied to an EMPTY file before the
|
||||
// password is written, so the bytes never exist under the directory's inherited
|
||||
// permissions even briefly.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// windowsSandboxSecretDirName holds per-principal secrets under the Zero config
|
||||
// directory. Kept in its own directory so the whole set can be removed when the
|
||||
// sandbox is torn down.
|
||||
const windowsSandboxSecretDirName = "windows-sandbox"
|
||||
|
||||
// windowsSandboxSecretPath returns where a principal's password lives. The
|
||||
// account name is already sanitised to [a-z0-9-] by windowsSandboxUserName, so
|
||||
// it cannot escape the directory.
|
||||
func windowsSandboxSecretPath(configDir string, username string) (string, error) {
|
||||
if strings.TrimSpace(configDir) == "" {
|
||||
return "", errors.New("windows sandbox secret: empty config directory")
|
||||
}
|
||||
if strings.TrimSpace(username) == "" {
|
||||
return "", errors.New("windows sandbox secret: empty principal name")
|
||||
}
|
||||
// Defence in depth against a caller passing something windowsSandboxUserName
|
||||
// did not produce: refuse anything with a separator or a parent reference.
|
||||
if strings.ContainsAny(username, `\/:`) || strings.Contains(username, "..") {
|
||||
return "", fmt.Errorf("windows sandbox secret: unsafe principal name %q", username)
|
||||
}
|
||||
return filepath.Join(configDir, windowsSandboxSecretDirName, username+".secret"), nil
|
||||
}
|
||||
|
||||
// currentTokenUserSID returns the SID of the user this process runs as. Under
|
||||
// UAC the elevated token keeps the same user SID as the desktop session, so
|
||||
// setup and the later unelevated command path agree on the owner, which is what
|
||||
// makes an owner-scoped ACL usable across the elevation boundary.
|
||||
func currentTokenUserSID() (*windows.SID, error) {
|
||||
var token windows.Token
|
||||
if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil {
|
||||
return nil, fmt.Errorf("open process token: %w", err)
|
||||
}
|
||||
defer token.Close()
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get token user: %w", err)
|
||||
}
|
||||
// The SID points into a buffer owned by the Tokenuser, so copy it out before
|
||||
// that buffer goes away.
|
||||
copied, err := user.User.Sid.Copy()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copy token user SID: %w", err)
|
||||
}
|
||||
return copied, nil
|
||||
}
|
||||
|
||||
// windowsSecretOwnerACL builds the explicit, inheritance-protected DACL granting
|
||||
// only owner and SYSTEM. PROTECTED is what drops any ACE inherited from the
|
||||
// config directory; without it a permissive parent would still grant access to
|
||||
// whoever it names.
|
||||
//
|
||||
// Returns the ACL rather than applying it, because the caller applies it to an
|
||||
// open HANDLE. Applying by pathname meant the object being locked was whatever
|
||||
// the name resolved to at that instant, which is not necessarily the object that
|
||||
// was just created.
|
||||
func windowsSecretOwnerACL(owner *windows.SID) (*windows.ACL, error) {
|
||||
if owner == nil {
|
||||
return nil, errors.New("windows sandbox secret: nil owner SID")
|
||||
}
|
||||
system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve SYSTEM SID: %w", err)
|
||||
}
|
||||
entries := []windows.EXPLICIT_ACCESS{
|
||||
{
|
||||
AccessPermissions: windows.GENERIC_ALL,
|
||||
AccessMode: windows.GRANT_ACCESS,
|
||||
Inheritance: windows.NO_INHERITANCE,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_USER,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(owner),
|
||||
},
|
||||
},
|
||||
{
|
||||
AccessPermissions: windows.GENERIC_ALL,
|
||||
AccessMode: windows.GRANT_ACCESS,
|
||||
Inheritance: windows.NO_INHERITANCE,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_USER,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(system),
|
||||
},
|
||||
},
|
||||
}
|
||||
acl, err := windows.ACLFromEntries(entries, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build secret ACL: %w", err)
|
||||
}
|
||||
return acl, nil
|
||||
}
|
||||
|
||||
// writeWindowsSandboxSecret stores a principal's password readable only by the
|
||||
// invoking user.
|
||||
//
|
||||
// The file is created empty, locked down, and only then written, so the secret
|
||||
// is never on disk under the directory's inherited ACL. An existing file is
|
||||
// replaced rather than appended, since a stale password would make LogonUser
|
||||
// fail in a way that looks like a sandbox bug.
|
||||
func writeWindowsSandboxSecret(path string, password string) error {
|
||||
owner, err := currentTokenUserSID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("create secret directory: %w", err)
|
||||
}
|
||||
// Sealed BEFORE the file exists, so a failure here leaves nothing on disk at
|
||||
// all rather than an empty file for someone else to win a race on.
|
||||
//
|
||||
// Encrypted to the invoking user on top of the ACL, so a copy taken outside
|
||||
// the filesystem's enforcement (backup, disk image) is inert. The principal
|
||||
// name is the entropy, which keeps one principal's blob from authenticating
|
||||
// another.
|
||||
sealed, err := protectWindowsSecret(password, windowsSandboxSecretEntropy(path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// ONE resolution of the path, not four. The leaf is created relative to a
|
||||
// pinned no-follow parent handle, and the DACL and the bytes are both applied
|
||||
// to that handle. Resolving the name again between those steps is what let a
|
||||
// junction or symlink swap point an elevated write at a file of the caller's
|
||||
// choosing.
|
||||
handle, err := createWindowsSecretFileNoFollow(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
if err := lockWindowsSecretHandleToOwner(handle, owner); err != nil {
|
||||
// Do not leave an unprotected file behind. Removal is by name, which is
|
||||
// safe in a way the write was not: worst case a swapped name means the
|
||||
// cleanup misses and leaves a locked-down file, never that it deletes
|
||||
// something it did not create.
|
||||
_ = os.Remove(path)
|
||||
return err
|
||||
}
|
||||
if err := writeWindowsSecretHandle(handle, sealed); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// windowsSandboxSecretEntropy derives the DPAPI entropy from the secret's own
|
||||
// filename, which is the principal name. Deriving it rather than threading the
|
||||
// name through keeps read and write agreeing by construction.
|
||||
func windowsSandboxSecretEntropy(path string) string {
|
||||
return strings.TrimSuffix(filepath.Base(path), ".secret")
|
||||
}
|
||||
|
||||
// Seamed so the permission-denied mapping in readWindowsSandboxSecret is
|
||||
// testable. Producing a real ERROR_ACCESS_DENIED needs DACL surgery on Windows,
|
||||
// since a 0000 file is still readable and reading a directory reports
|
||||
// "Incorrect function", so a test built that way would exercise the platform
|
||||
// rather than the mapping.
|
||||
var readWindowsSandboxSecretFile = os.ReadFile
|
||||
|
||||
// readWindowsSandboxSecret loads a principal's password. A missing file means
|
||||
// setup has not run for this workspace, which the caller turns into a fallback
|
||||
// rather than a hard failure.
|
||||
func readWindowsSandboxSecret(path string) (string, error) {
|
||||
data, err := readWindowsSandboxSecretFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", errWindowsSandboxIdentityUnavailable
|
||||
}
|
||||
// Permission denied is unavailability, not breakage. The secret's DACL
|
||||
// names whoever ran setup, so an operator who elevated with a separate
|
||||
// administrative account, through runas or an over-the-shoulder UAC
|
||||
// prompt, ends up with a secret their ordinary account cannot open. That
|
||||
// is the documented fail-soft case: fall back to the restricted token and
|
||||
// let the warning say so. Treating it as a hard error instead made every
|
||||
// sandboxed command fail on a machine that was merely set up by a
|
||||
// different admin, which is a common way to run an elevated setup.
|
||||
if os.IsPermission(err) {
|
||||
return "", errWindowsSandboxIdentityUnavailable
|
||||
}
|
||||
return "", fmt.Errorf("read sandbox secret: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return "", errWindowsSandboxIdentityUnavailable
|
||||
}
|
||||
secret, err := unprotectWindowsSecret(data, windowsSandboxSecretEntropy(path))
|
||||
if err != nil {
|
||||
// A blob written by another user, for another principal, or by an older
|
||||
// build that stored the password in the clear. Report it as unavailable so
|
||||
// the caller falls back to the restricted token; the next elevated setup
|
||||
// rewrites the secret in the current format.
|
||||
return "", errWindowsSandboxIdentityUnavailable
|
||||
}
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return "", errWindowsSandboxIdentityUnavailable
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// removeWindowsSandboxSecret deletes a stored password. Called before the
|
||||
// account itself is removed so a secret never outlives the principal it
|
||||
// authenticates.
|
||||
// errWindowsSandboxSecretNotOurs reports a secret this account cannot delete
|
||||
// because another administrator's setup owns its DACL.
|
||||
//
|
||||
// Distinguishable so teardown can carry on. The alternative is what it used to
|
||||
// do: abort before removing the account, its logon rights, its ACEs and its
|
||||
// ledger, leaving a fully provisioned principal on the machine because one file
|
||||
// could not be unlinked.
|
||||
var errWindowsSandboxSecretNotOurs = errors.New("the sandbox secret belongs to a different administrator's setup")
|
||||
|
||||
func removeWindowsSandboxSecret(path string) error {
|
||||
err := os.Remove(path)
|
||||
if err == nil || os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
// The read path already treats permission-denied as unavailability rather
|
||||
// than breakage, for the reason documented on readWindowsSandboxSecret: an
|
||||
// operator who elevated with a separate administrative account gets a secret
|
||||
// their ordinary account cannot open. Removal has to agree. It did not, so on
|
||||
// exactly those machines teardown stopped at the first file and stranded
|
||||
// everything after it.
|
||||
if os.IsPermission(err) {
|
||||
return fmt.Errorf("%w: %s", errWindowsSandboxSecretNotOurs, path)
|
||||
}
|
||||
return fmt.Errorf("remove sandbox secret: %w", err)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestWindowsSandboxSecretRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cfg", "zero-sbx-test.secret")
|
||||
const password = "Zs1!EXAMPLEPASSWORDVALUE"
|
||||
|
||||
if err := writeWindowsSandboxSecret(path, password); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
got, err := readWindowsSandboxSecret(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if got != password {
|
||||
t.Fatalf("read %q, want the stored password", got)
|
||||
}
|
||||
}
|
||||
|
||||
// THE security property: the stored password must be readable only by the user
|
||||
// who owns it. If any other trustee appears in the DACL, and in particular the
|
||||
// sandbox principal, that account could mint its own token and the identity
|
||||
// boundary would be worthless.
|
||||
func TestWindowsSandboxSecretIsLockedToOwner(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "locked.secret")
|
||||
if err := writeWindowsSandboxSecret(path, "Zs1!SECRET"); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
descriptor, err := windows.GetNamedSecurityInfo(
|
||||
path,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("read back security info: %v", err)
|
||||
}
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
t.Fatalf("read DACL: %v", err)
|
||||
}
|
||||
if dacl == nil {
|
||||
t.Fatal("secret has a nil DACL, which grants everyone access")
|
||||
}
|
||||
|
||||
owner, err := currentTokenUserSID()
|
||||
if err != nil {
|
||||
t.Fatalf("owner SID: %v", err)
|
||||
}
|
||||
system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
t.Fatalf("SYSTEM SID: %v", err)
|
||||
}
|
||||
|
||||
entries, err := windowsSecretACEList(dacl)
|
||||
if err != nil {
|
||||
t.Fatalf("enumerate ACEs: %v", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Fatal("secret DACL has no ACEs")
|
||||
}
|
||||
for _, sid := range entries {
|
||||
if sid.Equals(owner) || sid.Equals(system) {
|
||||
continue
|
||||
}
|
||||
t.Fatalf("secret DACL grants an unexpected trustee %s; only the owner and SYSTEM may appear", sid)
|
||||
}
|
||||
}
|
||||
|
||||
// The DACL must be inheritance-protected, otherwise a permissive ACE on the
|
||||
// config directory would still reach the secret.
|
||||
func TestWindowsSandboxSecretDaclIsProtected(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "protected.secret")
|
||||
if err := writeWindowsSandboxSecret(path, "Zs1!SECRET"); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Fatalf("read back security info: %v", err)
|
||||
}
|
||||
control, _, err := descriptor.Control()
|
||||
if err != nil {
|
||||
t.Fatalf("read control bits: %v", err)
|
||||
}
|
||||
if control&windows.SE_DACL_PROTECTED == 0 {
|
||||
t.Fatal("secret DACL is not protected, so inherited ACEs still apply")
|
||||
}
|
||||
}
|
||||
|
||||
// Rewriting must replace the previous secret rather than append to it, or
|
||||
// LogonUser would be handed two concatenated passwords.
|
||||
func TestWindowsSandboxSecretOverwrites(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rewrite.secret")
|
||||
if err := writeWindowsSandboxSecret(path, "Zs1!FIRST"); err != nil {
|
||||
t.Fatalf("first write: %v", err)
|
||||
}
|
||||
if err := writeWindowsSandboxSecret(path, "Zs1!SECOND"); err != nil {
|
||||
t.Fatalf("second write: %v", err)
|
||||
}
|
||||
got, err := readWindowsSandboxSecret(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if got != "Zs1!SECOND" {
|
||||
t.Fatalf("read %q, want only the newest password", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace whose setup has not run must report the actionable sentinel so the
|
||||
// command path falls back to the restricted-token backend instead of failing.
|
||||
func TestWindowsSandboxSecretMissingIsSentinel(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "absent.secret")
|
||||
if _, err := readWindowsSandboxSecret(path); err != errWindowsSandboxIdentityUnavailable {
|
||||
t.Fatalf("missing secret returned %v, want errWindowsSandboxIdentityUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty file is a half-written secret, not a valid empty password.
|
||||
//
|
||||
// Both seeds matter and only one of them tests what the name says. A
|
||||
// zero-length file is the truncated-write case, and it is the only one that
|
||||
// reaches the length check. Whitespace is several bytes, so it travels on to
|
||||
// DPAPI and fails to unprotect instead, which is the path
|
||||
// TestWindowsSandboxSecretRejectsLegacyPlaintext already covers. Seeding only
|
||||
// the whitespace, as this did, left the branch in the test's own name
|
||||
// unexercised.
|
||||
func TestWindowsSandboxSecretEmptyIsSentinel(t *testing.T) {
|
||||
for name, seed := range map[string][]byte{
|
||||
"truncated write": {},
|
||||
"whitespace only": []byte(" \r\n"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "empty.secret")
|
||||
if err := os.WriteFile(path, seed, 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if _, err := readWindowsSandboxSecret(path); err != errWindowsSandboxIdentityUnavailable {
|
||||
t.Fatalf("secret of %d bytes returned %v, want the unavailable sentinel", len(seed), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The principal name lands in a filename, so anything that could escape the
|
||||
// directory has to be refused even though windowsSandboxUserName already
|
||||
// sanitises its output.
|
||||
func TestWindowsSandboxSecretPathRejectsTraversal(t *testing.T) {
|
||||
for _, name := range []string{`..\evil`, "sub/dir", `C:\abs`, "..", ""} {
|
||||
if _, err := windowsSandboxSecretPath(`C:\cfg`, name); err == nil {
|
||||
t.Fatalf("principal name %q was accepted", name)
|
||||
}
|
||||
}
|
||||
if _, err := windowsSandboxSecretPath("", "zero-sbx-a"); err == nil {
|
||||
t.Fatal("empty config directory was accepted")
|
||||
}
|
||||
path, err := windowsSandboxSecretPath(`C:\cfg`, "zero-sbx-abc")
|
||||
if err != nil {
|
||||
t.Fatalf("valid name rejected: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(path, `zero-sbx-abc.secret`) {
|
||||
t.Fatalf("unexpected secret path %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Removal must be idempotent so teardown converges the same way provisioning
|
||||
// does, and must actually delete the secret.
|
||||
func TestWindowsSandboxSecretRemoveIsIdempotent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "gone.secret")
|
||||
if err := writeWindowsSandboxSecret(path, "Zs1!SECRET"); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := removeWindowsSandboxSecret(path); err != nil {
|
||||
t.Fatalf("first remove: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("secret still present after removal (stat err %v)", err)
|
||||
}
|
||||
if err := removeWindowsSandboxSecret(path); err != nil {
|
||||
t.Fatalf("removing an absent secret must succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// windowsSecretACEList returns the trustee SID of every ACE in a DACL so a test
|
||||
// can assert exactly who is named.
|
||||
func windowsSecretACEList(dacl *windows.ACL) ([]*windows.SID, error) {
|
||||
var out []*windows.SID
|
||||
for index := uint32(0); index < uint32(dacl.AceCount); index++ {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(dacl, index, &ace); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// GetAce hands back a generic ACE_HEADER and we reinterpret it. That is
|
||||
// only sound for the fixed-layout types: an object ACE carries Flags and
|
||||
// two GUIDs ahead of the trustee, so SidStart would land mid-structure
|
||||
// and Copy would read whatever bytes happen to be there. The caller's
|
||||
// "unexpected trustee" assertion would then print a nonsense SID instead
|
||||
// of naming the ACE that does not belong, which is the opposite of what
|
||||
// a failing test should do. Nothing under test builds anything but
|
||||
// allowed ACEs today, so this exists to keep the failure legible if that
|
||||
// ever changes.
|
||||
if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE {
|
||||
return nil, fmt.Errorf("ACE %d has type %d, want ACCESS_ALLOWED_ACE_TYPE (%d); refusing to decode its trustee", index, ace.Header.AceType, windows.ACCESS_ALLOWED_ACE_TYPE)
|
||||
}
|
||||
sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
|
||||
copied, err := sid.Copy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, copied)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DPAPI round-trip through the real store, which needs no privilege and so is
|
||||
// genuine coverage rather than a gated stub.
|
||||
func TestWindowsSandboxSecretRoundTripsThroughDPAPI(t *testing.T) {
|
||||
path, err := windowsSandboxSecretPath(t.TempDir(), "zero-sbx-roundtrip")
|
||||
if err != nil {
|
||||
t.Fatalf("secret path: %v", err)
|
||||
}
|
||||
const password = "S0me-Sandbox-P@ssw0rd-value"
|
||||
if err := writeWindowsSandboxSecret(path, password); err != nil {
|
||||
t.Fatalf("write secret: %v", err)
|
||||
}
|
||||
got, err := readWindowsSandboxSecret(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read secret: %v", err)
|
||||
}
|
||||
if got != password {
|
||||
t.Fatalf("round-trip returned %q, want %q", got, password)
|
||||
}
|
||||
// The point of the exercise: the password must not be recoverable by reading
|
||||
// the file, or the encryption layer is decorative.
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read raw secret file: %v", err)
|
||||
}
|
||||
if bytes.Contains(raw, []byte(password)) {
|
||||
t.Fatal("the password appears verbatim in the stored file; it was not encrypted")
|
||||
}
|
||||
}
|
||||
|
||||
// Entropy is the principal name, so a blob moved onto another principal's secret
|
||||
// path must fail to decrypt rather than authenticate the wrong account.
|
||||
func TestWindowsSandboxSecretDoesNotTransferBetweenPrincipals(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
minePath, err := windowsSandboxSecretPath(home, "zero-sbx-mine")
|
||||
if err != nil {
|
||||
t.Fatalf("secret path: %v", err)
|
||||
}
|
||||
theirsPath, err := windowsSandboxSecretPath(home, "zero-sbx-theirs")
|
||||
if err != nil {
|
||||
t.Fatalf("secret path: %v", err)
|
||||
}
|
||||
if err := writeWindowsSandboxSecret(minePath, "a-password-for-mine"); err != nil {
|
||||
t.Fatalf("write secret: %v", err)
|
||||
}
|
||||
blob, err := os.ReadFile(minePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read blob: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(theirsPath, blob, 0o600); err != nil {
|
||||
t.Fatalf("plant blob: %v", err)
|
||||
}
|
||||
if _, err := readWindowsSandboxSecret(theirsPath); !errors.Is(err, errWindowsSandboxIdentityUnavailable) {
|
||||
t.Fatalf("a blob planted at another principal's path decrypted; got err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An older plaintext secret must degrade to a fallback rather than being handed
|
||||
// to LogonUser as if it were a password.
|
||||
func TestWindowsSandboxSecretRejectsLegacyPlaintext(t *testing.T) {
|
||||
path, err := windowsSandboxSecretPath(t.TempDir(), "zero-sbx-legacy")
|
||||
if err != nil {
|
||||
t.Fatalf("secret path: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("plaintext-password"), 0o600); err != nil {
|
||||
t.Fatalf("write legacy secret: %v", err)
|
||||
}
|
||||
if _, err := readWindowsSandboxSecret(path); !errors.Is(err, errWindowsSandboxIdentityUnavailable) {
|
||||
t.Fatalf("legacy plaintext secret was accepted; got err = %v", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,498 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// A local Windows account name is capped at 20 characters, so the derived name
|
||||
// must truncate rather than produce a name NetUserAdd rejects.
|
||||
func TestWindowsSandboxUserNameRespectsLengthLimit(t *testing.T) {
|
||||
name := windowsSandboxUserName(strings.Repeat("a", 64), windowsSandboxRoleOffline)
|
||||
if len(name) > windowsSandboxUserNameMax {
|
||||
t.Fatalf("name %q is %d chars, want at most %d", name, len(name), windowsSandboxUserNameMax)
|
||||
}
|
||||
if !strings.HasPrefix(name, windowsSandboxUserPrefix) {
|
||||
t.Fatalf("name %q lost the managed prefix", name)
|
||||
}
|
||||
}
|
||||
|
||||
// The same workspace must map to the same principal, otherwise re-running setup
|
||||
// would accumulate a new local account every time.
|
||||
func TestWindowsSandboxUserNameIsStable(t *testing.T) {
|
||||
first := windowsSandboxUserName("abc123", windowsSandboxRoleOffline)
|
||||
second := windowsSandboxUserName("abc123", windowsSandboxRoleOffline)
|
||||
if first != second {
|
||||
t.Fatalf("name is not stable: %q vs %q", first, second)
|
||||
}
|
||||
if other := windowsSandboxUserName("def456", windowsSandboxRoleOffline); other == first {
|
||||
t.Fatalf("different workspaces produced the same principal %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
// The key is sanitised to characters a local account name accepts, so a hash or
|
||||
// path fragment cannot smuggle a separator or a space into the name.
|
||||
func TestWindowsSandboxUserNameRejectsUnsafeCharacters(t *testing.T) {
|
||||
name := windowsSandboxUserName(`C:\Users\me\proj ect`, windowsSandboxRoleOffline)
|
||||
for _, r := range strings.TrimPrefix(name, windowsSandboxUserPrefix) {
|
||||
isLower := r >= 'a' && r <= 'z'
|
||||
isDigit := r >= '0' && r <= '9'
|
||||
if !isLower && !isDigit {
|
||||
t.Fatalf("name %q contains unsafe rune %q", name, r)
|
||||
}
|
||||
}
|
||||
if name == windowsSandboxUserPrefix {
|
||||
t.Fatal("sanitising removed every character, leaving a bare prefix")
|
||||
}
|
||||
}
|
||||
|
||||
// An empty or fully-sanitised-away key must still yield a usable name rather
|
||||
// than the bare prefix.
|
||||
func TestWindowsSandboxUserNameHandlesEmptyKey(t *testing.T) {
|
||||
for _, key := range []string{"", "///", " "} {
|
||||
if got := windowsSandboxUserName(key, windowsSandboxRoleOffline); got == windowsSandboxUserPrefix {
|
||||
t.Fatalf("key %q produced a bare prefix", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The password must be fresh per call and carry the character classes a default
|
||||
// Windows complexity policy demands, or NetUserAdd fails with ERROR_PASSWORD_RESTRICTION.
|
||||
func TestNewWindowsSandboxPasswordIsRandomAndComplex(t *testing.T) {
|
||||
first, err := newWindowsSandboxPassword()
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
second, err := newWindowsSandboxPassword()
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if first == second {
|
||||
t.Fatal("two generated passwords are identical, so they are not random")
|
||||
}
|
||||
if len(first) < 12 {
|
||||
t.Fatalf("password is only %d chars", len(first))
|
||||
}
|
||||
var hasUpper, hasLower, hasDigit bool
|
||||
for _, r := range first {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
hasUpper = true
|
||||
case r >= 'a' && r <= 'z':
|
||||
hasLower = true
|
||||
case r >= '0' && r <= '9':
|
||||
hasDigit = true
|
||||
}
|
||||
}
|
||||
if !hasUpper || !hasLower || !hasDigit {
|
||||
t.Fatalf("password %q lacks a required character class", first)
|
||||
}
|
||||
}
|
||||
|
||||
// "Already exists" is the normal result of re-running setup and must not surface
|
||||
// as an error, while a genuine failure must.
|
||||
func TestNetAPIStatusTreatsExistingAsSuccess(t *testing.T) {
|
||||
if err := netAPIStatus("NetUserAdd", nerrSuccess); err != nil {
|
||||
t.Fatalf("success status returned %v", err)
|
||||
}
|
||||
if err := netAPIStatus("NetUserAdd", nerrUserExists, nerrUserExists); err != nil {
|
||||
t.Fatalf("existing user must be success, got %v", err)
|
||||
}
|
||||
if err := netAPIStatus("NetLocalGroupAdd", nerrGroupExists, nerrGroupExists, errorAliasExists); err != nil {
|
||||
t.Fatalf("existing group must be success, got %v", err)
|
||||
}
|
||||
if err := netAPIStatus("NetUserAdd", 2245); err == nil {
|
||||
t.Fatal("an unexpected status must surface as an error")
|
||||
}
|
||||
}
|
||||
|
||||
// Access-denied is the status an unelevated run gets, and it must say so rather
|
||||
// than reporting a bare number the user cannot act on.
|
||||
func TestNetAPIStatusExplainsAccessDenied(t *testing.T) {
|
||||
err := netAPIStatus("NetUserAdd", errorAccessDenied32)
|
||||
if err == nil {
|
||||
t.Fatal("access denied must be an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "elevated") {
|
||||
t.Fatalf("error %q should point at elevation", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The Win32 structs are passed to netapi32 as raw buffers, so their layout must
|
||||
// match what the API expects. A wrong size means silent memory corruption.
|
||||
func TestWindowsIdentityStructLayouts(t *testing.T) {
|
||||
ptr := unsafe.Sizeof(uintptr(0))
|
||||
if got, want := unsafe.Sizeof(localGroupMembersInfo3{}), ptr; got != want {
|
||||
t.Fatalf("LOCALGROUP_MEMBERS_INFO_3 size = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := unsafe.Sizeof(localGroupInfo1{}), 2*ptr; got != want {
|
||||
t.Fatalf("LOCALGROUP_INFO_1 size = %d, want %d", got, want)
|
||||
}
|
||||
// USER_INFO_1 is four pointers plus three DWORDs, with the compiler padding
|
||||
// each DWORD pair up to pointer alignment on amd64.
|
||||
if got := unsafe.Sizeof(userInfo1{}); got < 4*ptr {
|
||||
t.Fatalf("USER_INFO_1 size = %d, smaller than its four pointer fields", got)
|
||||
}
|
||||
if unsafe.Offsetof(userInfo1{}.Password) != ptr {
|
||||
t.Fatal("USER_INFO_1.Password must directly follow Name")
|
||||
}
|
||||
}
|
||||
|
||||
// LSA_UNICODE_STRING counts BYTES, not runes, and excludes the NUL terminator
|
||||
// from Length while including it in MaximumLength. Getting either wrong makes
|
||||
// LsaAddAccountRights read past the buffer or silently match no right, so pin it.
|
||||
func TestNewLSAStringUsesByteLengths(t *testing.T) {
|
||||
buffer, err := windows.UTF16FromString("SeBatchLogonRight")
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
entry := newLSAString(buffer)
|
||||
const runes uint16 = uint16(len("SeBatchLogonRight"))
|
||||
if entry.Length != runes*2 {
|
||||
t.Fatalf("Length = %d, want %d (bytes, excluding NUL)", entry.Length, runes*2)
|
||||
}
|
||||
if entry.MaximumLength != (runes+1)*2 {
|
||||
t.Fatalf("MaximumLength = %d, want %d (bytes, including NUL)", entry.MaximumLength, (runes+1)*2)
|
||||
}
|
||||
if entry.Buffer == nil {
|
||||
t.Fatal("Buffer must point at the encoded string")
|
||||
}
|
||||
}
|
||||
|
||||
// An empty buffer must not produce a struct pointing at nothing with a nonzero
|
||||
// length, which would hand LSA a wild pointer.
|
||||
func TestNewLSAStringHandlesEmptyBuffer(t *testing.T) {
|
||||
entry := newLSAString(nil)
|
||||
if entry.Buffer != nil || entry.Length != 0 || entry.MaximumLength != 0 {
|
||||
t.Fatalf("empty buffer produced %+v, want a zero value", entry)
|
||||
}
|
||||
}
|
||||
|
||||
// The LSA structs are passed to advapi32 as raw buffers, so their sizes must
|
||||
// match the Win32 definitions.
|
||||
func TestLSAStructLayouts(t *testing.T) {
|
||||
ptr := unsafe.Sizeof(uintptr(0))
|
||||
// LSA_UNICODE_STRING: two uint16 then a pointer, padded to pointer alignment.
|
||||
if got, want := unsafe.Sizeof(lsaUnicodeString{}), 2*ptr; got != want {
|
||||
t.Fatalf("LSA_UNICODE_STRING size = %d, want %d", got, want)
|
||||
}
|
||||
if unsafe.Offsetof(lsaUnicodeString{}.Buffer) != ptr {
|
||||
t.Fatal("LSA_UNICODE_STRING.Buffer must sit at the second pointer slot")
|
||||
}
|
||||
var attributes lsaObjectAttributes
|
||||
if unsafe.Sizeof(attributes) < 6*ptr-ptr {
|
||||
t.Fatalf("LSA_OBJECT_ATTRIBUTES size = %d, smaller than its fields", unsafe.Sizeof(attributes))
|
||||
}
|
||||
if unsafe.Offsetof(attributes.ObjectName) == 0 {
|
||||
t.Fatal("LSA_OBJECT_ATTRIBUTES.ObjectName must not alias Length")
|
||||
}
|
||||
}
|
||||
|
||||
// Provisioning creates real local accounts, so it only runs when explicitly
|
||||
// opted into on an elevated machine. Everything above covers the logic that can
|
||||
// be exercised without touching the account database.
|
||||
func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) {
|
||||
if os.Getenv("ZERO_WINDOWS_IDENTITY_PROVISION_TEST") != "1" {
|
||||
// Spelled out per shell because `set VAR=1` is cmd syntax and silently
|
||||
// sets a shell variable rather than an environment variable in
|
||||
// PowerShell, which makes this skip look like the elevation check failing.
|
||||
t.Skip("provisioning test not enabled: PowerShell `$env:ZERO_WINDOWS_IDENTITY_PROVISION_TEST = \"1\"`, cmd `set ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1`, bash `export ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1` (also needs an elevated terminal)")
|
||||
}
|
||||
if !windowsProcessIsElevated() {
|
||||
t.Skip("provisioning requires an elevated process")
|
||||
}
|
||||
// Starting clean keeps a failure here from being explained by residue from a
|
||||
// previous run. Provisioning no longer resets an adopted account's password,
|
||||
// so a leftover account would otherwise be adopted with a password this test
|
||||
// never learns.
|
||||
_ = removeWindowsSandboxIdentity(windowsSandboxUserName("ziptest01", windowsSandboxRoleOffline), "ziptest01")
|
||||
|
||||
identity, password, _, err := provisionWindowsSandboxIdentity("ziptest01", windowsSandboxRoleOffline)
|
||||
if err != nil {
|
||||
t.Fatalf("provision: %v", err)
|
||||
}
|
||||
// Registered immediately after provisioning so every failure path below is
|
||||
// covered. This test grants a real batch-logon right to a real local account;
|
||||
// leaving either behind on a developer machine is not acceptable residue, and
|
||||
// rights are revoked before the account so nothing is left keyed to a SID that
|
||||
// no longer resolves.
|
||||
t.Cleanup(func() {
|
||||
if err := revokeWindowsSandboxLogonRights(identity.SID); err != nil {
|
||||
t.Errorf("cleanup: revoke logon rights: %v", err)
|
||||
}
|
||||
if err := removeWindowsSandboxIdentity(identity.Username, "ziptest01"); err != nil && !errors.Is(err, errWindowsSandboxForeignAccountRetained) {
|
||||
t.Errorf("cleanup: remove principal: %v", err)
|
||||
}
|
||||
})
|
||||
if identity.SID == nil {
|
||||
t.Fatal("provisioned identity has no SID")
|
||||
}
|
||||
if password == "" {
|
||||
t.Fatal("provisioning returned an empty password")
|
||||
}
|
||||
// Re-running must converge on the same principal rather than failing or
|
||||
// creating a second account.
|
||||
again, secondPassword, _, err := provisionWindowsSandboxIdentity("ziptest01", windowsSandboxRoleOffline)
|
||||
if err != nil {
|
||||
t.Fatalf("second provision: %v", err)
|
||||
}
|
||||
if again.Username != identity.Username || !again.SID.Equals(identity.SID) {
|
||||
t.Fatalf("provisioning is not idempotent: %s then %s", identity, again)
|
||||
}
|
||||
if secondPassword == "" {
|
||||
t.Fatal("second provision returned an empty password")
|
||||
}
|
||||
// Deliberately NOT logging on with secondPassword. Provisioning does not
|
||||
// rotate an adopted account any more, so that value is a fresh random string
|
||||
// the account does not hold; rotation happens in
|
||||
// provisionWindowsSandboxPrincipalForSetup, immediately before the secret is
|
||||
// written, to keep the window where no stored password authenticates as small
|
||||
// as possible.
|
||||
//
|
||||
// The guarantee worth asserting is therefore the one the setup path makes:
|
||||
// after it returns, the stored secret logs the principal on. That covers
|
||||
// rotation, the secret write and the logon right in one assertion, and it is
|
||||
// the thing a broken re-setup would actually break.
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
WorkspaceRoots: []string{`C:\ziptest01`},
|
||||
}
|
||||
setupIdentity, _, err := provisionWindowsSandboxPrincipalForSetup(config, windowsSandboxRoleOffline)
|
||||
if err != nil {
|
||||
t.Fatalf("setup provision: %v", err)
|
||||
}
|
||||
secretPath, err := windowsSandboxSecretPath(config.SandboxHome, setupIdentity.Username)
|
||||
if err != nil {
|
||||
t.Fatalf("secret path: %v", err)
|
||||
}
|
||||
storedPassword, err := readWindowsSandboxSecret(secretPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read stored secret: %v", err)
|
||||
}
|
||||
token, err := logonWindowsSandboxPrincipal(setupIdentity.Username, storedPassword)
|
||||
if err != nil {
|
||||
t.Fatalf("logon with the secret the setup path stored: %v", err)
|
||||
}
|
||||
_ = token.Close()
|
||||
t.Cleanup(func() {
|
||||
_ = revokeWindowsSandboxLogonRights(setupIdentity.SID)
|
||||
_ = removeWindowsSandboxIdentity(setupIdentity.Username, "ziptest01")
|
||||
})
|
||||
// Lookup must find what provisioning created.
|
||||
found, err := lookupWindowsSandboxIdentity("ziptest01", windowsSandboxRoleOffline)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup after provision: %v", err)
|
||||
}
|
||||
if !found.SID.Equals(identity.SID) {
|
||||
t.Fatalf("lookup returned %s, want %s", found, identity)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the privileged chain: granting logon rights and actually
|
||||
// minting a token. Provisioning proves the account exists; this proves it is
|
||||
// USABLE, which is the part the runner depends on.
|
||||
//
|
||||
// The batch logon doubles as the assertion that LsaAddAccountRights worked. A
|
||||
// LOGON32_LOGON_BATCH logon fails with ERROR_LOGON_TYPE_NOT_GRANTED (1385)
|
||||
// unless SeBatchLogonRight is actually held, so a token coming back is proof the
|
||||
// grant landed rather than merely that the call returned success.
|
||||
//
|
||||
// Creates a real local account and removes it again, so it is gated the same way
|
||||
// as the provisioning round-trip.
|
||||
func TestGrantLogonRightsAndMintPrincipalToken(t *testing.T) {
|
||||
if os.Getenv("ZERO_WINDOWS_IDENTITY_PROVISION_TEST") != "1" {
|
||||
t.Skip("provisioning test not enabled: PowerShell `$env:ZERO_WINDOWS_IDENTITY_PROVISION_TEST = \"1\"`, cmd `set ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1`, bash `export ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1` (also needs an elevated terminal)")
|
||||
}
|
||||
if !windowsProcessIsElevated() {
|
||||
t.Skip("granting logon rights requires an elevated process")
|
||||
}
|
||||
|
||||
const key = "ziplogon01"
|
||||
// A leftover account from an interrupted run would keep its old password,
|
||||
// which the freshly generated one will not match, so start from a clean slate.
|
||||
_ = removeWindowsSandboxIdentity(windowsSandboxUserName(key, windowsSandboxRoleOffline), key)
|
||||
|
||||
identity, password, _, err := provisionWindowsSandboxIdentity(key, windowsSandboxRoleOffline)
|
||||
if err != nil {
|
||||
t.Fatalf("provision: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
// Rights first, then the account: the reverse order strands an LSA entry
|
||||
// keyed to a SID that no longer resolves.
|
||||
if err := revokeWindowsSandboxLogonRights(identity.SID); err != nil {
|
||||
t.Errorf("cleanup: revoke logon rights: %v", err)
|
||||
}
|
||||
if err := removeWindowsSandboxIdentity(identity.Username, key); err != nil && !errors.Is(err, errWindowsSandboxForeignAccountRetained) {
|
||||
t.Errorf("cleanup: remove principal: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Exercises LsaAddAccountRights, including the LSA_UNICODE_STRING byte-length
|
||||
// handling that nothing else has run.
|
||||
if err := grantWindowsSandboxLogonRights(identity.SID); err != nil {
|
||||
t.Fatalf("grant logon rights: %v", err)
|
||||
}
|
||||
// Idempotent: setup re-runs must not fail on rights the account already holds.
|
||||
if err := grantWindowsSandboxLogonRights(identity.SID); err != nil {
|
||||
t.Fatalf("granting logon rights twice must succeed: %v", err)
|
||||
}
|
||||
|
||||
token, err := logonWindowsSandboxPrincipal(identity.Username, password)
|
||||
if err != nil {
|
||||
t.Fatalf("logon as principal: %v", err)
|
||||
}
|
||||
defer token.Close()
|
||||
|
||||
// The token must BE the principal. If this came back as the caller, the whole
|
||||
// identity boundary would be an illusion and reads would still run as the user.
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
t.Fatalf("token user: %v", err)
|
||||
}
|
||||
if !user.User.Sid.Equals(identity.SID) {
|
||||
t.Fatalf("token belongs to %s, want the principal %s", user.User.Sid, identity.SID)
|
||||
}
|
||||
t.Logf("minted a token for %s", identity)
|
||||
}
|
||||
|
||||
// A workspace with no provisioned principal must report the actionable
|
||||
// "run setup" error rather than a raw lookup failure, so the command path can
|
||||
// fall back instead of surfacing a Win32 code.
|
||||
func TestLookupWindowsSandboxIdentityUnprovisioned(t *testing.T) {
|
||||
_, err := lookupWindowsSandboxIdentity("nosuchworkspacekey9z", windowsSandboxRoleOffline)
|
||||
if err == nil {
|
||||
t.Skip("a principal for this key unexpectedly exists on this machine")
|
||||
}
|
||||
if err != errWindowsSandboxIdentityUnavailable {
|
||||
t.Fatalf("error = %v, want errWindowsSandboxIdentityUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A name that resolves to something other than a user account is a conflict,
|
||||
// not an absent principal, and must not be reported as "setup has not run": the
|
||||
// command path treats that sentinel as permission to fall back silently, so
|
||||
// collapsing the two would hide a squatted account behind a quiet downgrade to
|
||||
// the restricted token.
|
||||
//
|
||||
// Every machine already has well-known non-user names to test against, so this
|
||||
// needs no privilege and no provisioning.
|
||||
func TestLookupWindowsSandboxIdentityRejectsNonUserAccount(t *testing.T) {
|
||||
// Groups that exist on any Windows install. Whichever resolves first is
|
||||
// enough; localized machines may not carry the English name.
|
||||
for _, group := range []string{"Administrators", "Users", "Guests"} {
|
||||
sid, _, accountType, err := windows.LookupSID("", group)
|
||||
if err != nil || sid == nil {
|
||||
continue
|
||||
}
|
||||
if accountType == windows.SidTypeUser {
|
||||
continue
|
||||
}
|
||||
resolveErr := func() error {
|
||||
_, err := resolveWindowsSandboxSID(group)
|
||||
return err
|
||||
}()
|
||||
if resolveErr == nil {
|
||||
t.Fatalf("resolveWindowsSandboxSID(%q) accepted a non-user account (type %d)", group, accountType)
|
||||
}
|
||||
// The classification is the part that matters: the sentinel is what
|
||||
// licenses the command path to fall back silently, so this refusal must
|
||||
// survive it rather than be folded into it.
|
||||
if classified := classifyWindowsSandboxLookupError(resolveErr); errors.Is(classified, errWindowsSandboxIdentityUnavailable) {
|
||||
t.Fatalf("non-user account %q classified as unprovisioned, which would silently downgrade to the restricted token: %v", group, classified)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Skip("no well-known non-user account resolved on this machine")
|
||||
}
|
||||
|
||||
// The account name is derived from a workspace hash, not discovered, so it can
|
||||
// be occupied by a local account that has nothing to do with Zero. Adopting one
|
||||
// means resetting a stranger's password, so provisioning has to prove ownership
|
||||
// first and refuse otherwise.
|
||||
//
|
||||
// Driven against real accounts every Windows install carries, which are
|
||||
// definitively not ours. Unprivileged: it only has to establish that they are
|
||||
// not classified as managed, so nothing is ever created or modified.
|
||||
func TestWindowsSandboxUserIsManagedRefusesForeignAccounts(t *testing.T) {
|
||||
checked := 0
|
||||
for _, name := range []string{"Administrator", "Guest", "DefaultAccount"} {
|
||||
managed, err := windowsSandboxUserIsManaged(name, "workspacekey")
|
||||
if err != nil {
|
||||
// Localized or disabled installs may not carry every one of these.
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
if managed {
|
||||
t.Fatalf("%q classified as a Zero sandbox principal; provisioning would reset its password", name)
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Skip("no well-known local account could be queried on this machine")
|
||||
}
|
||||
// An absent account must answer false rather than error, since provisioning
|
||||
// asks this question about names that usually do not exist yet.
|
||||
managed, err := windowsSandboxUserIsManaged("zero-sbx-nosuchacct", "workspacekey")
|
||||
if err != nil {
|
||||
t.Fatalf("querying a missing account: %v", err)
|
||||
}
|
||||
if managed {
|
||||
t.Fatal("a missing account was classified as managed")
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal has to be a typed, recognisable collision rather than a generic
|
||||
// failure, so setup can say what is wrong instead of reporting a Win32 status.
|
||||
func TestWindowsSandboxNameCollisionIsTyped(t *testing.T) {
|
||||
wrapped := fmt.Errorf("%w: %q", errWindowsSandboxNameCollision, "zero-sbx-dexample")
|
||||
if !errors.Is(wrapped, errWindowsSandboxNameCollision) {
|
||||
t.Fatal("collision error does not match its sentinel")
|
||||
}
|
||||
if !strings.Contains(wrapped.Error(), "not created by Zero") {
|
||||
t.Fatalf("collision message = %q, want it to say the account is not ours", wrapped.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// The two roles must never collide onto one account. If they did, the online
|
||||
// principal would be whatever the offline one is, which means either an approved
|
||||
// network command silently has no network, or an ordinary command silently has
|
||||
// one. The second is the dangerous direction.
|
||||
func TestWindowsSandboxUserNameSeparatesRoles(t *testing.T) {
|
||||
const key = "abc123"
|
||||
offline := windowsSandboxUserName(key, windowsSandboxRoleOffline)
|
||||
online := windowsSandboxUserName(key, windowsSandboxRoleOnline)
|
||||
if offline == online {
|
||||
t.Fatalf("both roles produced %q", offline)
|
||||
}
|
||||
// Still stable per role, or setup would create a new account every run.
|
||||
if again := windowsSandboxUserName(key, windowsSandboxRoleOnline); again != online {
|
||||
t.Fatalf("online name is not stable: %q then %q", online, again)
|
||||
}
|
||||
|
||||
// The role tag has to survive truncation. A very long key is exactly when the
|
||||
// name is cut to the 20-character limit, and losing the tag there is what
|
||||
// would collide the roles on precisely the workspaces most likely to hit it.
|
||||
longKey := strings.Repeat("f", 200)
|
||||
longOffline := windowsSandboxUserName(longKey, windowsSandboxRoleOffline)
|
||||
longOnline := windowsSandboxUserName(longKey, windowsSandboxRoleOnline)
|
||||
if longOffline == longOnline {
|
||||
t.Fatalf("truncation collided the roles onto %q", longOffline)
|
||||
}
|
||||
for _, name := range []string{longOffline, longOnline} {
|
||||
if len(name) > windowsSandboxUserNameMax {
|
||||
t.Fatalf("name %q is %d chars, want at most %d", name, len(name), windowsSandboxUserNameMax)
|
||||
}
|
||||
}
|
||||
|
||||
// Different workspaces still get different accounts within a role.
|
||||
if other := windowsSandboxUserName("def456", windowsSandboxRoleOffline); other == offline {
|
||||
t.Fatalf("two workspaces produced the same offline account %q", other)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// THE UPGRADE HAS TO BE OBSERVABLE, or it is not really there.
|
||||
//
|
||||
// windowsSandboxUserIsManaged accepts the old bare account comment and promises
|
||||
// the workspace key gets stamped on so the bare form can eventually be retired.
|
||||
// The probe for the legacy comment was seamed and the upgrade itself was not, so
|
||||
// nothing in the suite could tell whether the call ran: deleting it left
|
||||
// everything green. This repository has already had a fix silently reverted by a
|
||||
// later change, which is the failure this pins.
|
||||
func TestAdoptingALegacyAccountStampsTheWorkspaceKey(t *testing.T) {
|
||||
stub := func(t *testing.T, legacy bool) *string {
|
||||
t.Helper()
|
||||
var upgraded string
|
||||
|
||||
prevGroup := ensureWindowsSandboxGroupFn
|
||||
prevOffline := ensureWindowsSandboxOfflineGroupFn
|
||||
prevEnsure := ensureWindowsSandboxUserFn
|
||||
prevManaged := windowsSandboxUserIsManagedFn
|
||||
prevLegacy := windowsSandboxUserHasLegacyCommentFn
|
||||
prevPrivileged := windowsSandboxUserIsPrivilegedFn
|
||||
prevUpgrade := upgradeWindowsSandboxUserCommentFn
|
||||
prevReset := resetWindowsSandboxUserPasswordFn
|
||||
prevGroupAdd := addWindowsSandboxUserToGroupFn
|
||||
prevOfflineAdd := addWindowsSandboxUserToOfflineGroupFn
|
||||
prevSID := resolveWindowsSandboxSIDFn
|
||||
t.Cleanup(func() {
|
||||
ensureWindowsSandboxGroupFn = prevGroup
|
||||
ensureWindowsSandboxOfflineGroupFn = prevOffline
|
||||
ensureWindowsSandboxUserFn = prevEnsure
|
||||
windowsSandboxUserIsManagedFn = prevManaged
|
||||
windowsSandboxUserHasLegacyCommentFn = prevLegacy
|
||||
windowsSandboxUserIsPrivilegedFn = prevPrivileged
|
||||
upgradeWindowsSandboxUserCommentFn = prevUpgrade
|
||||
resetWindowsSandboxUserPasswordFn = prevReset
|
||||
addWindowsSandboxUserToGroupFn = prevGroupAdd
|
||||
addWindowsSandboxUserToOfflineGroupFn = prevOfflineAdd
|
||||
resolveWindowsSandboxSIDFn = prevSID
|
||||
})
|
||||
|
||||
ensureWindowsSandboxGroupFn = func() error { return nil }
|
||||
ensureWindowsSandboxOfflineGroupFn = func() error { return nil }
|
||||
// existed = true: the adoption path, which is the only one that upgrades.
|
||||
ensureWindowsSandboxUserFn = func(string, string, string) (bool, error) { return true, nil }
|
||||
windowsSandboxUserIsManagedFn = func(string, string) (bool, error) { return true, nil }
|
||||
windowsSandboxUserHasLegacyCommentFn = func(string) (bool, error) { return legacy, nil }
|
||||
windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return false, nil }
|
||||
upgradeWindowsSandboxUserCommentFn = func(_ string, workspaceKey string) error {
|
||||
upgraded = workspaceKey
|
||||
return nil
|
||||
}
|
||||
resetWindowsSandboxUserPasswordFn = func(string, string) error { return nil }
|
||||
addWindowsSandboxUserToGroupFn = func(string) error { return nil }
|
||||
addWindowsSandboxUserToOfflineGroupFn = func(string) error { return nil }
|
||||
resolveWindowsSandboxSIDFn = func(string) (*windows.SID, error) {
|
||||
return windows.StringToSid("S-1-5-32-546")
|
||||
}
|
||||
return &upgraded
|
||||
}
|
||||
|
||||
t.Run("a legacy comment is upgraded", func(t *testing.T) {
|
||||
upgraded := stub(t, true)
|
||||
if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline); err != nil {
|
||||
t.Fatalf("provisionWindowsSandboxIdentity: %v", err)
|
||||
}
|
||||
if *upgraded != "workspacekey" {
|
||||
t.Errorf("the workspace key was never stamped onto the adopted legacy account (got %q); windowsSandboxUserIsManaged goes on accepting the bare comment forever", *upgraded)
|
||||
}
|
||||
})
|
||||
|
||||
// And an account that already carries the key is left alone, or the
|
||||
// assertion above would be satisfied by an unconditional rewrite.
|
||||
t.Run("a current comment is left alone", func(t *testing.T) {
|
||||
upgraded := stub(t, false)
|
||||
if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline); err != nil {
|
||||
t.Fatalf("provisionWindowsSandboxIdentity: %v", err)
|
||||
}
|
||||
if *upgraded != "" {
|
||||
t.Errorf("an account that already carried the workspace key was rewritten anyway: %q", *upgraded)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The predecessor of this branch provisioned ONE account per workspace, named
|
||||
// "zero-sbx-<key>" with no role tag. Splitting the roles changed both names, so
|
||||
// on an upgraded machine neither role resolves to the account that is actually
|
||||
// installed. Every other path on this branch then reports the principal as never
|
||||
// provisioned, and the untagged account keeps its secret, its logon rights, its
|
||||
// ACEs and its ledger with nothing left that looks for them.
|
||||
//
|
||||
// windowsSandboxRoleLegacy exists to reproduce that name exactly so retirement
|
||||
// can still find it. If its tag ever stops being empty, this stops matching what
|
||||
// the predecessor wrote and the residue becomes unreachable again.
|
||||
func TestLegacyRoleReproducesTheUntaggedPredecessorName(t *testing.T) {
|
||||
// Short enough that the 20-character account-name limit does not truncate,
|
||||
// so the comparison is against the spelling itself rather than the cap.
|
||||
const key = "abc123"
|
||||
|
||||
legacy := windowsSandboxUserName(key, windowsSandboxRoleLegacy)
|
||||
if want := windowsSandboxUserPrefix + key; legacy != want {
|
||||
t.Fatalf("legacy account name = %q, want the pre-split spelling %q", legacy, want)
|
||||
}
|
||||
// And the cap still applies to it exactly as it did before the split, since
|
||||
// the predecessor truncated the same way.
|
||||
long := windowsSandboxUserName("averylongworkspacekeyindeed", windowsSandboxRoleLegacy)
|
||||
if len(long) > windowsSandboxUserNameMax {
|
||||
t.Errorf("legacy name %q is %d characters, over the %d-character limit", long, len(long), windowsSandboxUserNameMax)
|
||||
}
|
||||
if tag := windowsSandboxRoleLegacy.roleTag(); tag != "" {
|
||||
t.Errorf("legacy role tag = %q, want empty; a tag here would stop matching the installed account", tag)
|
||||
}
|
||||
|
||||
// It must not collide with either live role, or retiring the predecessor
|
||||
// would delete an account this branch is still using.
|
||||
offline := windowsSandboxUserName(key, windowsSandboxRoleOffline)
|
||||
online := windowsSandboxUserName(key, windowsSandboxRoleOnline)
|
||||
for _, live := range []string{offline, online} {
|
||||
if strings.EqualFold(legacy, live) {
|
||||
t.Errorf("legacy name %q collides with a live role name %q", legacy, live)
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(offline, online) {
|
||||
t.Fatalf("the two live roles derived the same account name %q", offline)
|
||||
}
|
||||
}
|
||||
|
||||
// THE LEGACY ROLE IS RETIRED, NEVER PROVISIONED. It derives the untagged name
|
||||
// on purpose, so provisioning it would create the very account this branch
|
||||
// replaced, on every setup, forever. Adding it to the retirement list and the
|
||||
// provisioning list are one character apart in the source and the difference is
|
||||
// invisible at a glance, so it is asserted rather than left to review.
|
||||
func TestSetupNeverProvisionsTheLegacyPrincipal(t *testing.T) {
|
||||
previous := provisionWindowsSandboxPrincipalForSetupFn
|
||||
t.Cleanup(func() { provisionWindowsSandboxPrincipalForSetupFn = previous })
|
||||
|
||||
var provisioned []windowsSandboxRole
|
||||
provisionWindowsSandboxPrincipalForSetupFn = func(_ WindowsSandboxCommandConfig, role windowsSandboxRole) (windowsSandboxIdentity, bool, error) {
|
||||
provisioned = append(provisioned, role)
|
||||
return windowsSandboxIdentity{}, false, errStopProvisioningForTest
|
||||
}
|
||||
|
||||
// The error stops setup at the first role; what matters is which roles it
|
||||
// was willing to ask for, and legacy must never be among them.
|
||||
_, _ = setupWindowsSandboxPrincipal(WindowsSandboxCommandConfig{SandboxHome: t.TempDir()})
|
||||
|
||||
for _, role := range provisioned {
|
||||
if role == windowsSandboxRoleLegacy {
|
||||
t.Fatalf("setup tried to provision the legacy role, which would recreate the pre-split account: %v", provisioned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var errStopProvisioningForTest = errors.New("stop provisioning for test")
|
||||
|
||||
// Opting out or re-running setup has to retire the predecessor as well as both
|
||||
// live roles. Retiring only the two this branch knows about is what leaves a
|
||||
// fully provisioned account on a machine whose marker says it has none.
|
||||
func TestSetupRetirementCoversTheLegacyPrincipal(t *testing.T) {
|
||||
previous := removeWindowsSandboxPrincipalForSetupFn
|
||||
t.Cleanup(func() { removeWindowsSandboxPrincipalForSetupFn = previous })
|
||||
|
||||
var retired []string
|
||||
removeWindowsSandboxPrincipalForSetupFn = func(_ WindowsSandboxCommandConfig, role windowsSandboxRole) error {
|
||||
retired = append(retired, string(role))
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := removeWindowsSandboxPrincipalsForSetup(WindowsSandboxCommandConfig{SandboxHome: t.TempDir()}); err != nil {
|
||||
t.Fatalf("removeWindowsSandboxPrincipalsForSetup: %v", err)
|
||||
}
|
||||
|
||||
sort.Strings(retired)
|
||||
want := []string{"legacy", "offline", "online"}
|
||||
if len(retired) != len(want) {
|
||||
t.Fatalf("retired %v, want all of %v; a role left out keeps its account and everything it owns", retired, want)
|
||||
}
|
||||
for index := range want {
|
||||
if retired[index] != want[index] {
|
||||
t.Fatalf("retired %v, want %v", retired, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,15 +62,190 @@ func BuildWindowsNetworkInfraPlan(config WindowsSandboxCommandConfig) (WindowsNe
|
||||
if err != nil {
|
||||
return WindowsNetworkPlan{}, err
|
||||
}
|
||||
// The offline-marker SID stays first: the setup marker records
|
||||
// IdentitySIDs[0] as the offline filter identity.
|
||||
identitySIDs := []string{offlineSID}
|
||||
// A sandbox principal cannot carry the offline-marker SID, because LogonUser
|
||||
// builds a token from an account's real group memberships and the marker is a
|
||||
// synthetic capability SID. So the same filters additionally match a real
|
||||
// local group that network-denied principals belong to.
|
||||
//
|
||||
// Gated on THIS home's opt-in, not merely on the group existing.
|
||||
//
|
||||
// The group is machine-global, so keying off its existence meant the first
|
||||
// workspace to opt in changed the computed plan for every OTHER sandbox home
|
||||
// on the machine. The plan is hashed into the setup marker and compared on
|
||||
// every command, so those homes started failing every command with "setup is
|
||||
// out of date" until each was re-run from an elevated terminal, having never
|
||||
// opted into anything. Existing markers have to keep validating.
|
||||
//
|
||||
// Reading the opt-in instead keeps an opted-out home computing exactly the
|
||||
// plan it computed before any of this existed. An opted-in home records the
|
||||
// group in its own marker, and setup and the command path both derive the
|
||||
// flag from the same environment, so they agree. Opting in AFTER setup does
|
||||
// invalidate that home's marker, which is correct: it has no principals yet
|
||||
// and setup is genuinely required.
|
||||
if windowsSandboxIdentityEnabled(config.Env) && resolveWindowsSandboxOfflineGroupSIDHook != nil {
|
||||
groupSID, err := resolveWindowsSandboxOfflineGroupSIDHook()
|
||||
if err != nil {
|
||||
return WindowsNetworkPlan{}, err
|
||||
}
|
||||
if trimmed := strings.TrimSpace(groupSID); trimmed != "" {
|
||||
identitySIDs = append(identitySIDs, trimmed)
|
||||
}
|
||||
}
|
||||
return WindowsNetworkPlan{
|
||||
Mode: NetworkDeny,
|
||||
ProviderKey: windowsWFPProviderKey,
|
||||
SubLayerKey: windowsWFPSubLayerKey,
|
||||
IdentitySIDs: []string{offlineSID},
|
||||
IdentitySIDs: identitySIDs,
|
||||
Filters: windowsDenyWFPFilterSpecs(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WindowsNetworkPlanForApply returns the plan to INSTALL, which is not always
|
||||
// the plan a home fingerprints.
|
||||
//
|
||||
// The filters are machine-global and every setup installs them by deleting and
|
||||
// recreating the fixed set. So an opted-OUT setup for workspace B was replacing
|
||||
// filters that workspace A's opted-in setup had installed, dropping the offline
|
||||
// group SID as it went. A's offline principal still passed the runtime
|
||||
// membership check and A's marker still validated, but no filter matched its
|
||||
// token any more, so a NetworkDeny command in A quietly gained egress. Nothing
|
||||
// on either side reported anything: B did exactly what it was asked to.
|
||||
//
|
||||
// The gate in BuildWindowsNetworkInfraPlan stays as it is, and this is
|
||||
// deliberately a SEPARATE function rather than a change to it. That gate exists
|
||||
// because the plan is hashed into each home's setup marker: keying it on the
|
||||
// group's existence made the first workspace to opt in invalidate every other
|
||||
// home's marker on the machine, and those homes then failed every command with
|
||||
// "setup is out of date" having opted into nothing. That has to keep working.
|
||||
//
|
||||
// The two questions are simply different. What a home RECORDS is about that
|
||||
// home's own configuration; what setup INSTALLS is about the machine, where the
|
||||
// group either exists or does not. Answering both from one plan is what forced a
|
||||
// choice between stale markers and a silent hole.
|
||||
//
|
||||
// The group not existing is the ordinary opted-out machine, and it adds nothing.
|
||||
//
|
||||
// A resolution FAILURE is a different answer and is fatal, because the filters
|
||||
// are replaced wholesale. The earlier reasoning here was that refusing to
|
||||
// install would trade a partial denial for no denial at all, and that is wrong:
|
||||
// the alternative to installing is leaving the filters that are already there
|
||||
// alone. Returning a marker-only plan does not decline to add coverage, it
|
||||
// actively REMOVES coverage another workspace is relying on. An opted-out home
|
||||
// skips assertWindowsNetworkPlanCoversOfflineGroup entirely (it is gated on
|
||||
// provisioned), so this is the only thing between a transient lookup error and
|
||||
// workspace A silently regaining egress under NetworkDeny.
|
||||
//
|
||||
// "Absent" and "could not be read" have to be different answers, which is the
|
||||
// same distinction the marker/apply split above is built on.
|
||||
func WindowsNetworkPlanForApply(plan WindowsNetworkPlan, resolveOfflineGroupSID func() (string, error)) (WindowsNetworkPlan, error) {
|
||||
if resolveOfflineGroupSID == nil {
|
||||
return plan, nil
|
||||
}
|
||||
groupSID, err := resolveOfflineGroupSID()
|
||||
if err != nil {
|
||||
return WindowsNetworkPlan{}, fmt.Errorf("resolve the sandbox offline group before replacing the machine network filters: %w", err)
|
||||
}
|
||||
trimmed := strings.TrimSpace(groupSID)
|
||||
if trimmed == "" {
|
||||
return plan, nil
|
||||
}
|
||||
for _, existing := range plan.IdentitySIDs {
|
||||
if strings.EqualFold(strings.TrimSpace(existing), trimmed) {
|
||||
return plan, nil
|
||||
}
|
||||
}
|
||||
// Copied rather than appended in place: the caller's plan is what gets
|
||||
// fingerprinted, and growing its backing array would risk changing the
|
||||
// recorded plan as a side effect of installing one.
|
||||
augmented := plan
|
||||
augmented.IdentitySIDs = append(append([]string{}, plan.IdentitySIDs...), trimmed)
|
||||
return augmented, nil
|
||||
}
|
||||
|
||||
// WindowsNetworkPlanCoversPrincipals reports whether a plan's block filters name
|
||||
// the offline group, which is the only thing that makes them apply to a sandbox
|
||||
// principal.
|
||||
//
|
||||
// This exists to be asserted, not consulted. The plan must be built AFTER
|
||||
// provisioning, because provisioning is what creates the group it resolves; a
|
||||
// plan built first names only the offline marker and leaves every offline
|
||||
// principal with an open network while setup reports success. That is a control
|
||||
// that enforces nothing while claiming to work, and the ordering which prevents
|
||||
// it is invisible at the call site, so a later refactor can undo it silently.
|
||||
func WindowsNetworkPlanCoversPrincipals(plan WindowsNetworkPlan, offlineGroupSID string) bool {
|
||||
offlineGroupSID = strings.TrimSpace(offlineGroupSID)
|
||||
if offlineGroupSID == "" {
|
||||
// No group provisioned on this host, so there is no principal for the
|
||||
// filters to miss and nothing to assert.
|
||||
return true
|
||||
}
|
||||
for _, sid := range plan.IdentitySIDs {
|
||||
if strings.EqualFold(strings.TrimSpace(sid), offlineGroupSID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// assertWindowsNetworkPlanCoversOfflineGroup is the post-provisioning check that
|
||||
// setup runs before it installs filters and reports success.
|
||||
//
|
||||
// It fails closed on every answer that is not a definite yes, because the thing
|
||||
// it guards is invisible when it goes wrong: filters that do not name the
|
||||
// offline group leave every offline principal with an open network on a machine
|
||||
// whose setup marker says it is protected. Nobody sees an error, and the sandbox
|
||||
// looks correctly installed.
|
||||
//
|
||||
// Two answers other than "the plan omits the group" also mean the check did not
|
||||
// happen and must not be treated as a pass:
|
||||
//
|
||||
// - The lookup failed. Whether the group is covered is then unknown, and the
|
||||
// Win32 reason is worth surfacing: an operator seeing "access is denied"
|
||||
// knows to re-run elevated.
|
||||
// - The lookup succeeded and found nothing. ("", nil) means the group does not
|
||||
// exist, which is the ordinary state BEFORE provisioning and an impossible
|
||||
// one after it, so here it means the group vanished or was never created.
|
||||
// WindowsNetworkPlanCoversPrincipals answers true for an empty SID, correctly
|
||||
// for the pre-provisioning callers that ask it, so the emptiness has to be
|
||||
// rejected here rather than delegated to it.
|
||||
//
|
||||
// provisioned says whether principals were actually provisioned on this run.
|
||||
// When they were not there is no offline group, no principal carrying it, and
|
||||
// nothing for the filters to miss, so there is nothing to assert. Running the
|
||||
// check anyway turns the ordinary opt-out setup into a hard failure, because a
|
||||
// machine with no group resolves to ("", nil) and the empty-SID rejection below
|
||||
// is correct only after provisioning.
|
||||
func assertWindowsNetworkPlanCoversOfflineGroup(plan WindowsNetworkPlan, resolve func() (string, error), provisioned bool) error {
|
||||
if !provisioned {
|
||||
return nil
|
||||
}
|
||||
if resolve == nil {
|
||||
return errors.New("sandbox offline group resolver is not wired up, so filter coverage cannot be verified")
|
||||
}
|
||||
groupSID, err := resolve()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve the sandbox offline group after provisioning, so filter coverage cannot be verified: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(groupSID) == "" {
|
||||
return errors.New("the sandbox offline group does not exist after provisioning, so the block filters would not apply to any sandbox principal")
|
||||
}
|
||||
if !WindowsNetworkPlanCoversPrincipals(plan, groupSID) {
|
||||
return errors.New("network block filters do not name the sandbox offline group, so they would not apply to any sandbox principal; the network plan must be built after principals are provisioned")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveWindowsSandboxOfflineGroupSIDHook resolves the local group that
|
||||
// network-denied principals belong to. It is wired up on Windows only, so this
|
||||
// file stays free of Win32 calls and the plan on other platforms is unchanged.
|
||||
//
|
||||
// Returning ("", nil) means the group does not exist yet, which is the state
|
||||
// before principals have ever been provisioned.
|
||||
var resolveWindowsSandboxOfflineGroupSIDHook func() (string, error)
|
||||
|
||||
// WindowsNetworkInfraHash fingerprints the provisioned (mode-independent) network
|
||||
// infrastructure so the setup marker validates against the same setup for BOTH
|
||||
// command modes. It never folds in the per-command network mode.
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The post-provisioning coverage assertion is the last thing standing between a
|
||||
// mis-ordered plan and a machine that reports a successful setup while every
|
||||
// offline principal has an open network. It has to fail closed on every way of
|
||||
// not knowing the answer, not just on a definite negative.
|
||||
func TestAssertWindowsNetworkPlanCoversOfflineGroupFailsClosed(t *testing.T) {
|
||||
const groupSID = "S-1-5-32-9999"
|
||||
covering := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-marker", groupSID}}
|
||||
notCovering := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-marker"}}
|
||||
lookupFailed := errors.New("look up zero-sandbox-offline: access is denied")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
plan WindowsNetworkPlan
|
||||
resolve func() (string, error)
|
||||
wantErr bool
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "plan names the group",
|
||||
plan: covering,
|
||||
resolve: func() (string, error) { return groupSID, nil },
|
||||
},
|
||||
{
|
||||
// The finding anandh8x raised: a failed lookup skipped the assertion
|
||||
// entirely and setup carried on to write a success marker.
|
||||
name: "lookup fails",
|
||||
plan: covering,
|
||||
resolve: func() (string, error) { return "", lookupFailed },
|
||||
wantErr: true,
|
||||
wantText: "access is denied",
|
||||
},
|
||||
{
|
||||
// Empty-and-no-error means "the group does not exist", which is a
|
||||
// legitimate answer BEFORE provisioning and an impossible one after it.
|
||||
// WindowsNetworkPlanCoversPrincipals answers true for an empty SID, so
|
||||
// without an explicit check the assertion passes vacuously.
|
||||
name: "group missing after provisioning",
|
||||
plan: covering,
|
||||
resolve: func() (string, error) { return "", nil },
|
||||
wantErr: true,
|
||||
wantText: "does not exist",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only SID",
|
||||
plan: covering,
|
||||
resolve: func() (string, error) { return " ", nil },
|
||||
wantErr: true,
|
||||
wantText: "does not exist",
|
||||
},
|
||||
{
|
||||
name: "plan omits the group",
|
||||
plan: notCovering,
|
||||
resolve: func() (string, error) { return groupSID, nil },
|
||||
wantErr: true,
|
||||
wantText: "do not name the sandbox offline group",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := assertWindowsNetworkPlanCoversOfflineGroup(test.plan, test.resolve, true)
|
||||
if test.wantErr && err == nil {
|
||||
t.Fatalf("assertWindowsNetworkPlanCoversOfflineGroup() = nil, want an error so setup rolls back")
|
||||
}
|
||||
if !test.wantErr {
|
||||
if err != nil {
|
||||
t.Fatalf("assertWindowsNetworkPlanCoversOfflineGroup() = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.wantText) {
|
||||
t.Fatalf("error = %q, want it to mention %q so the operator can tell the cases apart", err, test.wantText)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The lookup failure has to stay unwrapped-comparable: setup logs it and an
|
||||
// operator needs the underlying Win32 reason, not a flattened string.
|
||||
err := assertWindowsNetworkPlanCoversOfflineGroup(covering, func() (string, error) { return "", lookupFailed }, true)
|
||||
if !errors.Is(err, lookupFailed) {
|
||||
t.Fatalf("errors.Is(err, lookupFailed) = false; the cause was dropped: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The assert only means something AFTER principals were provisioned. The offline
|
||||
// group is created inside provisionWindowsSandboxIdentity, which runs only under
|
||||
// the ZERO_WINDOWS_SANDBOX_IDENTITY opt-in, so on a default machine there is no
|
||||
// group, no principal, and nothing for the filters to miss.
|
||||
//
|
||||
// Rejecting an empty SID is right once provisioning ran (that is the vacuous
|
||||
// pass anandh8x found) and wrong before it: it turned the ordinary opt-out setup
|
||||
// into a hard failure, which is the regression jatmn caught on #812.
|
||||
func TestAssertWindowsNetworkPlanCoversOfflineGroupSkipsWhenNothingWasProvisioned(t *testing.T) {
|
||||
plan := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-marker"}}
|
||||
|
||||
// The default machine: no group exists, so the resolver reports ("", nil).
|
||||
if err := assertWindowsNetworkPlanCoversOfflineGroup(plan, func() (string, error) { return "", nil }, false); err != nil {
|
||||
t.Fatalf("opt-out setup was refused: %v", err)
|
||||
}
|
||||
// Same state WITH provisioning claimed: now the missing group is a real fault.
|
||||
if err := assertWindowsNetworkPlanCoversOfflineGroup(plan, func() (string, error) { return "", nil }, true); err == nil {
|
||||
t.Fatal("a missing group after provisioning must still fail closed")
|
||||
}
|
||||
// Not provisioned must not become a licence to skip a lookup failure either:
|
||||
// the resolver is never consulted, so a broken lookup cannot matter here.
|
||||
if err := assertWindowsNetworkPlanCoversOfflineGroup(plan, func() (string, error) {
|
||||
t.Fatal("the resolver must not be consulted when nothing was provisioned")
|
||||
return "", nil
|
||||
}, false); err != nil {
|
||||
t.Fatalf("opt-out setup was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A nil resolver is a wiring mistake, and guessing that coverage is fine is the
|
||||
// one thing it must not do.
|
||||
func TestAssertWindowsNetworkPlanCoversOfflineGroupRejectsNilResolver(t *testing.T) {
|
||||
plan := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-32-9999"}}
|
||||
if err := assertWindowsNetworkPlanCoversOfflineGroup(plan, nil, true); err == nil {
|
||||
t.Fatal("assertWindowsNetworkPlanCoversOfflineGroup(nil resolver) = nil, want an error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE BLOCK FILTERS ARE MACHINE-GLOBAL, SO ONE WORKSPACE'S SETUP DECIDES ANOTHER
|
||||
// WORKSPACE'S NETWORK.
|
||||
//
|
||||
// Every setup installs them by deleting and recreating one fixed set. The plan
|
||||
// each home builds names the offline group only when THAT home opted in, which
|
||||
// is right for the marker it fingerprints and wrong for the filters it installs:
|
||||
// an ordinary opted-out setup for a second workspace replaced the filters
|
||||
// without the group SID, and the first workspace's offline principal — still in
|
||||
// the group, still passing the runtime membership check, still holding a valid
|
||||
// marker — was no longer matched by any filter. A NetworkDeny command there
|
||||
// gained egress, silently, because the second setup did exactly what it was
|
||||
// asked to do.
|
||||
//
|
||||
// The plan a home installs and the plan it records are therefore different
|
||||
// questions, and these pin both.
|
||||
|
||||
const testOfflineGroupSID = "S-1-5-32-9990"
|
||||
|
||||
func offlineGroupResolver(sid string) func() (string, error) {
|
||||
return func() (string, error) { return sid, nil }
|
||||
}
|
||||
|
||||
// The regression itself: an opted-OUT home must still install filters covering
|
||||
// the managed offline group, because an opted-IN home's principals depend on it.
|
||||
func TestOptedOutSetupKeepsOfflineGroupCoverageForOtherWorkspaces(t *testing.T) {
|
||||
optedOut, err := BuildWindowsNetworkInfraPlan(WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
// No opt-in in the environment: this is the ordinary workspace.
|
||||
Env: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsNetworkInfraPlan: %v", err)
|
||||
}
|
||||
|
||||
// What this home RECORDS must not change, or every opted-out marker on the
|
||||
// machine goes stale the moment another workspace opts in.
|
||||
if WindowsNetworkPlanCoversPrincipals(optedOut, testOfflineGroupSID) {
|
||||
t.Fatal("the recorded plan named the offline group; that is what invalidated other homes' markers")
|
||||
}
|
||||
|
||||
// What this home INSTALLS must cover the group, since the group exists.
|
||||
applied, err := WindowsNetworkPlanForApply(optedOut, offlineGroupResolver(testOfflineGroupSID))
|
||||
if err != nil {
|
||||
t.Fatalf("WindowsNetworkPlanForApply: %v", err)
|
||||
}
|
||||
if !WindowsNetworkPlanCoversPrincipals(applied, testOfflineGroupSID) {
|
||||
t.Errorf("an opted-out setup would install filters that do not match the offline group, so another workspace's NetworkDeny commands gain egress: %v", applied.IdentitySIDs)
|
||||
}
|
||||
// The offline-marker SID has to survive alongside it: the restricted-token
|
||||
// tier is matched by that one, and it is the whole denial on machines with no
|
||||
// principals at all.
|
||||
if len(applied.IdentitySIDs) == 0 || strings.TrimSpace(applied.IdentitySIDs[0]) == "" {
|
||||
t.Fatalf("the offline-marker SID was lost from the applied plan: %v", applied.IdentitySIDs)
|
||||
}
|
||||
if applied.IdentitySIDs[0] != optedOut.IdentitySIDs[0] {
|
||||
t.Errorf("the marker SID must stay first, since the setup marker records IdentitySIDs[0]: %v", applied.IdentitySIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// Augmenting must not mutate the plan the caller fingerprints. Appending in
|
||||
// place would let installing a plan change the plan that was recorded.
|
||||
func TestPlanForApplyDoesNotMutateTheRecordedPlan(t *testing.T) {
|
||||
recorded, err := BuildWindowsNetworkInfraPlan(WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
Env: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsNetworkInfraPlan: %v", err)
|
||||
}
|
||||
before := append([]string{}, recorded.IdentitySIDs...)
|
||||
|
||||
if _, err := WindowsNetworkPlanForApply(recorded, offlineGroupResolver(testOfflineGroupSID)); err != nil {
|
||||
t.Fatalf("WindowsNetworkPlanForApply: %v", err)
|
||||
}
|
||||
|
||||
if len(recorded.IdentitySIDs) != len(before) {
|
||||
t.Fatalf("the recorded plan grew from %v to %v", before, recorded.IdentitySIDs)
|
||||
}
|
||||
for index := range before {
|
||||
if recorded.IdentitySIDs[index] != before[index] {
|
||||
t.Errorf("the recorded plan changed at %d: %v vs %v", index, before, recorded.IdentitySIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An opted-IN home already names the group, and must not name it twice: a
|
||||
// duplicate identity would change the installed filter set for no reason.
|
||||
func TestPlanForApplyDoesNotDuplicateAnAlreadyCoveredGroup(t *testing.T) {
|
||||
plan := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-marker", testOfflineGroupSID}}
|
||||
applied, err := WindowsNetworkPlanForApply(plan, offlineGroupResolver(strings.ToLower(testOfflineGroupSID)))
|
||||
if err != nil {
|
||||
t.Fatalf("WindowsNetworkPlanForApply: %v", err)
|
||||
}
|
||||
if len(applied.IdentitySIDs) != 2 {
|
||||
t.Errorf("the group was added again despite already being covered: %v", applied.IdentitySIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// On an ordinary machine with no principals the group does not exist, and the
|
||||
// plan must be left exactly as it was.
|
||||
func TestPlanForApplyLeavesAMachineWithoutTheGroupAlone(t *testing.T) {
|
||||
plan := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-marker"}}
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
resolve func() (string, error)
|
||||
}{
|
||||
{name: "no resolver", resolve: nil},
|
||||
{name: "group absent", resolve: func() (string, error) { return "", nil }},
|
||||
{name: "blank SID", resolve: func() (string, error) { return " ", nil }},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
applied, err := WindowsNetworkPlanForApply(plan, testCase.resolve)
|
||||
if err != nil {
|
||||
t.Fatalf("a machine with no offline group is not a failure: %v", err)
|
||||
}
|
||||
if len(applied.IdentitySIDs) != 1 || applied.IdentitySIDs[0] != "S-1-5-21-marker" {
|
||||
t.Errorf("plan changed on a machine with no offline group: %v", applied.IdentitySIDs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A LOOKUP FAILURE IS NOT AN ABSENT GROUP. The filters are machine-global and
|
||||
// are replaced wholesale, so returning a marker-only plan here does not decline
|
||||
// to add coverage, it removes coverage another workspace is relying on.
|
||||
//
|
||||
// This is reachable only from an opted-OUT home, because
|
||||
// assertWindowsNetworkPlanCoversOfflineGroup is gated on provisioned and never
|
||||
// runs for one. Workspace A opts in, the group SID lands in the global filters;
|
||||
// workspace B is opted out and its lookup fails transiently; B installs a plan
|
||||
// without the group and A's NetworkDeny commands silently regain egress while
|
||||
// A's marker and its direct membership check both still pass.
|
||||
func TestPlanForApplyRefusesToReplaceFiltersWhenTheGroupCannotBeRead(t *testing.T) {
|
||||
// Opted-out home B: the coverage assert is skipped for it, so nothing else
|
||||
// stands between the failure and the wholesale replace.
|
||||
if err := assertWindowsNetworkPlanCoversOfflineGroup(
|
||||
WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-B-marker"}},
|
||||
func() (string, error) { return "", errors.New("group lookup failed") },
|
||||
false,
|
||||
); err != nil {
|
||||
t.Fatalf("the coverage assert is meant to be skipped for an opted-out home: %v", err)
|
||||
}
|
||||
|
||||
plan := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-B-marker"}}
|
||||
applied, err := WindowsNetworkPlanForApply(plan, func() (string, error) {
|
||||
return "", errors.New("group lookup failed")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("a failed group lookup installed %v, stripping the offline group from the machine's filters", applied.IdentitySIDs)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "group lookup failed") {
|
||||
t.Errorf("the underlying failure must be reported so the operator can act on it: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -118,3 +118,154 @@ func assertWindowsWFPCommonFilter(t *testing.T, specs map[string]WindowsWFPFilte
|
||||
|
||||
// Coverage for the network infra plan + hash and the per-mode token-SID
|
||||
// composition lives in windows_online_offline_test.go.
|
||||
|
||||
// The block filters have to name the offline group as well as the offline-marker
|
||||
// SID, or a sandbox principal is not covered by them at all: LogonUser builds a
|
||||
// token from real group memberships and cannot carry the synthetic marker. This
|
||||
// is the single property that makes network denial work for the principal
|
||||
// backend, so assert it on the plan rather than trusting the wiring.
|
||||
func TestNetworkInfraPlanIncludesOfflineGroupIdentity(t *testing.T) {
|
||||
previous := resolveWindowsSandboxOfflineGroupSIDHook
|
||||
t.Cleanup(func() { resolveWindowsSandboxOfflineGroupSIDHook = previous })
|
||||
|
||||
config := WindowsSandboxCommandConfig{SandboxHome: t.TempDir(), Env: map[string]string{windowsSandboxIdentityEnv: "1"}}
|
||||
|
||||
// Before principals have ever been provisioned the group does not exist, and
|
||||
// the plan must be exactly what it was before this feature. The plan is hashed
|
||||
// into the setup marker and re-derived on every command, so an identity set
|
||||
// that appeared out of nowhere would fail every command with "setup is out of
|
||||
// date".
|
||||
resolveWindowsSandboxOfflineGroupSIDHook = func() (string, error) { return "", nil }
|
||||
base, err := BuildWindowsNetworkInfraPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("build plan without the group: %v", err)
|
||||
}
|
||||
if len(base.IdentitySIDs) != 1 {
|
||||
t.Fatalf("identity SIDs = %v, want only the offline marker when the group is absent", base.IdentitySIDs)
|
||||
}
|
||||
|
||||
const groupSID = "S-1-5-32-9999"
|
||||
resolveWindowsSandboxOfflineGroupSIDHook = func() (string, error) { return groupSID, nil }
|
||||
withGroup, err := BuildWindowsNetworkInfraPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("build plan with the group: %v", err)
|
||||
}
|
||||
if len(withGroup.IdentitySIDs) != 2 || withGroup.IdentitySIDs[1] != groupSID {
|
||||
t.Fatalf("identity SIDs = %v, want the offline marker plus %s", withGroup.IdentitySIDs, groupSID)
|
||||
}
|
||||
// The marker records IdentitySIDs[0] as the offline filter identity, so the
|
||||
// marker SID has to stay first.
|
||||
if withGroup.IdentitySIDs[0] != base.IdentitySIDs[0] {
|
||||
t.Fatalf("offline marker moved from position 0: %v", withGroup.IdentitySIDs)
|
||||
}
|
||||
// Adding the group must change the fingerprint, or setup and the command path
|
||||
// could disagree about the installed filters without anything noticing.
|
||||
baseHash, err := WindowsNetworkInfraHash(base)
|
||||
if err != nil {
|
||||
t.Fatalf("hash base: %v", err)
|
||||
}
|
||||
groupHash, err := WindowsNetworkInfraHash(withGroup)
|
||||
if err != nil {
|
||||
t.Fatalf("hash with group: %v", err)
|
||||
}
|
||||
if baseHash == groupHash {
|
||||
t.Fatal("the infra hash ignored the offline group identity")
|
||||
}
|
||||
}
|
||||
|
||||
// A lookup failure must not be swallowed into "no group", because that silently
|
||||
// produces a plan whose filters do not cover sandbox principals.
|
||||
func TestNetworkInfraPlanPropagatesOfflineGroupLookupFailure(t *testing.T) {
|
||||
previous := resolveWindowsSandboxOfflineGroupSIDHook
|
||||
t.Cleanup(func() { resolveWindowsSandboxOfflineGroupSIDHook = previous })
|
||||
|
||||
resolveWindowsSandboxOfflineGroupSIDHook = func() (string, error) {
|
||||
return "", errors.New("boom")
|
||||
}
|
||||
if _, err := BuildWindowsNetworkInfraPlan(WindowsSandboxCommandConfig{SandboxHome: t.TempDir(), Env: map[string]string{windowsSandboxIdentityEnv: "1"}}); err == nil {
|
||||
t.Fatal("a failed offline-group lookup produced a plan; the filters would not cover any principal")
|
||||
}
|
||||
}
|
||||
|
||||
// The ordering that makes the block filters apply to sandbox principals is
|
||||
// invisible at the call site: the plan must be built AFTER provisioning,
|
||||
// because provisioning creates the group the filters name. A plan built first
|
||||
// names only the offline marker, and a machine would then report a successful
|
||||
// setup while every offline principal had an open network.
|
||||
//
|
||||
// Asserted on the predicate setup checks before installing anything, so moving
|
||||
// the plan build back ahead of provisioning fails loudly instead of silently
|
||||
// producing a control that enforces nothing.
|
||||
func TestNetworkPlanCoverageDetectsPrincipalsLeftUncovered(t *testing.T) {
|
||||
const groupSID = "S-1-5-32-4242"
|
||||
|
||||
// What a plan built BEFORE provisioning looks like: marker only.
|
||||
tooEarly := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-15-3-1111"}}
|
||||
if WindowsNetworkPlanCoversPrincipals(tooEarly, groupSID) {
|
||||
t.Fatal("a plan naming only the offline marker was reported as covering principals; the ordering guard would not fire")
|
||||
}
|
||||
|
||||
// And after: marker plus the group.
|
||||
correct := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-15-3-1111", groupSID}}
|
||||
if !WindowsNetworkPlanCoversPrincipals(correct, groupSID) {
|
||||
t.Fatal("a plan naming the offline group was reported as not covering principals; setup would refuse a correct plan")
|
||||
}
|
||||
// SID comparison is case-insensitive, so a differently-cased resolve does not
|
||||
// read as a missing group and block a valid setup.
|
||||
mixed := WindowsNetworkPlan{IdentitySIDs: []string{strings.ToLower(groupSID)}}
|
||||
if !WindowsNetworkPlanCoversPrincipals(mixed, groupSID) {
|
||||
t.Fatal("case difference read as a missing group")
|
||||
}
|
||||
|
||||
// A host with no group provisioned has no principal to miss, so there is
|
||||
// nothing to assert and setup must not refuse.
|
||||
if !WindowsNetworkPlanCoversPrincipals(tooEarly, "") {
|
||||
t.Fatal("an unprovisioned host was treated as a coverage failure")
|
||||
}
|
||||
}
|
||||
|
||||
// AN OPTED-OUT HOME MUST KEEP ITS EXISTING MARKER VALID.
|
||||
//
|
||||
// The offline group is machine-global. Keyed on its existence, the first
|
||||
// workspace to opt in changed the computed plan for every OTHER sandbox home on
|
||||
// the machine, so their stored NetworkInfraHash stopped matching and every one
|
||||
// of their commands failed with "setup is out of date" until each was re-run
|
||||
// from an elevated terminal. They had opted into nothing.
|
||||
//
|
||||
// The hashes are compared rather than the SID lists because the hash is what the
|
||||
// marker actually stores and what validation compares.
|
||||
func TestOfflineGroupDoesNotChangeAnOptedOutHomesInfraHash(t *testing.T) {
|
||||
previous := resolveWindowsSandboxOfflineGroupSIDHook
|
||||
t.Cleanup(func() { resolveWindowsSandboxOfflineGroupSIDHook = previous })
|
||||
|
||||
optedOut := WindowsSandboxCommandConfig{SandboxHome: t.TempDir()}
|
||||
|
||||
// No group on the machine yet: the state before anyone opted in.
|
||||
resolveWindowsSandboxOfflineGroupSIDHook = nil
|
||||
before, err := BuildWindowsNetworkInfraPlan(optedOut)
|
||||
if err != nil {
|
||||
t.Fatalf("build plan before any opt-in: %v", err)
|
||||
}
|
||||
beforeHash, err := WindowsNetworkInfraHash(before)
|
||||
if err != nil {
|
||||
t.Fatalf("hash before: %v", err)
|
||||
}
|
||||
|
||||
// Another workspace has now opted in and created the machine-global group.
|
||||
resolveWindowsSandboxOfflineGroupSIDHook = func() (string, error) {
|
||||
return "S-1-5-21-1111111111-1111111111-1111111111-4002", nil
|
||||
}
|
||||
after, err := BuildWindowsNetworkInfraPlan(optedOut)
|
||||
if err != nil {
|
||||
t.Fatalf("build plan after another home opted in: %v", err)
|
||||
}
|
||||
afterHash, err := WindowsNetworkInfraHash(after)
|
||||
if err != nil {
|
||||
t.Fatalf("hash after: %v", err)
|
||||
}
|
||||
|
||||
if beforeHash != afterHash {
|
||||
t.Fatalf("another workspace opting in changed this home's infra hash (%s -> %s), so its stored marker stops validating and every command fails until it is re-set-up",
|
||||
beforeHash, afterHash)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The offline group's SID goes onto the persistent WFP deny filters and the
|
||||
// sandbox principal is made a member of it. Adopting a same-named group that
|
||||
// something else owns therefore cuts off every existing member's outbound
|
||||
// traffic and hands our principal that group's permissions, so an unmarked
|
||||
// group has to be refused rather than reused.
|
||||
func TestEnsureWindowsLocalGroupRefusesForeignSameNameGroup(t *testing.T) {
|
||||
for name, testCase := range map[string]struct {
|
||||
status uintptr
|
||||
owned bool
|
||||
ownedErr error
|
||||
wantError string
|
||||
}{
|
||||
"foreign group with our name": {
|
||||
status: nerrGroupExists, owned: false,
|
||||
wantError: "not managed by zero",
|
||||
},
|
||||
"foreign group reported via ERROR_ALIAS_EXISTS": {
|
||||
status: errorAliasExists, owned: false,
|
||||
wantError: "not managed by zero",
|
||||
},
|
||||
"our own group on a re-run": {
|
||||
status: nerrGroupExists, owned: true,
|
||||
},
|
||||
"ownership lookup fails": {
|
||||
status: nerrGroupExists, ownedErr: errors.New("lookup refused"),
|
||||
wantError: "lookup refused",
|
||||
},
|
||||
"group did not exist": {
|
||||
status: nerrSuccess,
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn
|
||||
t.Cleanup(func() {
|
||||
addWindowsLocalGroupFn = prevAdd
|
||||
windowsLocalGroupOwnedByZeroFn = prevOwned
|
||||
})
|
||||
addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return testCase.status, nil }
|
||||
lookups := 0
|
||||
windowsLocalGroupOwnedByZeroFn = func(string, string) (bool, error) {
|
||||
lookups++
|
||||
return testCase.owned, testCase.ownedErr
|
||||
}
|
||||
|
||||
err := ensureWindowsSandboxOfflineGroup()
|
||||
if testCase.wantError == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("ensureWindowsSandboxOfflineGroup: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("adopted a group it should have refused (lookups=%d)", lookups)
|
||||
}
|
||||
if !strings.Contains(err.Error(), testCase.wantError) {
|
||||
t.Fatalf("error = %q, want it to mention %q", err, testCase.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The marker compared against is the offline group's own comment, not the
|
||||
// principals group's: two managed groups with different markers must not be
|
||||
// mistaken for each other.
|
||||
func TestEnsureWindowsLocalGroupChecksTheGroupsOwnMarker(t *testing.T) {
|
||||
prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn
|
||||
t.Cleanup(func() {
|
||||
addWindowsLocalGroupFn = prevAdd
|
||||
windowsLocalGroupOwnedByZeroFn = prevOwned
|
||||
})
|
||||
addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return nerrGroupExists, nil }
|
||||
var gotName, gotComment string
|
||||
windowsLocalGroupOwnedByZeroFn = func(name, comment string) (bool, error) {
|
||||
gotName, gotComment = name, comment
|
||||
return true, nil
|
||||
}
|
||||
if err := ensureWindowsSandboxOfflineGroup(); err != nil {
|
||||
t.Fatalf("ensureWindowsSandboxOfflineGroup: %v", err)
|
||||
}
|
||||
if gotName != windowsSandboxOfflineGroupName {
|
||||
t.Fatalf("checked group %q, want %q", gotName, windowsSandboxOfflineGroupName)
|
||||
}
|
||||
if gotComment != windowsSandboxOfflineGroupComment {
|
||||
t.Fatalf("compared marker %q, want %q", gotComment, windowsSandboxOfflineGroupComment)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Choosing the offline account is only half of what denies it the network: the
|
||||
// WFP filters match the offline GROUP'S SID. An account that has drifted out of
|
||||
// that group still logs on from its stored secret, and its token no longer
|
||||
// satisfies the filter condition — so a no-network profile would get full
|
||||
// egress. The command path has to re-check the membership, not trust the marker
|
||||
// setup wrote when it last succeeded.
|
||||
func TestPrincipalTokenRechecksOfflineGroupMembership(t *testing.T) {
|
||||
for name, testCase := range map[string]struct {
|
||||
mode NetworkMode
|
||||
member bool
|
||||
memberErr error
|
||||
wantChecked bool
|
||||
wantReached bool
|
||||
wantErr bool
|
||||
}{
|
||||
"offline principal drifted out of the group": {
|
||||
mode: NetworkDeny, member: false,
|
||||
wantChecked: true, wantReached: false,
|
||||
},
|
||||
"offline principal still a member": {
|
||||
mode: NetworkDeny, member: true,
|
||||
wantChecked: true, wantReached: true,
|
||||
},
|
||||
"membership lookup fails": {
|
||||
mode: NetworkDeny, memberErr: errors.New("group lookup refused"),
|
||||
wantChecked: true, wantReached: false, wantErr: true,
|
||||
},
|
||||
// An allow-network command uses the online principal, which is not in the
|
||||
// offline group by design. Checking it there would refuse every approved
|
||||
// network command.
|
||||
"online principal is not checked": {
|
||||
mode: NetworkAllow, member: false,
|
||||
wantChecked: false, wantReached: true,
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
prevLookup := lookupWindowsSandboxPrincipalForCommandFn
|
||||
prevMember := windowsSandboxUserInLocalGroupFn
|
||||
prevSecret := readWindowsSandboxSecretFn
|
||||
prevWarn := warnWindowsSandboxOfflineMembershipMissing
|
||||
t.Cleanup(func() {
|
||||
lookupWindowsSandboxPrincipalForCommandFn = prevLookup
|
||||
windowsSandboxUserInLocalGroupFn = prevMember
|
||||
readWindowsSandboxSecretFn = prevSecret
|
||||
warnWindowsSandboxOfflineMembershipMissing = prevWarn
|
||||
})
|
||||
|
||||
sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lookupWindowsSandboxPrincipalForCommandFn = func(string, windowsSandboxRole) (windowsSandboxIdentity, error) {
|
||||
return windowsSandboxIdentity{Username: "zero-sbx-test", SID: sid}, nil
|
||||
}
|
||||
checkedGroup := ""
|
||||
windowsSandboxUserInLocalGroupFn = func(_ string, group string) (bool, error) {
|
||||
checkedGroup = group
|
||||
return testCase.member, testCase.memberErr
|
||||
}
|
||||
secretRead := false
|
||||
readWindowsSandboxSecretFn = func(string) (string, error) {
|
||||
secretRead = true
|
||||
return "", errWindowsSandboxIdentityUnavailable
|
||||
}
|
||||
warnWindowsSandboxOfflineMembershipMissing = func(string) {}
|
||||
|
||||
config := windowsSandboxTestConfig()
|
||||
config.Env = map[string]string{windowsSandboxIdentityEnv: "1"}
|
||||
config.PermissionProfile.Network.Mode = testCase.mode
|
||||
|
||||
_, ok, err := windowsSandboxPrincipalToken(config)
|
||||
if testCase.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("a failed membership lookup was swallowed")
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("windowsSandboxPrincipalToken: %v", err)
|
||||
}
|
||||
_ = ok
|
||||
if secretRead != testCase.wantReached {
|
||||
t.Fatalf("reached the secret read = %v, want %v (gate must short-circuit before it)", secretRead, testCase.wantReached)
|
||||
}
|
||||
if checked := checkedGroup != ""; checked != testCase.wantChecked {
|
||||
t.Fatalf("membership checked = %v, want %v", checked, testCase.wantChecked)
|
||||
}
|
||||
if testCase.wantChecked && checkedGroup != windowsSandboxOfflineGroupName {
|
||||
t.Fatalf("checked group %q, want %q", checkedGroup, windowsSandboxOfflineGroupName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,25 @@ func TestWindowsRuntimeTokenSIDs(t *testing.T) {
|
||||
// setup serves both modes (and its fingerprint is stable across modes).
|
||||
func TestBuildWindowsNetworkInfraPlanIsModeIndependent(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
// Pinned, because the count below depends on whether this machine happens to
|
||||
// have the offline group already. BuildWindowsNetworkInfraPlan folds that
|
||||
// group's SID in when it resolves, so on a Windows host where an earlier
|
||||
// elevated setup created ZeroSandboxOffline the plan legitimately carries two
|
||||
// identity SIDs and this test failed on a correct plan. CI never saw it: the
|
||||
// Linux and macOS jobs leave the hook nil, and a fresh Windows runner has no
|
||||
// group yet. Stubbing it makes the assertion about the plan rather than about
|
||||
// the machine it runs on.
|
||||
previousHook := resolveWindowsSandboxOfflineGroupSIDHook
|
||||
t.Cleanup(func() { resolveWindowsSandboxOfflineGroupSIDHook = previousHook })
|
||||
resolveWindowsSandboxOfflineGroupSIDHook = nil
|
||||
|
||||
mk := func(mode NetworkMode) WindowsSandboxCommandConfig {
|
||||
return WindowsSandboxCommandConfig{
|
||||
SandboxHome: home,
|
||||
SandboxHome: home,
|
||||
// Opted in, because the offline group only enters the plan for a home
|
||||
// that asked for principals. Without this the plan is the pre-principal
|
||||
// one and the group assertions below have nothing to find.
|
||||
Env: map[string]string{windowsSandboxIdentityEnv: "1"},
|
||||
CommandCWD: `C:\ws`,
|
||||
WorkspaceRoots: []string{`C:\ws`},
|
||||
PermissionProfile: PermissionProfile{
|
||||
@@ -72,6 +88,40 @@ func TestBuildWindowsNetworkInfraPlanIsModeIndependent(t *testing.T) {
|
||||
if denyPlan.IdentitySIDs[0] != offline {
|
||||
t.Errorf("infra plan SID = %q, want offline-marker %q", denyPlan.IdentitySIDs[0], offline)
|
||||
}
|
||||
|
||||
// Mode independence has to hold on a machine that already has the offline
|
||||
// group too, which is where the pinned counts above would otherwise be
|
||||
// asserting something about the host rather than the plan. The group SID is
|
||||
// folded in for both modes, so the hashes must still agree; only the count
|
||||
// changes.
|
||||
resolveWindowsSandboxOfflineGroupSIDHook = func() (string, error) { return "S-1-5-32-9999", nil }
|
||||
withGroupDeny, err := BuildWindowsNetworkInfraPlan(mk(NetworkDeny))
|
||||
if err != nil {
|
||||
t.Fatalf("deny infra plan with the group present: %v", err)
|
||||
}
|
||||
withGroupAllow, err := BuildWindowsNetworkInfraPlan(mk(NetworkAllow))
|
||||
if err != nil {
|
||||
t.Fatalf("allow infra plan with the group present: %v", err)
|
||||
}
|
||||
// Assert the SIDs themselves, not the count. A duplicate offline marker or an
|
||||
// unrelated SID would satisfy a length check while meaning something quite
|
||||
// different.
|
||||
if len(withGroupDeny.IdentitySIDs) != 2 ||
|
||||
withGroupDeny.IdentitySIDs[0] != offline ||
|
||||
withGroupDeny.IdentitySIDs[1] != "S-1-5-32-9999" {
|
||||
t.Fatalf("group present should add its SID after the offline marker, got %v (offline marker %q)", withGroupDeny.IdentitySIDs, offline)
|
||||
}
|
||||
groupDenyHash, _ := WindowsNetworkInfraHash(withGroupDeny)
|
||||
groupAllowHash, _ := WindowsNetworkInfraHash(withGroupAllow)
|
||||
if groupDenyHash != groupAllowHash || groupDenyHash == "" {
|
||||
t.Fatalf("infra hash must stay mode-independent with the group present: deny=%q allow=%q", groupDenyHash, groupAllowHash)
|
||||
}
|
||||
// And it must differ from the no-group hash, which is the cross-workspace
|
||||
// coupling called out for a maintainer decision: one workspace creating the
|
||||
// group changes every other sandbox home's expected fingerprint.
|
||||
if groupDenyHash == denyHash {
|
||||
t.Error("group presence did not change the infra hash; the marker staleness this causes is a deliberate property and should be visible here")
|
||||
}
|
||||
}
|
||||
|
||||
// A pre-existing schema-1 capability file (no Offline SID) is upgraded in place:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// windowsPrincipalIdentityEnvironment rewrites the variables that name WHO the
|
||||
// command is running as.
|
||||
//
|
||||
// The child environment starts life as the invoking user's, and the deliberate
|
||||
// sandbox redirects only replace HOME, the temp variables and the per-tool cache
|
||||
// dirs. Everything identifying the account was left alone, so a command running
|
||||
// as the principal still read USERPROFILE, APPDATA, LOCALAPPDATA, HOMEDRIVE,
|
||||
// HOMEPATH and USERNAME describing the CALLER, whose profile the principal
|
||||
// deliberately cannot open. Native tools resolve config and per-user state
|
||||
// through exactly those, so they either fail during startup or, worse, quietly
|
||||
// look somewhere they have no business reading.
|
||||
//
|
||||
// The values point into the sandbox runtime tree rather than at the principal's
|
||||
// real Windows profile. That tree is already granted to the principal and
|
||||
// already holds its caches, so the paths are writable by construction. Naming
|
||||
// the real profile would need LoadUserProfile to have run, and a variable
|
||||
// pointing at a directory that does not exist yet is a worse answer than one
|
||||
// pointing somewhere usable.
|
||||
//
|
||||
// Layered UNDER the deliberate redirects: HOME, TMPDIR, TMP and TEMP are not
|
||||
// touched here, so sandboxRuntimeEnvironment stays the single owner of those.
|
||||
func windowsPrincipalIdentityEnvironment(env map[string]string, username string, runtime *SandboxRuntime) map[string]string {
|
||||
username = strings.TrimSpace(username)
|
||||
if env == nil || username == "" || runtime == nil {
|
||||
return env
|
||||
}
|
||||
base := strings.TrimSpace(runtime.Data)
|
||||
if base == "" {
|
||||
base = strings.TrimSpace(runtime.Root)
|
||||
}
|
||||
if base == "" {
|
||||
// No runtime tree means no writable place to point at, and inventing one
|
||||
// outside the grants would only move the failure. Leaving the caller's
|
||||
// values would be worse, so name the account and stop there.
|
||||
env["USERNAME"] = username
|
||||
return env
|
||||
}
|
||||
|
||||
profile := filepath.Join(base, "profile")
|
||||
env["USERPROFILE"] = profile
|
||||
env["APPDATA"] = filepath.Join(profile, "AppData", "Roaming")
|
||||
env["LOCALAPPDATA"] = filepath.Join(profile, "AppData", "Local")
|
||||
env["USERNAME"] = username
|
||||
if host, err := os.Hostname(); err == nil && strings.TrimSpace(host) != "" {
|
||||
// A local account's domain is the machine. Left as the caller's it would
|
||||
// name a domain the principal is not a member of.
|
||||
env["USERDOMAIN"] = host
|
||||
}
|
||||
// HOMEDRIVE and HOMEPATH are a split of the same location, and tools join
|
||||
// them back together, so they have to be split from the value above rather
|
||||
// than carried over from the caller.
|
||||
if volume := filepath.VolumeName(profile); volume != "" {
|
||||
env["HOMEDRIVE"] = volume
|
||||
env["HOMEPATH"] = strings.TrimPrefix(profile, volume)
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// callerEnvironment is the shape the child starts from: the invoking user's
|
||||
// values, with the deliberate sandbox redirects already layered on top.
|
||||
func callerEnvironment() map[string]string {
|
||||
return map[string]string{
|
||||
"USERPROFILE": `C:\Users\caller`,
|
||||
"APPDATA": `C:\Users\caller\AppData\Roaming`,
|
||||
"LOCALAPPDATA": `C:\Users\caller\AppData\Local`,
|
||||
"HOMEDRIVE": `C:`,
|
||||
"HOMEPATH": `\Users\caller`,
|
||||
"USERNAME": "caller",
|
||||
"USERDOMAIN": "CALLERDOMAIN",
|
||||
// Owned by sandboxRuntimeEnvironment, and this must not touch them.
|
||||
"HOME": `C:\runtime\home`,
|
||||
"TEMP": `C:\runtime\temp`,
|
||||
"TMP": `C:\runtime\temp`,
|
||||
}
|
||||
}
|
||||
|
||||
// NOTHING MAY STILL NAME THE CALLER.
|
||||
//
|
||||
// A principal is a separate account that deliberately cannot open the invoking
|
||||
// user's profile, so every variable a native tool resolves per-user state
|
||||
// through has to describe the principal instead. Left alone they pointed at the
|
||||
// caller, and tools either failed during startup or looked somewhere they had no
|
||||
// business reading.
|
||||
func TestPrincipalEnvironmentStopsNamingTheCaller(t *testing.T) {
|
||||
runtime := &SandboxRuntime{
|
||||
Root: `C:\runtime`,
|
||||
Data: `C:\runtime\data`,
|
||||
}
|
||||
env := windowsPrincipalIdentityEnvironment(callerEnvironment(), "zero-sbx-abc123", runtime)
|
||||
|
||||
for _, key := range []string{"USERPROFILE", "APPDATA", "LOCALAPPDATA"} {
|
||||
if strings.Contains(strings.ToLower(env[key]), "caller") {
|
||||
t.Errorf("%s still points into the caller's profile: %s", key, env[key])
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(env[key]), strings.ToLower(runtime.Data)) {
|
||||
t.Errorf("%s is not inside the runtime tree the principal can write: %s", key, env[key])
|
||||
}
|
||||
}
|
||||
if strings.Contains(strings.ToLower(env["HOMEPATH"]), "caller") {
|
||||
t.Errorf("HOMEPATH still points into the caller's profile: %s", env["HOMEPATH"])
|
||||
}
|
||||
if env["USERNAME"] != "zero-sbx-abc123" {
|
||||
t.Errorf("USERNAME = %q, want the principal account", env["USERNAME"])
|
||||
}
|
||||
if env["USERDOMAIN"] == "CALLERDOMAIN" {
|
||||
t.Error("USERDOMAIN still names the caller's domain, which the principal is not a member of")
|
||||
}
|
||||
// HOMEDRIVE and HOMEPATH are rejoined by tools, so they have to describe the
|
||||
// same location as USERPROFILE rather than a mix of both identities.
|
||||
if rejoined := env["HOMEDRIVE"] + env["HOMEPATH"]; rejoined != env["USERPROFILE"] {
|
||||
t.Errorf("HOMEDRIVE+HOMEPATH = %s, want the same location as USERPROFILE %s", rejoined, env["USERPROFILE"])
|
||||
}
|
||||
}
|
||||
|
||||
// The deliberate redirects stay owned by sandboxRuntimeEnvironment. Clobbering
|
||||
// them here would silently move the sandbox's temp and cache handling into a
|
||||
// second place, which is how the two drift apart.
|
||||
func TestPrincipalEnvironmentLeavesTheSandboxRedirectsAlone(t *testing.T) {
|
||||
before := callerEnvironment()
|
||||
env := windowsPrincipalIdentityEnvironment(callerEnvironment(), "zero-sbx-abc123", &SandboxRuntime{
|
||||
Root: `C:\runtime`,
|
||||
Data: `C:\runtime\data`,
|
||||
})
|
||||
for _, key := range []string{"HOME", "TEMP", "TMP"} {
|
||||
if env[key] != before[key] {
|
||||
t.Errorf("%s = %q, want it left at the sandbox redirect %q", key, env[key], before[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Without a runtime tree there is nowhere writable to point at, and inventing a
|
||||
// path outside the grants would only move the failure. Naming the account is
|
||||
// still strictly better than advertising the caller's.
|
||||
func TestPrincipalEnvironmentWithoutARuntimeTreeStillRenamesTheAccount(t *testing.T) {
|
||||
env := windowsPrincipalIdentityEnvironment(callerEnvironment(), "zero-sbx-abc123", &SandboxRuntime{})
|
||||
if env["USERNAME"] != "zero-sbx-abc123" {
|
||||
t.Errorf("USERNAME = %q, want the principal account even with no runtime tree", env["USERNAME"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package sandbox
|
||||
|
||||
import "testing"
|
||||
|
||||
// THE FINGERPRINT MUST DESCRIBE THE PLAN THAT ACTUALLY GETS APPLIED.
|
||||
//
|
||||
// windowsPrincipalPlanFingerprint builds a principal plan to hash into the setup
|
||||
// marker, and it was the one of three buildWindowsPrincipalACLPlan call sites
|
||||
// that did not pass DenyWrite. Apply and teardown both did. So a change to the
|
||||
// policy's deny-write paths moved the plan those two build and left the marker's
|
||||
// hash unchanged, which is the marker failing at the one job it has.
|
||||
//
|
||||
// It was not a live hole, because the capability ACLPlanHash covers the same
|
||||
// paths and moves the marker anyway. That is exactly what would have kept it
|
||||
// invisible until somebody changed the capability plan's shape.
|
||||
func TestThePrincipalFingerprintCoversDenyWrite(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
base := WindowsSandboxSetupConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PrincipalOptIn: true,
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: workspace}},
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
},
|
||||
}
|
||||
|
||||
without, err := windowsPrincipalPlanFingerprint(base)
|
||||
if err != nil {
|
||||
t.Fatalf("windowsPrincipalPlanFingerprint: %v", err)
|
||||
}
|
||||
|
||||
withDeny := base
|
||||
withDeny.PermissionProfile.FileSystem.DenyWrite = []string{workspace + string('/') + "protected"}
|
||||
changed, err := windowsPrincipalPlanFingerprint(withDeny)
|
||||
if err != nil {
|
||||
t.Fatalf("windowsPrincipalPlanFingerprint: %v", err)
|
||||
}
|
||||
|
||||
if without == changed {
|
||||
t.Error("adding a deny-write path did not move the principal fingerprint, so the marker cannot tell that the applied plan changed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE ACCOUNT SID MUST NOT BE A RESTRICTING SID.
|
||||
//
|
||||
// WRITE_RESTRICTED allows a write only when the normal token AND the
|
||||
// restricting-SID list both allow it. The principal's account SID is already
|
||||
// enabled in its normal token, so listing it as a restricting SID makes the
|
||||
// second check a formality for anything granted to that account: every path
|
||||
// carrying a direct principal ACE passes both halves and is writable wherever it
|
||||
// sits, including the profile directory Windows creates on first logon outside
|
||||
// every configured write root.
|
||||
//
|
||||
// Same shape as the World SID in the restricted list (#865). A SID already
|
||||
// carried by the normal token cannot also restrict it.
|
||||
//
|
||||
// The pre-existing jail test could not catch this: it grants a GROUP
|
||||
// (WinBuiltinGuestsSid), so it exercises a configuration the product never
|
||||
// ships. This asserts the composition that actually ships.
|
||||
func TestPrincipalJailExcludesTheAccountsOwnSID(t *testing.T) {
|
||||
accountSID := "S-1-5-21-9-9-9-1500"
|
||||
// The account SID is planted IN the capability list on purpose. Passing a
|
||||
// list that never contained it would make this test unfalsifiable: it would
|
||||
// pass against any implementation, including one that appends the account SID
|
||||
// straight back. This is the route the World SID took in #865.
|
||||
capabilities := []string{"S-1-5-21-1-2-3-1001", accountSID, "S-1-5-21-1-2-3-1002"}
|
||||
|
||||
jail := windowsPrincipalJailSIDs(capabilities, accountSID)
|
||||
|
||||
for _, sid := range jail {
|
||||
if strings.EqualFold(sid, accountSID) {
|
||||
t.Fatalf("the principal's own account SID is a restricting SID, so any path with a direct principal ACE escapes the write jail: %v", jail)
|
||||
}
|
||||
}
|
||||
// ...and the capability SIDs are still all present, or the principal would be
|
||||
// jailed out of the workspace it owns and the fix would be a denial of service
|
||||
// rather than a fix.
|
||||
for _, want := range []string{"S-1-5-21-1-2-3-1001", "S-1-5-21-1-2-3-1002"} {
|
||||
if !containsString(jail, want) {
|
||||
t.Errorf("capability SID %q missing from the jail, so the principal loses write access to its own workspace: %v", want, jail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The jail must not alias the caller's slice. Appending to a shared backing
|
||||
// array would let a later append mutate the restricting set of a token already
|
||||
// being built.
|
||||
//
|
||||
// Both assertions read through to the BACKING ARRAY, which is the only place
|
||||
// aliasing is observable. An earlier version of this test appended a sentinel and
|
||||
// then ranged over the caller's slice looking for it, which can never fail:
|
||||
// append returns a new header, so the caller's length never grows and the range
|
||||
// never reaches the slot the write landed in. It passed against every
|
||||
// implementation, including one that returns the caller's slice verbatim.
|
||||
func TestPrincipalJailDoesNotAliasTheCapabilitySlice(t *testing.T) {
|
||||
capabilities := make([]string, 2, 8)
|
||||
capabilities[0] = "S-1-5-21-1-2-3-1001"
|
||||
capabilities[1] = "S-1-5-21-1-2-3-1002"
|
||||
|
||||
jail := windowsPrincipalJailSIDs(capabilities, "S-1-5-21-9-9-9-1500")
|
||||
if len(jail) == 0 {
|
||||
t.Fatal("jail is empty, so neither assertion below proves anything")
|
||||
}
|
||||
// Shared element storage: a mutation through jail[0] would rewrite a SID in
|
||||
// the restricting set of a token already being built.
|
||||
if &jail[0] == &capabilities[0] {
|
||||
t.Fatal("jail shares element storage with the caller's capability slice")
|
||||
}
|
||||
// Spare capacity: appending to an aliased jail writes into the caller's
|
||||
// backing array past its length, which is invisible through the caller's own
|
||||
// header but very visible to anything else holding a longer view of it.
|
||||
grown := append(jail, "S-1-1-0")
|
||||
shared := capabilities[:cap(capabilities)]
|
||||
if shared[len(capabilities)] == "S-1-1-0" {
|
||||
t.Fatalf("appending to the jail wrote through into the caller's backing array: jail=%v shared=%v",
|
||||
grown, shared[:len(capabilities)+1])
|
||||
}
|
||||
if len(capabilities) != 2 {
|
||||
t.Errorf("caller's slice grew to %d", len(capabilities))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// The principal path must apply the write jail, not just hand over the account's
|
||||
// own token. A LogonUser token carries every write the account's ambient
|
||||
// memberships grant, so without this the profile's write roots are advisory.
|
||||
//
|
||||
// Driven with the process token as base because minting a real principal token
|
||||
// needs an elevated, provisioned machine; the restriction machinery under test
|
||||
// is identical either way.
|
||||
func TestRestrictWindowsTokenJailsWritesOutsideCapabilitySIDs(t *testing.T) {
|
||||
var base windows.Token
|
||||
desired := uint32(windows.TOKEN_DUPLICATE | windows.TOKEN_QUERY | windows.TOKEN_ASSIGN_PRIMARY |
|
||||
windows.TOKEN_ADJUST_DEFAULT | windows.TOKEN_ADJUST_SESSIONID | windows.TOKEN_ADJUST_PRIVILEGES)
|
||||
if err := windows.OpenProcessToken(windows.CurrentProcess(), desired, &base); err != nil {
|
||||
t.Skipf("cannot open the process token here: %v", err)
|
||||
}
|
||||
defer base.Close()
|
||||
|
||||
// A capability SID granted nowhere near the probe directory.
|
||||
capSID, err := windows.CreateWellKnownSid(windows.WinBuiltinGuestsSid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jailed, err := restrictWindowsTokenForCapabilitySIDs(base, []string{capSID.String()}, true)
|
||||
if err != nil {
|
||||
t.Skipf("cannot build a restricted token here: %v", err)
|
||||
}
|
||||
defer jailed.Close()
|
||||
|
||||
// Setup assertion: the unrestricted process can write here, so a denial below
|
||||
// is the jail and not a broken fixture.
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "written.txt")
|
||||
if err := os.WriteFile(target, []byte("probe"), 0o600); err != nil {
|
||||
t.Fatalf("SETUP INVALID: the test process itself cannot write %s: %v", target, err)
|
||||
}
|
||||
if err := os.Remove(target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
config := WindowsSandboxCommandConfig{
|
||||
CommandCWD: dir,
|
||||
WorkspaceRoots: []string{dir},
|
||||
Command: []string{"cmd", "/c", "echo probe> " + target},
|
||||
}
|
||||
if _, err := runWindowsCommandAsUser(jailed, config); err != nil {
|
||||
t.Fatalf("run under the jailed token: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
t.Error("the jailed token wrote a path no capability SID covers; the write jail is not applied")
|
||||
}
|
||||
}
|
||||
|
||||
// parseWindowsCapabilitySIDs must reject an empty list rather than build an
|
||||
// unrestricted token, and must not leak the SIDs it already parsed on failure.
|
||||
func TestParseWindowsCapabilitySIDsRejectsEmptyAndBadInput(t *testing.T) {
|
||||
if _, err := parseWindowsCapabilitySIDs(nil); err == nil {
|
||||
t.Error("empty SID list accepted; that would build a token with no restriction")
|
||||
}
|
||||
valid, err := windows.CreateWellKnownSid(windows.WinBuiltinGuestsSid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := parseWindowsCapabilitySIDs([]string{valid.String(), "not-a-sid"}); err == nil {
|
||||
t.Error("an unparseable SID was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Privileges CreateProcessAsUser requires when the token belongs to a DIFFERENT
|
||||
// account than the caller.
|
||||
//
|
||||
// The documented exemption from SE_ASSIGNPRIMARYTOKEN_NAME is for a restricted
|
||||
// version of the caller's OWN primary token, which is exactly what the ordinary
|
||||
// restricted-token path passes and exactly why that path works without holding
|
||||
// anything special. A principal token comes from LogonUser against a separate
|
||||
// local account, so the exemption does not apply and both privileges are back in
|
||||
// play.
|
||||
const (
|
||||
seAssignPrimaryTokenPrivilege = "SeAssignPrimaryTokenPrivilege"
|
||||
seIncreaseQuotaPrivilege = "SeIncreaseQuotaPrivilege"
|
||||
)
|
||||
|
||||
// enableWindowsPrincipalLaunchPrivileges enables what launching another account
|
||||
// needs, and reports precisely what is missing when it cannot.
|
||||
//
|
||||
// Enabling matters on its own: a privilege present in a token but DISABLED still
|
||||
// fails the access check, and an elevated administrator typically holds
|
||||
// SeIncreaseQuotaPrivilege in exactly that state. So this is not only a
|
||||
// diagnostic, it is the step that makes the call work wherever the rights are
|
||||
// actually held.
|
||||
//
|
||||
// Where they are not held, which is the ordinary unelevated case, this turns a
|
||||
// bare "Access is denied" from deep inside process creation into a statement of
|
||||
// what the sandbox cannot do and why. That failure is otherwise indistinguishable
|
||||
// from the command itself being rejected, and it happens before the command's
|
||||
// executable is ever opened.
|
||||
func enableWindowsPrincipalLaunchPrivileges() error {
|
||||
var token windows.Token
|
||||
if err := windows.OpenProcessToken(
|
||||
windows.CurrentProcess(),
|
||||
windows.TOKEN_ADJUST_PRIVILEGES|windows.TOKEN_QUERY,
|
||||
&token,
|
||||
); err != nil {
|
||||
return fmt.Errorf("open process token for principal launch: %w", err)
|
||||
}
|
||||
defer token.Close()
|
||||
|
||||
// ENUMERATE the token, do not infer from AdjustTokenPrivileges.
|
||||
//
|
||||
// AdjustTokenPrivileges reports a privilege the token does not hold by
|
||||
// returning success and setting ERROR_NOT_ALL_ASSIGNED, and this binding does
|
||||
// not surface that: it returns nil for SeTcbPrivilege on an ordinary user
|
||||
// process, which holds nothing of the sort. A check built on its error would
|
||||
// pass on every machine, which is worse than no check, because it would report
|
||||
// the sandbox ready in exactly the case it cannot run.
|
||||
held, err := windowsTokenPrivilegeLUIDs(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var missing []string
|
||||
for _, name := range []string{seAssignPrimaryTokenPrivilege, seIncreaseQuotaPrivilege} {
|
||||
luid, err := windowsPrivilegeLUID(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := held[luid]; !ok {
|
||||
missing = append(missing, name)
|
||||
continue
|
||||
}
|
||||
// Held but possibly disabled, which still fails the access check. This is
|
||||
// the case an elevated administrator lands in.
|
||||
if err := enableWindowsTokenPrivilege(token, name); err != nil {
|
||||
return fmt.Errorf("enable %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return principalLaunchPrivilegeError(missing)
|
||||
}
|
||||
|
||||
// principalLaunchPrivilegeError renders the refusal for a set of missing
|
||||
// privileges, and is separate from the token work so it can be driven for every
|
||||
// combination.
|
||||
//
|
||||
// It has to be, because WHICH privilege is missing depends on the account
|
||||
// running the test. An ordinary unelevated user typically holds neither, but not
|
||||
// always: this developer box holds SeAssignPrimaryTokenPrivilege and not
|
||||
// SeIncreaseQuotaPrivilege, so a test that named one of them and ran the real
|
||||
// preflight asserted a machine's configuration rather than the code. Splitting
|
||||
// the rendering out makes each case deterministic and leaves the token path with
|
||||
// only the question that genuinely varies.
|
||||
func principalLaunchPrivilegeError(missing []string) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"launching a command as the sandbox principal needs %s, which this process does not hold. "+
|
||||
"CreateProcessAsUser exempts only a restricted version of the caller's own token, and a principal "+
|
||||
"is a separate account, so an ordinary unelevated process cannot start one. "+
|
||||
"Unset %s to use the restricted-token sandbox, which needs no such privilege",
|
||||
strings.Join(missing, " and "), windowsSandboxIdentityEnv)
|
||||
}
|
||||
|
||||
// windowsPrivilegeLUID resolves a privilege name to its locally unique id, which
|
||||
// is how a token actually names the privileges it holds.
|
||||
func windowsPrivilegeLUID(name string) (windows.LUID, error) {
|
||||
namePtr, err := windows.UTF16PtrFromString(name)
|
||||
if err != nil {
|
||||
return windows.LUID{}, fmt.Errorf("encode privilege name %s: %w", name, err)
|
||||
}
|
||||
var luid windows.LUID
|
||||
if err := windows.LookupPrivilegeValue(nil, namePtr, &luid); err != nil {
|
||||
return windows.LUID{}, fmt.Errorf("look up privilege %s: %w", name, err)
|
||||
}
|
||||
return luid, nil
|
||||
}
|
||||
|
||||
// windowsTokenPrivilegeLUIDs returns every privilege the token holds, enabled or
|
||||
// not. Presence and enabled-ness are separate questions: a privilege absent from
|
||||
// this set can never be enabled, while one present but disabled only needs
|
||||
// AdjustTokenPrivileges.
|
||||
func windowsTokenPrivilegeLUIDs(token windows.Token) (map[windows.LUID]struct{}, error) {
|
||||
var size uint32
|
||||
// First call sizes the buffer and is expected to fail with
|
||||
// ERROR_INSUFFICIENT_BUFFER, so its error is deliberately not checked.
|
||||
_ = windows.GetTokenInformation(token, windows.TokenPrivileges, nil, 0, &size)
|
||||
if size == 0 {
|
||||
return nil, errors.New("query token privileges: zero-length result")
|
||||
}
|
||||
buffer := make([]byte, size)
|
||||
if err := windows.GetTokenInformation(token, windows.TokenPrivileges, &buffer[0], size, &size); err != nil {
|
||||
return nil, fmt.Errorf("query token privileges: %w", err)
|
||||
}
|
||||
privileges := (*windows.Tokenprivileges)(unsafe.Pointer(&buffer[0]))
|
||||
if privileges.PrivilegeCount == 0 {
|
||||
return map[windows.LUID]struct{}{}, nil
|
||||
}
|
||||
entries := unsafe.Slice(&privileges.Privileges[0], privileges.PrivilegeCount)
|
||||
held := make(map[windows.LUID]struct{}, len(entries))
|
||||
for _, entry := range entries {
|
||||
held[entry.Luid] = struct{}{}
|
||||
}
|
||||
return held, nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The launch preflight must name what is missing and what to do about it.
|
||||
//
|
||||
// Without it the failure surfaces as a bare "Access is denied" from inside
|
||||
// CreateProcessAsUser, which is indistinguishable from the sandboxed command
|
||||
// itself being rejected. It happens before the command's executable is opened,
|
||||
// so there is nothing else in the output to tell the two apart.
|
||||
//
|
||||
// WHICH privilege is missing is a property of the account running the test, not
|
||||
// of the code. An unelevated user often holds neither, but not always: the box
|
||||
// this was written on holds SeAssignPrimaryTokenPrivilege and not
|
||||
// SeIncreaseQuotaPrivilege. The previous version of this test ran the real
|
||||
// preflight and then asserted SeAssignPrimaryTokenPrivilege appeared in the
|
||||
// message, so it passed or failed on the tester's token rather than on
|
||||
// anything in the source, and it failed here for a reason that was never a
|
||||
// defect. Rendering is tested directly instead, once per combination.
|
||||
func TestPrincipalLaunchPrivilegeErrorNamesWhatIsMissing(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
missing []string
|
||||
}{
|
||||
{"neither held", []string{seAssignPrimaryTokenPrivilege, seIncreaseQuotaPrivilege}},
|
||||
{"only assign-primary-token missing", []string{seAssignPrimaryTokenPrivilege}},
|
||||
{"only increase-quota missing", []string{seIncreaseQuotaPrivilege}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := principalLaunchPrivilegeError(tc.missing)
|
||||
if err == nil {
|
||||
t.Fatal("a missing privilege produced no error")
|
||||
}
|
||||
for _, want := range tc.missing {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the message does not name %s, so an operator cannot act on it: %v", want, err)
|
||||
}
|
||||
}
|
||||
// And it must not name one that IS held, or the operator goes looking
|
||||
// for a privilege that was never the problem.
|
||||
for _, other := range []string{seAssignPrimaryTokenPrivilege, seIncreaseQuotaPrivilege} {
|
||||
if contains(tc.missing, other) {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(err.Error(), other) {
|
||||
t.Errorf("the message names %s, which this case holds: %v", other, err)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(err.Error(), windowsSandboxIdentityEnv) {
|
||||
t.Errorf("the message does not name the opt-out: %v", err)
|
||||
}
|
||||
// The point of the message is the way out. Without it the operator is
|
||||
// told only that something is denied.
|
||||
if !strings.Contains(err.Error(), "restricted-token sandbox") {
|
||||
t.Errorf("the message does not offer the fallback, so it reads as a dead end: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Holding both is not an error, or the sandbox would refuse on exactly the
|
||||
// machines it is meant to run on.
|
||||
func TestPrincipalLaunchPrivilegeErrorIsNilWhenNothingIsMissing(t *testing.T) {
|
||||
if err := principalLaunchPrivilegeError(nil); err != nil {
|
||||
t.Errorf("no missing privileges produced an error: %v", err)
|
||||
}
|
||||
if err := principalLaunchPrivilegeError([]string{}); err != nil {
|
||||
t.Errorf("an empty missing set produced an error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The real preflight against this machine's token. The verdict legitimately
|
||||
// differs by account, so the only thing asserted is that a refusal is
|
||||
// actionable: it names at least one of the two privileges rather than failing
|
||||
// with something the operator cannot use.
|
||||
func TestPrincipalLaunchPreflightRefusalIsActionable(t *testing.T) {
|
||||
err := enableWindowsPrincipalLaunchPrivileges()
|
||||
if err == nil {
|
||||
t.Log("this process holds the principal launch privileges; nothing to explain")
|
||||
return
|
||||
}
|
||||
if !strings.Contains(err.Error(), seAssignPrimaryTokenPrivilege) &&
|
||||
!strings.Contains(err.Error(), seIncreaseQuotaPrivilege) {
|
||||
t.Errorf("the preflight refused without naming either privilege: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Repeated calls must agree. This runs once per sandboxed command, and a
|
||||
// preflight that answered differently on the second call would let a command
|
||||
// through that the first call refused.
|
||||
func TestPrincipalLaunchPreflightIsStable(t *testing.T) {
|
||||
first := enableWindowsPrincipalLaunchPrivileges()
|
||||
second := enableWindowsPrincipalLaunchPrivileges()
|
||||
if (first == nil) != (second == nil) {
|
||||
t.Fatalf("preflight verdict changed between calls: first=%v second=%v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A record of the paths a sandbox principal was last granted ACEs on.
|
||||
//
|
||||
// applyWindowsPrincipalACLs revokes this trustee before it re-applies, but the
|
||||
// only paths it could name were the ones in the plan it was about to apply. A
|
||||
// root that LEFT the policy is absent from that plan, so its ACE survived the
|
||||
// re-setup marker validation forces and the principal kept write access the
|
||||
// current policy no longer grants — the sandbox widened as a result of being
|
||||
// tightened. Teardown repeated the same current-plan-only calculation, so it did
|
||||
// not clean the leftover either.
|
||||
//
|
||||
// Nothing on Windows can answer "which paths hold an ACE for this SID" without
|
||||
// walking every volume, so the grants have to be written down as they are made.
|
||||
//
|
||||
// Keyed by principal, beside the secret and for the same reason: one sandbox
|
||||
// home serves every workspace on the machine, so a single shared file would let
|
||||
// one workspace's setup overwrite another's record — reproducing exactly the
|
||||
// stale-ACE bug this exists to close, one level up.
|
||||
|
||||
const windowsPrincipalACLLedgerSchemaVersion = 1
|
||||
|
||||
const windowsPrincipalACLLedgerDirName = "windows-principal-acl"
|
||||
|
||||
type windowsPrincipalACLLedger struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
|
||||
func windowsPrincipalACLLedgerPath(sandboxHome string, username string) (string, error) {
|
||||
if strings.TrimSpace(sandboxHome) == "" {
|
||||
return "", errors.New("windows principal ACL ledger: empty sandbox home")
|
||||
}
|
||||
if strings.TrimSpace(username) == "" {
|
||||
return "", errors.New("windows principal ACL ledger: empty principal name")
|
||||
}
|
||||
// The same guard the secret path applies, against a caller passing something
|
||||
// windowsSandboxUserName did not produce.
|
||||
if strings.ContainsAny(username, `\/:`) || strings.Contains(username, "..") {
|
||||
return "", fmt.Errorf("windows principal ACL ledger: unsafe principal name %q", username)
|
||||
}
|
||||
return filepath.Join(sandboxHome, windowsPrincipalACLLedgerDirName, username+".json"), nil
|
||||
}
|
||||
|
||||
// readWindowsPrincipalACLLedger returns the paths an earlier setup recorded for
|
||||
// this principal.
|
||||
//
|
||||
// recorded is false for every reason the record cannot be trusted — absent,
|
||||
// unreadable, malformed, or written to a schema this build does not know — and
|
||||
// not merely for "absent". Collapsing them is deliberate: there is exactly one
|
||||
// safe response to any of them, and it is the same one. The prior grant set is
|
||||
// unknown, and a principal whose grants are unknown cannot be reused. Returning
|
||||
// an error instead would invite a caller to report it and carry on with an empty
|
||||
// prior set, which is the fail-open this record exists to close — the one case
|
||||
// where the previous paths are not empty but unenumerable.
|
||||
func readWindowsPrincipalACLLedger(sandboxHome string, username string) ([]string, bool) {
|
||||
path, err := windowsPrincipalACLLedgerPath(sandboxHome, username)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var ledger windowsPrincipalACLLedger
|
||||
if err := json.Unmarshal(contents, &ledger); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if ledger.SchemaVersion != windowsPrincipalACLLedgerSchemaVersion {
|
||||
return nil, false
|
||||
}
|
||||
return trimNonEmptyStrings(ledger.Paths), true
|
||||
}
|
||||
|
||||
// writeWindowsPrincipalACLLedger records paths for this principal, replacing any
|
||||
// previous record atomically so an interrupted write cannot leave a truncated
|
||||
// file — which the reader would then treat as "no principal was ever granted
|
||||
// anything", the very state it must never guess.
|
||||
func writeWindowsPrincipalACLLedger(sandboxHome string, username string, paths []string) error {
|
||||
path, err := windowsPrincipalACLLedgerPath(sandboxHome, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("create windows principal ACL ledger dir: %w", err)
|
||||
}
|
||||
contents, err := json.MarshalIndent(windowsPrincipalACLLedger{
|
||||
SchemaVersion: windowsPrincipalACLLedgerSchemaVersion,
|
||||
Paths: trimNonEmptyStrings(paths),
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal windows principal ACL ledger: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".windows-principal-acl-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create windows principal ACL ledger temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
if _, err := tmp.Write(contents); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("write windows principal ACL ledger temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("close windows principal ACL ledger temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("replace windows principal ACL ledger: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeWindowsPrincipalACLLedger drops the record. An absent one is not an
|
||||
// error: this runs on the teardown path, where being gone is the goal.
|
||||
func removeWindowsPrincipalACLLedger(sandboxHome string, username string) error {
|
||||
path, err := windowsPrincipalACLLedgerPath(sandboxHome, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("remove windows principal ACL ledger: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unionWindowsPrincipalACLPaths merges path sets for revocation, keeping the
|
||||
// first spelling of each path.
|
||||
//
|
||||
// Deduplication uses the same case-insensitive key the ACL plans use, so a root
|
||||
// recorded as C:\Ws by one setup and re-granted as c:\ws by the next is one path
|
||||
// to revoke rather than two.
|
||||
func unionWindowsPrincipalACLPaths(sets ...[]string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
union := make([]string, 0)
|
||||
for _, set := range sets {
|
||||
for _, path := range set {
|
||||
key := windowsCapabilityPathKey(path)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
union = append(union, path)
|
||||
}
|
||||
}
|
||||
return union
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The point of the record is that a LATER setup can name a root the CURRENT
|
||||
// policy no longer mentions, so the round trip has to survive the process that
|
||||
// wrote it having no memory of the paths.
|
||||
func TestPrincipalACLLedgerRoundTripsRecordedPaths(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
paths := []string{`C:\ws\alpha`, `C:\ws\beta`, `C:\cache\runtime`}
|
||||
if err := writeWindowsPrincipalACLLedger(home, "zerosbx01", paths); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
got, recorded := readWindowsPrincipalACLLedger(home, "zerosbx01")
|
||||
if !recorded {
|
||||
t.Fatal("a record this process just wrote read back as untrusted")
|
||||
}
|
||||
if strings.Join(got, "|") != strings.Join(paths, "|") {
|
||||
t.Errorf("read back %v, want %v", got, paths)
|
||||
}
|
||||
}
|
||||
|
||||
// One sandbox home serves every workspace on the machine. If the record were a
|
||||
// single shared file, workspace B's setup would overwrite workspace A's, and A's
|
||||
// dropped roots would then be unnameable at the next re-setup — the same stale
|
||||
// ACE this record exists to revoke, produced by the record itself.
|
||||
func TestPrincipalACLLedgerIsPerPrincipal(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
if err := writeWindowsPrincipalACLLedger(home, "zerosbx01", []string{`C:\ws\alpha`}); err != nil {
|
||||
t.Fatalf("write alpha: %v", err)
|
||||
}
|
||||
if err := writeWindowsPrincipalACLLedger(home, "zerosbx02", []string{`C:\ws\beta`}); err != nil {
|
||||
t.Fatalf("write beta: %v", err)
|
||||
}
|
||||
alpha, ok := readWindowsPrincipalACLLedger(home, "zerosbx01")
|
||||
if !ok || len(alpha) != 1 || alpha[0] != `C:\ws\alpha` {
|
||||
t.Errorf("first principal's record = %v (ok=%v); a second workspace's setup overwrote it", alpha, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Every untrustworthy record has to read as untrustworthy, not as "nothing was
|
||||
// ever granted". The caller retires the principal on false; treating a corrupt
|
||||
// file as an empty prior set is the fail-open.
|
||||
func TestPrincipalACLLedgerRefusesRecordsItCannotTrust(t *testing.T) {
|
||||
for name, contents := range map[string]string{
|
||||
"truncated mid-write": `{"schemaVersion": 1, "pat`,
|
||||
"not json at all": "\x00\x01garbage",
|
||||
"a schema from later": `{"schemaVersion": 99, "paths": ["C:\\ws"]}`,
|
||||
"a schema from before": `{"paths": ["C:\\ws"]}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
path, err := windowsPrincipalACLLedgerPath(home, "zerosbx01")
|
||||
if err != nil {
|
||||
t.Fatalf("path: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if paths, recorded := readWindowsPrincipalACLLedger(home, "zerosbx01"); recorded {
|
||||
t.Errorf("read a record it cannot interpret as trustworthy (%v); the caller would then revoke nothing", paths)
|
||||
}
|
||||
})
|
||||
}
|
||||
// And an absent one, which is the ordinary first-setup case.
|
||||
if _, recorded := readWindowsPrincipalACLLedger(t.TempDir(), "zerosbx01"); recorded {
|
||||
t.Error("a missing record read as trusted")
|
||||
}
|
||||
}
|
||||
|
||||
// A partial write must not be readable at all, which is why the file is renamed
|
||||
// into place rather than written in situ: a reader that saw half a record would
|
||||
// treat the missing half as never granted.
|
||||
func TestPrincipalACLLedgerWriteIsAtomic(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
if err := writeWindowsPrincipalACLLedger(home, "zerosbx01", []string{`C:\ws\alpha`, `C:\ws\beta`}); err != nil {
|
||||
t.Fatalf("first write: %v", err)
|
||||
}
|
||||
if err := writeWindowsPrincipalACLLedger(home, "zerosbx01", []string{`C:\ws\alpha`}); err != nil {
|
||||
t.Fatalf("second write: %v", err)
|
||||
}
|
||||
path, err := windowsPrincipalACLLedgerPath(home, "zerosbx01")
|
||||
if err != nil {
|
||||
t.Fatalf("path: %v", err)
|
||||
}
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
var ledger windowsPrincipalACLLedger
|
||||
if err := json.Unmarshal(contents, &ledger); err != nil {
|
||||
t.Fatalf("the replaced record did not parse: %v", err)
|
||||
}
|
||||
if len(ledger.Paths) != 1 {
|
||||
t.Errorf("record = %v, want the second write to have replaced the first outright", ledger.Paths)
|
||||
}
|
||||
// No temp files left behind to be mistaken for a record later.
|
||||
entries, err := os.ReadDir(filepath.Dir(path))
|
||||
if err != nil {
|
||||
t.Fatalf("read dir: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("ledger directory holds %d entries, want just the record", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// The name comes from windowsSandboxUserName, but the path builder is the last
|
||||
// thing between a caller and the filesystem, so it refuses anything that could
|
||||
// escape the directory.
|
||||
func TestPrincipalACLLedgerPathRefusesUnsafeNames(t *testing.T) {
|
||||
for _, username := range []string{"", " ", `..\..\evil`, "a/b", `a\b`, "c:evil", "..", "zerosbx..01"} {
|
||||
if _, err := windowsPrincipalACLLedgerPath(`C:\home`, username); err == nil {
|
||||
t.Errorf("accepted principal name %q", username)
|
||||
}
|
||||
}
|
||||
if _, err := windowsPrincipalACLLedgerPath("", "zerosbx01"); err == nil {
|
||||
t.Error("accepted an empty sandbox home")
|
||||
}
|
||||
if _, err := windowsPrincipalACLLedgerPath(`C:\home`, "zerosbx01"); err != nil {
|
||||
t.Errorf("rejected a name windowsSandboxUserName would produce: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Removal is idempotent because teardown only cares that the record is gone,
|
||||
// and a setup that failed before writing one must not make teardown fail too.
|
||||
func TestPrincipalACLLedgerRemovalToleratesAnAbsentRecord(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
if err := removeWindowsPrincipalACLLedger(home, "zerosbx01"); err != nil {
|
||||
t.Fatalf("remove an absent record: %v", err)
|
||||
}
|
||||
if err := writeWindowsPrincipalACLLedger(home, "zerosbx01", []string{`C:\ws`}); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := removeWindowsPrincipalACLLedger(home, "zerosbx01"); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
if _, recorded := readWindowsPrincipalACLLedger(home, "zerosbx01"); recorded {
|
||||
t.Error("the record survived removal")
|
||||
}
|
||||
}
|
||||
|
||||
// The union is what setup revokes over. A path in both sets must be revoked
|
||||
// once, and a root respelled between setups — Windows opens a path whatever its
|
||||
// casing — is one path, not two.
|
||||
func TestUnionPrincipalACLPathsDedupesTheWayTheACLPlansDo(t *testing.T) {
|
||||
union := unionWindowsPrincipalACLPaths(
|
||||
[]string{`C:\Ws\Alpha`, `C:\ws\beta`, " "},
|
||||
[]string{`c:\ws\alpha`, `C:\ws\gamma`, `C:/ws/beta`},
|
||||
)
|
||||
if len(union) != 3 {
|
||||
t.Fatalf("union = %v, want three distinct paths", union)
|
||||
}
|
||||
// The first spelling wins: revocation needs a real path, and the recorded one
|
||||
// is the spelling that was actually granted.
|
||||
if union[0] != `C:\Ws\Alpha` {
|
||||
t.Errorf("union[0] = %q, want the recorded spelling kept", union[0])
|
||||
}
|
||||
// The recorded set comes first so a dropped root cannot be crowded out.
|
||||
if union[1] != `C:\ws\beta` || union[2] != `C:\ws\gamma` {
|
||||
t.Errorf("union = %v, want the recorded paths before the newly granted ones", union)
|
||||
}
|
||||
if got := unionWindowsPrincipalACLPaths(nil, nil); len(got) != 0 {
|
||||
t.Errorf("union of nothing = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// The finding this record exists for, end to end and against real DACLs: setup
|
||||
// grants two roots, the user removes one from their policy, setup runs again,
|
||||
// and the removed root must not still name the principal.
|
||||
//
|
||||
// Both halves go through the PRODUCTION applyWindowsPrincipalACLs rather than
|
||||
// the revoke helper, because the mechanism already worked — what did not was the
|
||||
// call site's idea of which paths to revoke. It could only name the paths of the
|
||||
// plan it was about to apply, and the dropped root is by definition absent from
|
||||
// that plan, so the re-setup marker validation forces preserved the very ACE it
|
||||
// was supposed to clear.
|
||||
func TestReSetupRevokesARootTheNarrowedPolicyDropped(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
username := "zerosbxregression"
|
||||
root := t.TempDir()
|
||||
kept := filepath.Join(root, "kept")
|
||||
dropped := filepath.Join(root, "dropped")
|
||||
for _, dir := range []string{kept, dropped} {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
// Guests: a real, resolvable trustee this process is not a member of, so the
|
||||
// ACEs below are observable without affecting the test process.
|
||||
principal := "S-1-5-32-546"
|
||||
|
||||
wide := FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: kept}, {Root: dropped}},
|
||||
}
|
||||
if _, err := applyWindowsPrincipalACLs(home, username, principal, wide, wide.WriteRoots); err != nil {
|
||||
t.Fatalf("first setup: %v", err)
|
||||
}
|
||||
if !hasACEForTrustee(t, dropped, principal) {
|
||||
t.Fatal("precondition: the first setup should have granted the root that is about to leave the policy")
|
||||
}
|
||||
|
||||
// The user narrows their policy and re-runs setup. Nothing in this call
|
||||
// mentions the dropped root; only the record does.
|
||||
narrow := FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: kept}},
|
||||
}
|
||||
if _, err := applyWindowsPrincipalACLs(home, username, principal, narrow, narrow.WriteRoots); err != nil {
|
||||
t.Fatalf("re-setup: %v", err)
|
||||
}
|
||||
|
||||
if hasACEForTrustee(t, dropped, principal) {
|
||||
t.Error("the principal kept its grant on a root the narrowed policy removed")
|
||||
}
|
||||
if !hasACEForTrustee(t, kept, principal) {
|
||||
t.Error("revoking over the recorded paths also dropped the grant the narrowed policy still wants")
|
||||
}
|
||||
// And the record narrows with the policy, or it would accumulate every root
|
||||
// the workspace has ever had.
|
||||
recorded, ok := readWindowsPrincipalACLLedger(home, username)
|
||||
if !ok {
|
||||
t.Fatal("no record survived the re-setup")
|
||||
}
|
||||
if containsPathFold(recorded, dropped) {
|
||||
t.Errorf("the record still names %q after the policy dropped it", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
// The record is written BEFORE any DACL changes and as the union, not after and
|
||||
// as the new set. A crash between the grant and a post-hoc write would otherwise
|
||||
// leave a record missing paths this run granted, stranding them at the next
|
||||
// policy change — the same bug, one interruption away.
|
||||
func TestPrincipalACLRecordCoversTheGrantBeforeItIsMade(t *testing.T) {
|
||||
prevApply := applyWindowsACLPlanFn
|
||||
t.Cleanup(func() { applyWindowsACLPlanFn = prevApply })
|
||||
|
||||
home := t.TempDir()
|
||||
username := "zerosbxtwophase"
|
||||
workspace := t.TempDir()
|
||||
stale := filepath.Join(t.TempDir(), "left-the-policy")
|
||||
if err := writeWindowsPrincipalACLLedger(home, username, []string{stale}); err != nil {
|
||||
t.Fatalf("seed the record: %v", err)
|
||||
}
|
||||
|
||||
var atGrant []string
|
||||
applyWindowsACLPlanFn = func(plan WindowsACLPlan) (func() error, error) {
|
||||
if len(plan.Entries) > 0 && plan.Entries[0].Action != windowsACLRevoke {
|
||||
atGrant, _ = readWindowsPrincipalACLLedger(home, username)
|
||||
}
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
|
||||
filesystem := FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: workspace}},
|
||||
}
|
||||
if _, err := applyWindowsPrincipalACLs(home, username, "S-1-5-32-546", filesystem, filesystem.WriteRoots); err != nil {
|
||||
t.Fatalf("applyWindowsPrincipalACLs: %v", err)
|
||||
}
|
||||
|
||||
if !containsPathFold(atGrant, stale) || !containsPathFold(atGrant, workspace) {
|
||||
t.Errorf("record at grant time = %v, want the union of the recorded and newly granted paths", atGrant)
|
||||
}
|
||||
after, ok := readWindowsPrincipalACLLedger(home, username)
|
||||
if !ok {
|
||||
t.Fatal("no record after a successful setup")
|
||||
}
|
||||
if containsPathFold(after, stale) {
|
||||
t.Errorf("record after = %v, want it narrowed to what is granted now", after)
|
||||
}
|
||||
if !containsPathFold(after, workspace) {
|
||||
t.Errorf("record after = %v, want the granted workspace root", after)
|
||||
}
|
||||
}
|
||||
|
||||
// A principal from an earlier setup whose grants were never recorded is the one
|
||||
// case where the prior set is not empty but unenumerable, and carrying on with
|
||||
// it is the fail-open: revocation would then cover only what the new plan
|
||||
// happens to name. Retiring the account instead makes every ACE that cannot be
|
||||
// found name a SID Windows never reuses.
|
||||
//
|
||||
// The record is per role because the principals are: seeding one role's record
|
||||
// must retire the other and only the other. Retiring both would destroy an
|
||||
// account there is nothing wrong with, and retiring neither is the fail-open.
|
||||
func TestSetupRetiresAPrincipalWithNoRecordOfItsGrants(t *testing.T) {
|
||||
for name, testCase := range map[string]struct {
|
||||
seedRoles []windowsSandboxRole
|
||||
identityFound bool
|
||||
wantRetired []windowsSandboxRole
|
||||
}{
|
||||
"no record and principals from an earlier setup": {
|
||||
identityFound: true,
|
||||
wantRetired: []windowsSandboxRole{windowsSandboxRoleOffline, windowsSandboxRoleOnline},
|
||||
},
|
||||
"no record and nothing provisioned": {identityFound: false},
|
||||
"only the offline role has a record": {
|
||||
seedRoles: []windowsSandboxRole{windowsSandboxRoleOffline},
|
||||
identityFound: true,
|
||||
wantRetired: []windowsSandboxRole{windowsSandboxRoleOnline},
|
||||
},
|
||||
"both roles have a record to reconcile against": {
|
||||
seedRoles: []windowsSandboxRole{windowsSandboxRoleOffline, windowsSandboxRoleOnline},
|
||||
identityFound: true,
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
config := stubWindowsPrincipalSetup(t)
|
||||
key := windowsSandboxPrincipalKey(config)
|
||||
for _, role := range testCase.seedRoles {
|
||||
username := windowsSandboxUserName(key, role)
|
||||
if err := writeWindowsPrincipalACLLedger(config.SandboxHome, username, []string{`C:\ws\recorded`}); err != nil {
|
||||
t.Fatalf("seed the %s record: %v", role, err)
|
||||
}
|
||||
}
|
||||
|
||||
lookupWindowsSandboxIdentityFn = func(_ string, role windowsSandboxRole) (windowsSandboxIdentity, error) {
|
||||
if testCase.identityFound {
|
||||
return windowsSandboxIdentity{Username: windowsSandboxUserName(key, role), SID: guestsSID(t)}, nil
|
||||
}
|
||||
return windowsSandboxIdentity{}, errWindowsSandboxIdentityUnavailable
|
||||
}
|
||||
var retired []windowsSandboxRole
|
||||
removeWindowsSandboxPrincipalForSetupFn = func(_ WindowsSandboxCommandConfig, role windowsSandboxRole) error {
|
||||
retired = append(retired, role)
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := setupWindowsSandboxPrincipal(config); err != nil {
|
||||
t.Fatalf("setupWindowsSandboxPrincipal: %v", err)
|
||||
}
|
||||
if !sameRoles(retired, testCase.wantRetired) {
|
||||
t.Errorf("retired %v, want %v", retired, testCase.wantRetired)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sameRoles(got []windowsSandboxRole, want []windowsSandboxRole) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for index := range got {
|
||||
if got[index] != want[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// A failure to retire has to fail the setup. Reporting success would leave the
|
||||
// operator believing the sandbox is provisioned while a principal whose grants
|
||||
// nobody can enumerate is still holding them.
|
||||
func TestSetupFailsWhenAnUnrecordedPrincipalCannotBeRetired(t *testing.T) {
|
||||
config := stubWindowsPrincipalSetup(t)
|
||||
lookupWindowsSandboxIdentityFn = func(string, windowsSandboxRole) (windowsSandboxIdentity, error) {
|
||||
return windowsSandboxIdentity{Username: "zerosbx", SID: guestsSID(t)}, nil
|
||||
}
|
||||
removeWindowsSandboxPrincipalForSetupFn = func(WindowsSandboxCommandConfig, windowsSandboxRole) error {
|
||||
return errors.New("account is in use")
|
||||
}
|
||||
if _, err := setupWindowsSandboxPrincipal(config); err == nil {
|
||||
t.Fatal("setup reported success after failing to retire a principal it cannot reconcile")
|
||||
}
|
||||
}
|
||||
|
||||
// Teardown had the same blind spot as setup: it computed the paths to revoke
|
||||
// from the CURRENT policy, so retiring a principal cleared every ACE except the
|
||||
// one on the root that had left the policy — and then deleted the account, which
|
||||
// left that ACE naming a SID nothing could resolve to clean up later.
|
||||
func TestTeardownRevokesRecordedPathsTheCurrentPolicyNoLongerNames(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
workspace := t.TempDir()
|
||||
dropped := filepath.Join(t.TempDir(), "left-the-policy")
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: home,
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: workspace}},
|
||||
},
|
||||
},
|
||||
}
|
||||
role := windowsSandboxRoleOffline
|
||||
username := windowsSandboxUserName(windowsSandboxPrincipalKey(config), role)
|
||||
if err := writeWindowsPrincipalACLLedger(home, username, []string{dropped}); err != nil {
|
||||
t.Fatalf("seed the record: %v", err)
|
||||
}
|
||||
|
||||
paths, err := windowsPrincipalRevocationPaths(config, "S-1-5-32-546", role)
|
||||
if err != nil {
|
||||
t.Fatalf("windowsPrincipalRevocationPaths: %v", err)
|
||||
}
|
||||
|
||||
// The other role's revocation must not pick this up: separate accounts,
|
||||
// separate records, and retiring one must leave the other's ACEs alone.
|
||||
other, err := windowsPrincipalRevocationPaths(config, "S-1-5-32-546", windowsSandboxRoleOnline)
|
||||
if err != nil {
|
||||
t.Fatalf("windowsPrincipalRevocationPaths(online): %v", err)
|
||||
}
|
||||
if containsPathFold(other, dropped) {
|
||||
t.Errorf("the online role would revoke %q, which only the offline role's record names", dropped)
|
||||
}
|
||||
if !containsPathFold(paths, dropped) {
|
||||
t.Errorf("teardown would revoke %v, missing the recorded root %q the policy no longer names", paths, dropped)
|
||||
}
|
||||
if !containsPathFold(paths, workspace) {
|
||||
t.Errorf("teardown would revoke %v, missing the workspace the current policy grants", paths)
|
||||
}
|
||||
}
|
||||
|
||||
// stubWindowsPrincipalSetup replaces every elevated call
|
||||
// setupWindowsSandboxPrincipal makes, so the decision under test is reachable on
|
||||
// a machine with nothing provisioned.
|
||||
func stubWindowsPrincipalSetup(t *testing.T) WindowsSandboxCommandConfig {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
workspace := t.TempDir()
|
||||
|
||||
prevLookup := lookupWindowsSandboxIdentityFn
|
||||
prevRemove := removeWindowsSandboxPrincipalForSetupFn
|
||||
prevProvision := provisionWindowsSandboxIdentityFn
|
||||
prevGrant := grantWindowsSandboxLogonRightsFn
|
||||
prevReset := resetWindowsSandboxUserPasswordFn
|
||||
prevSecret := writeWindowsSandboxSecretFn
|
||||
prevApply := applyWindowsACLPlanFn
|
||||
prevCache := sandboxUserCacheDir
|
||||
t.Cleanup(func() {
|
||||
lookupWindowsSandboxIdentityFn = prevLookup
|
||||
removeWindowsSandboxPrincipalForSetupFn = prevRemove
|
||||
provisionWindowsSandboxIdentityFn = prevProvision
|
||||
grantWindowsSandboxLogonRightsFn = prevGrant
|
||||
resetWindowsSandboxUserPasswordFn = prevReset
|
||||
writeWindowsSandboxSecretFn = prevSecret
|
||||
applyWindowsACLPlanFn = prevApply
|
||||
sandboxUserCacheDir = prevCache
|
||||
})
|
||||
|
||||
provisionWindowsSandboxIdentityFn = func(key string, role windowsSandboxRole) (windowsSandboxIdentity, string, bool, error) {
|
||||
return windowsSandboxIdentity{Username: windowsSandboxUserName(key, role), SID: guestsSID(t)}, "pw", true, nil
|
||||
}
|
||||
grantWindowsSandboxLogonRightsFn = func(*windows.SID) error { return nil }
|
||||
resetWindowsSandboxUserPasswordFn = func(string, string) error { return nil }
|
||||
writeWindowsSandboxSecretFn = func(string, string) error { return nil }
|
||||
applyWindowsACLPlanFn = func(WindowsACLPlan) (func() error, error) {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
cache := t.TempDir()
|
||||
sandboxUserCacheDir = func() (string, error) { return cache, nil }
|
||||
|
||||
return WindowsSandboxCommandConfig{
|
||||
SandboxHome: home,
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: workspace}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func guestsSID(t *testing.T) *windows.SID {
|
||||
t.Helper()
|
||||
sid, err := windows.StringToSid("S-1-5-32-546")
|
||||
if err != nil {
|
||||
t.Fatalf("StringToSid: %v", err)
|
||||
}
|
||||
return sid
|
||||
}
|
||||
|
||||
// containsPathFold compares the way the ACL plans do, which is the only
|
||||
// comparison that means anything here.
|
||||
//
|
||||
// Comparing the raw spellings passed CI on nothing and failed on Windows: the
|
||||
// plan builder runs every root through normalizeProfilePath, whose EvalSymlinks
|
||||
// expands the 8.3 short name GitHub's runners hand out for TEMP, so the record
|
||||
// holds C:\Users\runneradmin\... while t.TempDir() returned C:\Users\RUNNER~1\...
|
||||
// and EqualFold called two spellings of one directory different paths. A
|
||||
// developer whose TEMP has no short name never sees it.
|
||||
//
|
||||
// Production is self-consistent — both the recorded and the newly planned paths
|
||||
// go through the same normalization — so this was only ever the test being
|
||||
// naive about what "same path" means on Windows.
|
||||
func containsPathFold(paths []string, want string) bool {
|
||||
wanted := windowsCapabilityPathKey(normalizeProfilePath(want))
|
||||
if wanted == "" {
|
||||
return false
|
||||
}
|
||||
for _, path := range paths {
|
||||
if windowsCapabilityPathKey(normalizeProfilePath(path)) == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// PROVISIONING STAYS CLOSED WHILE NO LAUNCH MECHANISM EXISTS.
|
||||
//
|
||||
// Launching a command as a separate account needs SeAssignPrimaryTokenPrivilege
|
||||
// and SeIncreaseQuotaPrivilege. The process that needs them is the LATER,
|
||||
// ordinary command process, not setup, and nothing carries launch authority
|
||||
// across that boundary.
|
||||
//
|
||||
// The previous gate asked whether the CALLING process held the privileges,
|
||||
// which is the wrong lifetime and got the answer backwards where it mattered:
|
||||
// it refused unelevated setup, which was never dangerous, and it PASSED
|
||||
// elevated setup, which is precisely the case that lands a local account, its
|
||||
// password, its logon rights, workspace ACEs, the recovery ledger and network
|
||||
// filter state for a backend no later command can use. The gate existed to
|
||||
// prevent durable state serving something that cannot run, and it produced it.
|
||||
//
|
||||
// So the refusal must not be satisfiable by holding the privileges right now.
|
||||
func TestSetupRefusesToProvisionAPrincipalNothingCanLaunch(t *testing.T) {
|
||||
optIn := true
|
||||
options := WindowsSandboxSetupArgsOptions{
|
||||
CommandCWD: t.TempDir(),
|
||||
SandboxHome: t.TempDir(),
|
||||
PrincipalOptIn: &optIn,
|
||||
}
|
||||
|
||||
args, err := BuildWindowsSandboxSetupArgs(options)
|
||||
if err == nil {
|
||||
t.Fatalf("setup args were built for a principal nothing can launch: %v", args)
|
||||
}
|
||||
if !errors.Is(err, errWindowsPrincipalLaunchUnavailable) {
|
||||
t.Errorf("the refusal is not the launch-unavailable one: %v", err)
|
||||
}
|
||||
for _, want := range []string{"SeAssignPrimaryTokenPrivilege", "SeIncreaseQuotaPrivilege", "provision"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not mention %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
// The way forward has to be the one that works. Telling the operator to
|
||||
// re-run elevated is the advice that produced the unusable state.
|
||||
if strings.Contains(strings.ToLower(err.Error()), "elevated terminal") {
|
||||
t.Errorf("the refusal still points at an elevated terminal, which does not help: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), windowsSandboxIdentityEnv) {
|
||||
t.Errorf("the refusal does not name the opt-out: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// THE REGRESSION, STATED DIRECTLY: an elevated caller must be refused too.
|
||||
//
|
||||
// This is the case the old check let through, and it is the only one that
|
||||
// creates durable machine state. A gate that consults the current process
|
||||
// cannot express it, which is why the seam takes no token and no argument.
|
||||
func TestSetupRefusalDoesNotDependOnTheCallersPrivileges(t *testing.T) {
|
||||
optIn := true
|
||||
options := WindowsSandboxSetupArgsOptions{
|
||||
CommandCWD: t.TempDir(),
|
||||
SandboxHome: t.TempDir(),
|
||||
PrincipalOptIn: &optIn,
|
||||
}
|
||||
|
||||
// Whatever this process holds, twice, must give the same answer.
|
||||
first, firstErr := BuildWindowsSandboxSetupArgs(options)
|
||||
second, secondErr := BuildWindowsSandboxSetupArgs(options)
|
||||
if firstErr == nil || secondErr == nil {
|
||||
t.Fatalf("provisioning succeeded: %v / %v", first, second)
|
||||
}
|
||||
if firstErr.Error() != secondErr.Error() {
|
||||
t.Errorf("the refusal is not stable across calls:\n %v\n %v", firstErr, secondErr)
|
||||
}
|
||||
}
|
||||
|
||||
// AND THE GATE IS ONLY FOR THE OPTED-IN PATH. Without the opt-in there is no
|
||||
// principal to provision, so the ordinary restricted-token sandbox, which needs
|
||||
// none of those privileges, must still set up.
|
||||
func TestSetupWithoutTheOptInIsUnaffected(t *testing.T) {
|
||||
optOut := false
|
||||
if _, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{
|
||||
CommandCWD: t.TempDir(),
|
||||
SandboxHome: t.TempDir(),
|
||||
PrincipalOptIn: &optOut,
|
||||
}); err != nil {
|
||||
t.Errorf("the restricted-token sandbox was refused for a privilege it does not need: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// allowPrincipalLaunchForTest opens the gate for tests that are about argument
|
||||
// PLUMBING rather than about whether a principal can be launched. The opt-in
|
||||
// still has to round-trip correctly for `zero doctor` and for the day a broker
|
||||
// lands, and those assertions should not be deleted just because the entry
|
||||
// point is closed today.
|
||||
func allowPrincipalLaunchForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := windowsPrincipalLaunchAvailable
|
||||
windowsPrincipalLaunchAvailable = func() error { return nil }
|
||||
t.Cleanup(func() { windowsPrincipalLaunchAvailable = previous })
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package sandbox
|
||||
|
||||
import "testing"
|
||||
|
||||
// readCapabilityProfile is the shape that selects the strict token: a restricted
|
||||
// filesystem with DenyRead set. Without DenyRead the token stays
|
||||
// WRITE_RESTRICTED and reads never reach the restricted-SID check at all, so
|
||||
// every assertion below would hold vacuously.
|
||||
func readCapabilityProfile(readRoot string) PermissionProfile {
|
||||
return PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: `C:\workspace`}},
|
||||
ReadRoots: []string{`C:\`, readRoot},
|
||||
DenyRead: []string{`C:\Users\someone\.aws\credentials`},
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkAllow},
|
||||
}
|
||||
}
|
||||
|
||||
// A READ ROOT MUST CARRY A RESTRICTING SID.
|
||||
//
|
||||
// On the active-principal path the command runs on a strict token, because
|
||||
// DenyRead drops WRITE_RESTRICTED and Windows then applies the restricted-SID
|
||||
// check to reads as well as writes. Read roots were granted only to the
|
||||
// principal's account SID, which windowsPrincipalJailSIDs removes from the
|
||||
// restricting set on purpose, so the normal check passed and the restricted
|
||||
// check matched nothing. The effect was not a narrower sandbox but an unusable
|
||||
// one: no read root readable, up to and including the executable being launched.
|
||||
//
|
||||
// The custom read root is the case a workspace-only assertion would miss: an SDK
|
||||
// or toolchain directory outside the workspace that the command must read.
|
||||
func TestCapabilityPlanGrantsReadRootsToARestrictingSID(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
custom := `C:\sdk\toolchain`
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: home,
|
||||
CommandCWD: `C:\workspace`,
|
||||
WorkspaceRoots: []string{`C:\workspace`},
|
||||
PermissionProfile: readCapabilityProfile(custom),
|
||||
}
|
||||
plan, err := BuildWindowsACLPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsACLPlan: %v", err)
|
||||
}
|
||||
readSID, err := WindowsReadAllowSID(home)
|
||||
if err != nil {
|
||||
t.Fatalf("WindowsReadAllowSID: %v", err)
|
||||
}
|
||||
if readSID == "" {
|
||||
t.Fatal("no read capability SID, so this test proves nothing")
|
||||
}
|
||||
|
||||
for _, want := range []string{`C:\`, custom} {
|
||||
found := false
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action == WindowsACLAllowRead &&
|
||||
windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(want) &&
|
||||
entry.Capability == readSID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("read root %s has no allow-read entry for the read capability SID, so a strict token cannot read it", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The read grant must not reopen the deny list.
|
||||
//
|
||||
// The read roots start at the filesystem root, so the capability that makes them
|
||||
// readable also covers every DenyRead carveout underneath. If the deny entries
|
||||
// name only the write capabilities, the carveout stays readable through the read
|
||||
// capability and DenyRead silently stops meaning anything — which is the whole
|
||||
// reason the strict token is selected in the first place.
|
||||
func TestDenyReadCoversTheReadCapabilitySID(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: home,
|
||||
CommandCWD: `C:\workspace`,
|
||||
WorkspaceRoots: []string{`C:\workspace`},
|
||||
PermissionProfile: readCapabilityProfile(`C:\sdk\toolchain`),
|
||||
}
|
||||
plan, err := BuildWindowsACLPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsACLPlan: %v", err)
|
||||
}
|
||||
readSID, err := WindowsReadAllowSID(home)
|
||||
if err != nil {
|
||||
t.Fatalf("WindowsReadAllowSID: %v", err)
|
||||
}
|
||||
|
||||
denied := false
|
||||
sawDeny := false
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action != WindowsACLDenyRead {
|
||||
continue
|
||||
}
|
||||
sawDeny = true
|
||||
if entry.Capability == readSID {
|
||||
denied = true
|
||||
}
|
||||
}
|
||||
if !sawDeny {
|
||||
t.Fatal("plan has no deny-read entries at all, so this test proves nothing")
|
||||
}
|
||||
if !denied {
|
||||
t.Error("no deny-read entry names the read capability SID, so every DenyRead path stays readable through the read grant")
|
||||
}
|
||||
}
|
||||
|
||||
// Without DenyRead the token stays WRITE_RESTRICTED, reads skip the restricted
|
||||
// check, and the read grant would be ACEs that buy nothing — on roots as broad
|
||||
// as the filesystem root. Both halves of the protocol decide this from the
|
||||
// profile alone, so setup and the command cannot disagree about whether the
|
||||
// entries exist.
|
||||
func TestReadCapabilityIsAbsentWithoutDenyRead(t *testing.T) {
|
||||
profile := readCapabilityProfile(`C:\sdk\toolchain`)
|
||||
profile.FileSystem.DenyRead = nil
|
||||
plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: `C:\workspace`,
|
||||
WorkspaceRoots: []string{`C:\workspace`},
|
||||
PermissionProfile: profile,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsACLPlan: %v", err)
|
||||
}
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Action == WindowsACLAllowRead {
|
||||
t.Fatalf("allow-read entry %s present without DenyRead, where reads are never restricted", entry.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE STRICT TOKEN MUST CARRY THE GRANT ITS READ ROOTS NAME.
|
||||
//
|
||||
// A profile with DenyRead drops WRITE_RESTRICTED, which puts reads under the
|
||||
// restricted-SID check as well. Everyone used to satisfy that check, and taking
|
||||
// it away without putting the read capability in its place leaves a token that
|
||||
// cannot open cmd.exe: the command dies at launch with an access denial that
|
||||
// says nothing about the profile.
|
||||
//
|
||||
// The ACL plan grants this same capability on every read root, so the two halves
|
||||
// have to be decided together. This pins the token half; the plan half is pinned
|
||||
// by the plan tests, and the kernel behaviour by the impersonation test beside
|
||||
// this one.
|
||||
func TestTheStrictTokenCarriesTheReadCapability(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
want, err := WindowsReadAllowSID(home)
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: no read capability for %s: %v", home, err)
|
||||
}
|
||||
|
||||
base := []string{"S-1-15-3-1024-workspace"}
|
||||
got, err := windowsRestrictedTokenSIDsForProfile(base, home, false)
|
||||
if err != nil {
|
||||
t.Fatalf("windowsRestrictedTokenSIDsForProfile: %v", err)
|
||||
}
|
||||
if !carriesSID(got, want) {
|
||||
t.Fatalf("the strict token's restricted SIDs %v omit the read capability %s, so the command cannot open its own executable", got, want)
|
||||
}
|
||||
if !carriesSID(got, base[0]) {
|
||||
t.Fatalf("the workspace capability was dropped: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// And the WRITE_RESTRICTED token does not get it: reads are already unrestricted
|
||||
// there, so adding a SID would widen the write jail for nothing.
|
||||
func TestTheWriteRestrictedTokenDoesNotCarryTheReadCapability(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
readSID, err := WindowsReadAllowSID(home)
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: no read capability for %s: %v", home, err)
|
||||
}
|
||||
got, err := windowsRestrictedTokenSIDsForProfile([]string{"S-1-15-3-1024-workspace"}, home, true)
|
||||
if err != nil {
|
||||
t.Fatalf("windowsRestrictedTokenSIDsForProfile: %v", err)
|
||||
}
|
||||
if carriesSID(got, readSID) {
|
||||
t.Fatalf("the WRITE_RESTRICTED token carries the read capability %s, widening the write jail to every read root", readSID)
|
||||
}
|
||||
}
|
||||
|
||||
// Not containsSID: #886 adds a package-level helper by that name, and the two
|
||||
// branches would stop compiling the moment both land.
|
||||
func carriesSID(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if strings.EqualFold(value, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// THE PLAN AND THE TOKEN DECIDE THE READ CAPABILITY SEPARATELY.
|
||||
//
|
||||
// The ACL plan asks whether the profile configures DenyRead; the runner asks
|
||||
// whether the token keeps WRITE_RESTRICTED. Both are read off the same field
|
||||
// today, in two files, with nothing tying them together. Drift either way is
|
||||
// silent and bad: a token carrying a SID no DACL names cannot open its own
|
||||
// executable, and a plan granting a SID no token carries leaves reads confined
|
||||
// by nothing.
|
||||
//
|
||||
// So this pins the equivalence rather than either half.
|
||||
func TestThePlanAndTheTokenAgreeOnTheReadCapability(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
denyRead []string
|
||||
}{
|
||||
{name: "with denyRead", denyRead: []string{filepath.Join("secrets")}},
|
||||
{name: "without denyRead"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: workspace}},
|
||||
ReadRoots: []string{workspace},
|
||||
DenyRead: prefixEach(workspace, testCase.denyRead),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
planned, err := windowsReadAllowCapabilitySID(config)
|
||||
if err != nil {
|
||||
t.Fatalf("windowsReadAllowCapabilitySID: %v", err)
|
||||
}
|
||||
writeRestricted := len(config.PermissionProfile.FileSystem.DenyRead) == 0
|
||||
tokenSIDs, err := windowsRestrictedTokenSIDsForProfile(nil, config.SandboxHome, writeRestricted)
|
||||
if err != nil {
|
||||
t.Fatalf("windowsRestrictedTokenSIDsForProfile: %v", err)
|
||||
}
|
||||
|
||||
inPlan := planned != ""
|
||||
inToken := len(tokenSIDs) > 0
|
||||
if inPlan != inToken {
|
||||
t.Fatalf("the plan grants the read capability = %t but the token carries it = %t; one side confines reads the other side never checks", inPlan, inToken)
|
||||
}
|
||||
if inPlan && !carriesSID(tokenSIDs, planned) {
|
||||
t.Fatalf("the plan grants %s but the token carries %v, so the command cannot read what setup allowed", planned, tokenSIDs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func prefixEach(root string, relatives []string) []string {
|
||||
if len(relatives) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(relatives))
|
||||
for _, relative := range relatives {
|
||||
out = append(out, filepath.Join(root, relative))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import "testing"
|
||||
|
||||
func containsRole(roles []windowsSandboxRole, want windowsSandboxRole) bool {
|
||||
for _, role := range roles {
|
||||
if role == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// THE LEGACY ROLE BELONGS TO EXACTLY ONE OF THE TWO INVENTORIES.
|
||||
//
|
||||
// Both directions are real failures that have nearly happened here.
|
||||
//
|
||||
// In the retirable list: teardown retires legacy, and the is-it-still-installed
|
||||
// guard used to ask only the live pair. An upgraded machine still holding the
|
||||
// untagged pre-split account was therefore reported clean, and the opted-out
|
||||
// marker claimed a teardown that had not happened.
|
||||
//
|
||||
// Out of the live list: provisioning legacy would create that pre-split account
|
||||
// fresh on every setup of a machine that had already been upgraded past it.
|
||||
func TestTheLegacyRoleIsRetirableButNeverProvisioned(t *testing.T) {
|
||||
if containsRole(windowsSandboxLiveRoles, windowsSandboxRoleLegacy) {
|
||||
t.Error("the legacy role is in the provisioning inventory; setup would recreate the pre-split account on every run")
|
||||
}
|
||||
if !containsRole(windowsSandboxRetirableRoles, windowsSandboxRoleLegacy) {
|
||||
t.Error("the legacy role is not in the retirable inventory; teardown and the installed check would both miss the pre-split account")
|
||||
}
|
||||
for _, role := range windowsSandboxLiveRoles {
|
||||
if !containsRole(windowsSandboxRetirableRoles, role) {
|
||||
t.Errorf("live role %q is not retirable, so teardown would leave it behind", role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the installed check consults the retirable inventory, so a machine still
|
||||
// holding only the legacy account is not reported clean.
|
||||
func TestPrincipalIsInstalledSeesTheLegacyAccount(t *testing.T) {
|
||||
prev := lookupWindowsSandboxIdentityFn
|
||||
t.Cleanup(func() { lookupWindowsSandboxIdentityFn = prev })
|
||||
|
||||
var asked []windowsSandboxRole
|
||||
lookupWindowsSandboxIdentityFn = func(_ string, role windowsSandboxRole) (windowsSandboxIdentity, error) {
|
||||
asked = append(asked, role)
|
||||
if role == windowsSandboxRoleLegacy {
|
||||
return windowsSandboxIdentity{}, nil
|
||||
}
|
||||
return windowsSandboxIdentity{}, errWindowsSandboxIdentityUnavailable
|
||||
}
|
||||
|
||||
workspace := t.TempDir()
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
}
|
||||
if !windowsSandboxPrincipalIsInstalled(config) {
|
||||
t.Errorf("a machine still holding the legacy account was reported clean; roles asked: %v", asked)
|
||||
}
|
||||
if !containsRole(asked, windowsSandboxRoleLegacy) {
|
||||
t.Errorf("the legacy role was never looked up: %v", asked)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package sandbox
|
||||
|
||||
import "testing"
|
||||
|
||||
// Doctor and the runtime must name the SAME principal.
|
||||
//
|
||||
// The helper this replaces existed to be that single rule and said so, and the
|
||||
// drift it was meant to prevent happened anyway: dual-role provisioning made the
|
||||
// runtime use an offline principal under NetworkDeny while doctor still reported
|
||||
// the old restricted-token standdown, so an operator was told their reads were
|
||||
// unconfined while they were confined. Both sides now call this, and the test
|
||||
// pins the mapping rather than either caller's copy of it.
|
||||
func TestPrincipalRoleForNetwork(t *testing.T) {
|
||||
for mode, want := range map[NetworkMode]string{
|
||||
NetworkAllow: "online",
|
||||
NetworkDeny: "offline",
|
||||
// Unset and unrecognized lose the network rather than keeping it, matching
|
||||
// what the runtime does with a mode it does not know.
|
||||
"": "offline",
|
||||
"not-a-mode": "offline",
|
||||
} {
|
||||
if got := WindowsSandboxPrincipalRoleForNetwork(mode); got != want {
|
||||
t.Errorf("role for %q = %q, want %q", mode, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,12 @@ type WindowsSandboxCommandConfig struct {
|
||||
Env map[string]string
|
||||
SandboxLevel WindowsSandboxLevel
|
||||
Command []string
|
||||
// CallerSID names the Windows user whose sandbox principal this config plans
|
||||
// against. Empty — the case for every ordinary command — means the process
|
||||
// running it, which is already the invoking user. It is set only by the
|
||||
// elevated setup half, which runs as somebody else under over-the-shoulder UAC
|
||||
// and would otherwise provision an account the caller can never find.
|
||||
CallerSID string
|
||||
}
|
||||
|
||||
func BuildWindowsSandboxCommandArgs(options WindowsSandboxCommandArgsOptions) ([]string, error) {
|
||||
@@ -336,13 +342,27 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli
|
||||
level = WindowsSandboxLevelUnelevated
|
||||
}
|
||||
args, err := BuildWindowsSandboxCommandArgs(WindowsSandboxCommandArgsOptions{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: spec.Dir,
|
||||
WorkspaceRoots: []string{execRequest.WorkspaceRoot},
|
||||
PermissionProfile: execRequest.PermissionProfile,
|
||||
Env: childEnv,
|
||||
SandboxLevel: level,
|
||||
Command: append([]string{spec.Name}, spec.Args...),
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: spec.Dir,
|
||||
WorkspaceRoots: []string{execRequest.WorkspaceRoot},
|
||||
// Augmented HERE, in the parent, because this is the last process whose
|
||||
// TEMP is the operator's.
|
||||
//
|
||||
// The runner cannot derive the runtime candidates itself: the child env it
|
||||
// runs with has TEMP and TMP 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 —
|
||||
// same number of roots, different paths — and every command died on
|
||||
// "permission roots or deny lists changed" with two 12-entry plans that
|
||||
// disagreed.
|
||||
PermissionProfile: windowsSandboxProfileWithRuntime(
|
||||
execRequest.PermissionProfile,
|
||||
[]string{execRequest.WorkspaceRoot},
|
||||
),
|
||||
Env: childEnv,
|
||||
SandboxLevel: level,
|
||||
Command: append([]string{spec.Name}, spec.Args...),
|
||||
})
|
||||
if err != nil {
|
||||
return CommandPlan{}, err
|
||||
@@ -442,6 +462,20 @@ type WindowsCapabilitySIDs struct {
|
||||
// commands include it and are blocked; online (approved) commands omit it and
|
||||
// reach the network — both still write-jailed by the capability SIDs.
|
||||
Offline string `json:"offline,omitempty"`
|
||||
// ReadAllow carries the principal's READ grants, and exists because a strict
|
||||
// (non-WRITE_RESTRICTED) token runs the restricted-SID check over reads too.
|
||||
//
|
||||
// A profile with DenyRead drops WRITE_RESTRICTED, so every read has to satisfy
|
||||
// the restricting set as well as the normal token. Read roots were granted
|
||||
// only to the principal's own account SID, which the write jail deliberately
|
||||
// keeps OUT of that set, so the normal check passed and the restricted check
|
||||
// matched nothing: the whole disk became unreadable, down to the executable
|
||||
// the command was trying to start.
|
||||
//
|
||||
// Synthetic and held by nobody. An ACE naming it grants access only to a token
|
||||
// the sandbox itself composed, and restricting SIDs can only narrow a token,
|
||||
// never widen it, so publishing this grant on the read roots hands out nothing.
|
||||
ReadAllow string `json:"readAllow,omitempty"`
|
||||
}
|
||||
|
||||
func ResolveWindowsSandboxHome(env map[string]string) (string, error) {
|
||||
@@ -478,8 +512,13 @@ func LoadOrCreateWindowsCapabilitySIDs(sandboxHome string) (WindowsCapabilitySID
|
||||
// Back-compat: an older (schema 1) file has no offline-marker SID.
|
||||
// Mint one and persist so the setup helper and the runner agree on a
|
||||
// single value for the WFP filter scope across processes.
|
||||
if caps.Offline == "" {
|
||||
caps.Offline = randomWindowsCapabilitySID()
|
||||
if caps.Offline == "" || caps.ReadAllow == "" {
|
||||
if caps.Offline == "" {
|
||||
caps.Offline = randomWindowsCapabilitySID()
|
||||
}
|
||||
if caps.ReadAllow == "" {
|
||||
caps.ReadAllow = randomWindowsCapabilitySID()
|
||||
}
|
||||
caps.SchemaVersion = windowsCapabilitySIDSchemaVersion
|
||||
if err := saveWindowsCapabilitySIDs(path, caps); err != nil {
|
||||
return WindowsCapabilitySIDs{}, err
|
||||
@@ -494,6 +533,7 @@ func LoadOrCreateWindowsCapabilitySIDs(sandboxHome string) (WindowsCapabilitySID
|
||||
SchemaVersion: windowsCapabilitySIDSchemaVersion,
|
||||
ReadOnly: randomWindowsCapabilitySID(),
|
||||
Offline: randomWindowsCapabilitySID(),
|
||||
ReadAllow: randomWindowsCapabilitySID(),
|
||||
WorkspaceByRoot: map[string]string{},
|
||||
WritableRootByPath: map[string]string{},
|
||||
}
|
||||
@@ -686,3 +726,44 @@ func randomWindowsCapabilitySID() string {
|
||||
}
|
||||
return fmt.Sprintf("S-1-5-21-%d-%d-%d-%d", words[0], words[1], words[2], words[3])
|
||||
}
|
||||
|
||||
// windowsRestrictedTokenSIDsForProfile adds the read capability to a restricted
|
||||
// token's SID list when the profile selected the strict token.
|
||||
//
|
||||
// WRITE_RESTRICTED scopes the restricted-SID check to writes. Without it the
|
||||
// check applies to reads as well, so the token needs a restricted SID that the
|
||||
// read roots' DACLs name or the command cannot open its own executable. It used
|
||||
// to be Everyone, which is a key to every object whose DACL grants Everyone
|
||||
// write and so handed back the write jail (#869). BuildWindowsACLPlan grants
|
||||
// this capability on every read root and denies it on every DenyRead path, so
|
||||
// the read allowance and the restriction are the same decision.
|
||||
//
|
||||
// Only elevated setup can grant it on the volume root the production profile
|
||||
// seeds; the unelevated tier refuses a DenyRead profile up front for that
|
||||
// reason, so nothing reaches here expecting a grant nobody applied.
|
||||
func windowsRestrictedTokenSIDsForProfile(tokenSIDs []string, sandboxHome string, writeRestricted bool) ([]string, error) {
|
||||
if writeRestricted {
|
||||
return tokenSIDs, nil
|
||||
}
|
||||
readSID, err := WindowsReadAllowSID(sandboxHome)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve the sandbox read capability: %w", err)
|
||||
}
|
||||
return append(tokenSIDs, readSID), nil
|
||||
}
|
||||
|
||||
// WindowsReadAllowSID returns the sandbox home's read-capability SID, minting and
|
||||
// persisting it on first use. Both halves of the setup protocol ask for it: the
|
||||
// capability ACL plan grants it on every read root, and the principal's strict
|
||||
// token carries it as a restricting SID, so the read allow-list and the
|
||||
// restriction come from one value instead of two that can drift.
|
||||
func WindowsReadAllowSID(sandboxHome string) (string, error) {
|
||||
caps, err := LoadOrCreateWindowsCapabilitySIDs(sandboxHome)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(caps.ReadAllow) == "" {
|
||||
return "", errors.New("windows sandbox read-capability SID is missing")
|
||||
}
|
||||
return caps.ReadAllow, nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// verifyWindowsRuntimeRootCapability confirms the runtime roots this command is
|
||||
// about to write to still carry the capability ACE setup gave them.
|
||||
//
|
||||
// THE MARKER ATTESTS TO A PLAN, NOT TO AN OBJECT. cleanupSandboxRuntimeRoots
|
||||
// removes inactive sibling workspace roots on an age and count policy. When that
|
||||
// workspace runs again, command-side preparation recreates the SAME deterministic
|
||||
// pathname as the ordinary caller, and the new directory inherits from its
|
||||
// parent: it does not carry the capability SID ACE that elevated setup applied to
|
||||
// the object that used to be there. ValidateWindowsSandboxSetupMarker still
|
||||
// passes, because it fingerprints pathnames and actions rather than the identity
|
||||
// of the ACL-bearing object, so the restricted child launched and then failed its
|
||||
// cache, temp and package-cache writes with a bare ACCESS_DENIED and nothing
|
||||
// pointing at setup.
|
||||
//
|
||||
// Cleanup treats these directories as disposable cache state; setup and its
|
||||
// marker treat their DACL as durable provisioned state. Checking the ACE here is
|
||||
// what reconciles the two: the command refuses with the remedy instead of
|
||||
// launching into a tree it cannot write.
|
||||
func verifyWindowsRuntimeRootCapability(config WindowsSandboxCommandConfig) error {
|
||||
runtime := config.PermissionProfile.Runtime
|
||||
if runtime == nil {
|
||||
return nil
|
||||
}
|
||||
root := strings.TrimSpace(runtime.Root)
|
||||
if root == "" {
|
||||
return nil
|
||||
}
|
||||
sid, err := windowsCapabilitySIDForWriteRoot(config, root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve the sandbox runtime capability for %s: %w", root, err)
|
||||
}
|
||||
present, err := windowsPathGrantsCapability(root, sid)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("the sandbox runtime root %s does not exist, so setup's provisioning is gone — run `zero sandbox setup` from an elevated (Administrator) terminal", root)
|
||||
}
|
||||
return fmt.Errorf("inspect the sandbox runtime root %s: %w", root, err)
|
||||
}
|
||||
if !present {
|
||||
return fmt.Errorf("the sandbox runtime root %s no longer carries the capability grant setup applied to it, "+
|
||||
"which happens when the directory was reclaimed and recreated by an ordinary run; "+
|
||||
"every sandboxed write into it would fail with access denied — run `zero sandbox setup` from an elevated (Administrator) terminal", root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// windowsPathGrantsCapability reports whether path's DACL carries an allow ACE
|
||||
// for sid. Read off the object, because the whole point is that the pathname
|
||||
// says nothing about which object now answers to it.
|
||||
func windowsPathGrantsCapability(path string, sid string) (bool, error) {
|
||||
wanted, err := windows.StringToSid(sid)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse the capability SID %q: %w", sid, err)
|
||||
}
|
||||
handle, _, err := openWindowsACLTarget(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil || dacl == nil {
|
||||
return false, err
|
||||
}
|
||||
// GetAce hands back a generic header that is reinterpreted here, which is
|
||||
// sound only for the fixed-layout ACE types: an object ACE carries Flags and
|
||||
// two GUIDs ahead of the trustee, so SidStart would land mid-structure. A type
|
||||
// this does not recognise is skipped rather than decoded into a nonsense SID.
|
||||
for index := uint32(0); index < uint32(dacl.AceCount); index++ {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(dacl, index, &ace); err != nil {
|
||||
continue
|
||||
}
|
||||
if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE {
|
||||
continue
|
||||
}
|
||||
aceSID := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
|
||||
if aceSID.Equals(wanted) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// CLEANUP AND SETUP DISAGREE ABOUT WHO OWNS THE RUNTIME DIRECTORY.
|
||||
//
|
||||
// cleanupSandboxRuntimeRoots reclaims inactive sibling workspace roots on an age
|
||||
// and count policy, treating them as disposable cache state. Setup and its marker
|
||||
// treat their DACL as durable provisioned state. When the reclaimed workspace runs
|
||||
// again, command-side preparation recreates the SAME deterministic pathname as
|
||||
// the ordinary caller, and the new directory inherits from its parent without the
|
||||
// capability SID ACE elevated setup applied to the object that used to be there.
|
||||
//
|
||||
// The marker still validates, because it fingerprints pathnames and actions
|
||||
// rather than the identity of the ACL-bearing object. So the restricted child
|
||||
// launched and then failed its cache, temp and package-cache writes with a bare
|
||||
// access-denied and nothing pointing at setup.
|
||||
//
|
||||
// Driven by applying a real plan, reclaiming the directory the way cleanup does,
|
||||
// recreating the pathname the way an ordinary run does, and asking the check the
|
||||
// command now makes. DACL edits on a user-owned directory need no Administrator
|
||||
// rights, so this runs unelevated.
|
||||
//
|
||||
// Not covered here: an actual write performed by a real capability token, which
|
||||
// needs a provisioned sandbox this box cannot create. The ACE presence is the
|
||||
// fact the command consumes, and it is what this pins.
|
||||
func TestRuntimeRootReclaimedAndRecreatedLosesItsCapability(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
previous := sandboxUserCacheDir
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previous })
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
|
||||
workspace := t.TempDir()
|
||||
runtimeRoot, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot))
|
||||
if !ok {
|
||||
t.Fatalf("SETUP INVALID: no deterministic runtime root for %s", workspace)
|
||||
}
|
||||
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: PermissionProfile{
|
||||
Runtime: &SandboxRuntime{Root: runtimeRoot},
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{
|
||||
{Root: workspace},
|
||||
{Root: runtimeRoot},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
plan, err := BuildWindowsACLPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsACLPlan: %v", err)
|
||||
}
|
||||
if _, err := applyWindowsACLPlan(plan); err != nil {
|
||||
t.Skipf("cannot apply an ACL plan here: %v", err)
|
||||
}
|
||||
|
||||
// A provisioned machine passes, or the assertion below would be satisfied by a
|
||||
// check that refuses unconditionally.
|
||||
if err := verifyWindowsRuntimeRootCapability(config); err != nil {
|
||||
t.Fatalf("SETUP INVALID: a freshly provisioned runtime root was rejected: %v", err)
|
||||
}
|
||||
|
||||
// Cleanup reclaims it, and the next ordinary run recreates the same pathname.
|
||||
if err := os.RemoveAll(runtimeRoot); err != nil {
|
||||
t.Fatalf("reclaim the runtime root the way cleanup does: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
|
||||
t.Fatalf("recreate the pathname the way an ordinary run does: %v", err)
|
||||
}
|
||||
|
||||
err = verifyWindowsRuntimeRootCapability(config)
|
||||
if err == nil {
|
||||
t.Fatal("a recreated runtime root with no capability grant was accepted; the command would launch and then fail every sandboxed write with nothing explaining why")
|
||||
}
|
||||
message := err.Error()
|
||||
if !strings.Contains(message, runtimeRoot) {
|
||||
t.Errorf("the refusal does not name the root: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(message), "sandbox setup") {
|
||||
t.Errorf("the refusal does not point at the remedy: %v", err)
|
||||
}
|
||||
|
||||
// And the marker on its own still says everything is fine, which is exactly
|
||||
// why the object has to be checked separately.
|
||||
if _, statErr := os.Stat(filepath.Dir(runtimeRoot)); statErr != nil {
|
||||
t.Fatalf("the runtime parent vanished: %v", statErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A secret this account cannot delete must be distinguishable from one that
|
||||
// simply would not delete.
|
||||
//
|
||||
// Teardown carries on past the first and aborts on the second. Before the
|
||||
// distinction existed, a secret owned by another administrator's setup stopped
|
||||
// teardown at its very first step, leaving the account, its logon rights, its
|
||||
// ACEs and its ledger all installed. The operator asked for removal, got an
|
||||
// error, and kept a working principal.
|
||||
func TestRemovingAnUndeletableSecretIsDistinguishable(t *testing.T) {
|
||||
// A non-empty directory standing in for a secret: os.Remove refuses it, which
|
||||
// gives a real removal failure without needing a second administrator.
|
||||
path := filepath.Join(t.TempDir(), "secret")
|
||||
if err := os.Mkdir(path, 0o700); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(path, "occupant"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("seed occupant: %v", err)
|
||||
}
|
||||
|
||||
err := removeWindowsSandboxSecret(path)
|
||||
if err == nil {
|
||||
t.Fatal("a secret that could not be removed reported success")
|
||||
}
|
||||
// This is NOT the foreign-owner case, so teardown must still treat it as
|
||||
// fatal rather than shrugging and carrying on.
|
||||
if errors.Is(err, errWindowsSandboxSecretNotOurs) {
|
||||
t.Errorf("an ordinary removal failure was classified as a foreign owner: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Absence is success. Teardown runs on machines that never provisioned a
|
||||
// principal, and re-running it must converge rather than fail.
|
||||
func TestRemovingAMissingSecretIsNotAnError(t *testing.T) {
|
||||
if err := removeWindowsSandboxSecret(filepath.Join(t.TempDir(), "never-existed")); err != nil {
|
||||
t.Fatalf("removing a missing secret reported an error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary case still works, so the tolerance above did not turn removal
|
||||
// into a no-op.
|
||||
func TestRemovingOurOwnSecretDeletesIt(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "secret")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := removeWindowsSandboxSecret(path); err != nil {
|
||||
t.Fatalf("removeWindowsSandboxSecret: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("the secret survived removal: stat err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,121 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/peermsg"
|
||||
)
|
||||
|
||||
const WindowsSandboxSetupName = "zero-windows-sandbox-setup.exe"
|
||||
|
||||
const windowsSandboxSetupMarkerSchemaVersion = 4
|
||||
// Bumped to 7 when the single principal was split into offline and online role
|
||||
// accounts. THE NAMES CHANGED, so a marker written by the previous version is
|
||||
// not evidence that this version's setup has run: it validates by comparing
|
||||
// serialized plans and hashes, and neither names an account. Left at 6, an
|
||||
// upgraded machine kept a valid marker, setup was never re-run, and the runner
|
||||
// found neither "zero-sbx-d<key>" nor "zero-sbx-n<key>" and fell back to the
|
||||
// restricted-token backend with no read confinement, silently.
|
||||
//
|
||||
// Bumping it makes that installation report as out of date, which is what sends
|
||||
// the operator back through elevated setup. Setup then provisions both roles and
|
||||
// retires the untagged predecessor (see removeWindowsSandboxPrincipalsForSetup).
|
||||
const windowsSandboxSetupMarkerSchemaVersion = 7
|
||||
|
||||
// windowsSandboxIdentityEnv opts a machine into the principal backend while it
|
||||
// is still experimental. Provisioning is inert without it, so an existing
|
||||
// install keeps the restricted-token behaviour until someone turns this on.
|
||||
//
|
||||
// Lives here, beside the setup protocol rather than beside the Windows-only
|
||||
// runtime, because the opt-in is part of that protocol: it has to be readable on
|
||||
// every platform so the setup args and the marker can carry it.
|
||||
const windowsSandboxIdentityEnv = "ZERO_WINDOWS_SANDBOX_IDENTITY"
|
||||
|
||||
// windowsSandboxIdentityEnabled reports whether the principal backend is opted
|
||||
// into. An explicit entry in env is authoritative; otherwise the process
|
||||
// environment decides.
|
||||
func windowsSandboxIdentityEnabled(env map[string]string) bool {
|
||||
if value, ok := env[windowsSandboxIdentityEnv]; ok {
|
||||
return strings.TrimSpace(value) == "1"
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv(windowsSandboxIdentityEnv)) == "1"
|
||||
}
|
||||
|
||||
// WindowsSandboxPrincipalOptIn resolves the principal opt-in for callers outside
|
||||
// this package (the `zero sandbox setup` CLI and `zero doctor`), so both sides
|
||||
// of the setup protocol read the opt-in the same way. Pass nil to consult the
|
||||
// current process environment.
|
||||
func WindowsSandboxPrincipalOptIn(env map[string]string) bool {
|
||||
return windowsSandboxIdentityEnabled(env)
|
||||
}
|
||||
|
||||
func windowsSandboxPrincipalOptInValue(optIn bool) string {
|
||||
if optIn {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
type WindowsSandboxSetupArgsOptions struct {
|
||||
SandboxHome string
|
||||
CommandCWD string
|
||||
WorkspaceRoots []string
|
||||
PermissionProfile PermissionProfile
|
||||
// PrincipalOptIn is the caller's principal opt-in, serialized into the setup
|
||||
// args. Elevated setup runs in its own process — a UAC-elevated one whose
|
||||
// environment is not the caller's — so it must be told the value rather than
|
||||
// left to sample an environment nobody set.
|
||||
//
|
||||
// Tri-state on purpose. nil means "this caller did not resolve the opt-in",
|
||||
// and BuildWindowsSandboxSetupArgs then resolves it from the environment of
|
||||
// the process building the args — which is the caller's own process, the one
|
||||
// place where the ambient value is the value the operator typed. A plain bool
|
||||
// could not say that: its zero value asserts "opted out", so every caller that
|
||||
// simply did not know about this field would serialize `--sandbox-principal 0`
|
||||
// while the command half still resolved the opt-in from its environment. Under
|
||||
// a machine-wide opt-in the two halves would then disagree and marker
|
||||
// validation would refuse every command — the same silent-disagreement bug
|
||||
// this flag exists to remove, re-created one layer up.
|
||||
//
|
||||
// Set it only to override the environment (a caller holding a command's Env
|
||||
// map, or a test pinning a value); leave it nil to mean "whatever this shell
|
||||
// says", which is what `zero sandbox setup` and `zero doctor` want.
|
||||
PrincipalOptIn *bool
|
||||
// CallerSID identifies the Windows user setup was invoked BY, serialized into
|
||||
// the args for the same reason PrincipalOptIn is: elevated setup runs in its
|
||||
// own process, and under over-the-shoulder UAC (or `runas`) that process
|
||||
// belongs to a DIFFERENT administrator than the caller.
|
||||
//
|
||||
// Everything a principal needs is scoped to the invoking user — the account
|
||||
// name, its ACL ledger and its DPAPI secret all key off that identity — so a
|
||||
// helper that sampled its own token would provision an account for the
|
||||
// administrator who typed the credentials while every later command looked for
|
||||
// one named after the ordinary user, found nothing, fell back to the restricted
|
||||
// token, and left an admin-keyed account nothing would ever reclaim.
|
||||
//
|
||||
// Empty means "resolve from the process building the args", which IS the
|
||||
// caller: these args are built before the UAC boundary is crossed.
|
||||
CallerSID string
|
||||
}
|
||||
|
||||
// callerSID resolves the invoking user, in the caller's own process, before the
|
||||
// args cross the UAC boundary. Empty when the token cannot be read (and on
|
||||
// non-Windows builds), which leaves the elevated helper on its pre-existing
|
||||
// behaviour of sampling its own token rather than inventing an identity.
|
||||
func (options WindowsSandboxSetupArgsOptions) callerSID() string {
|
||||
if sid := strings.TrimSpace(options.CallerSID); sid != "" {
|
||||
return sid
|
||||
}
|
||||
return windowsCurrentUserSID()
|
||||
}
|
||||
|
||||
// principalOptIn resolves the tri-state. It runs inside
|
||||
// BuildWindowsSandboxSetupArgs, i.e. in the caller's process, before the args
|
||||
// cross the UAC boundary — so an unset caller still ships an explicit 0|1 that
|
||||
// the elevated helper can trust.
|
||||
func (options WindowsSandboxSetupArgsOptions) principalOptIn() bool {
|
||||
if options.PrincipalOptIn != nil {
|
||||
return *options.PrincipalOptIn
|
||||
}
|
||||
return windowsSandboxIdentityEnabled(nil)
|
||||
}
|
||||
|
||||
type WindowsSandboxSetupConfig struct {
|
||||
@@ -29,6 +133,11 @@ type WindowsSandboxSetupConfig struct {
|
||||
CommandCWD string
|
||||
WorkspaceRoots []string
|
||||
PermissionProfile PermissionProfile
|
||||
PrincipalOptIn bool
|
||||
// CallerSID is the invoking user this setup is provisioning FOR. See
|
||||
// WindowsSandboxSetupArgsOptions.CallerSID. Empty means the caller did not
|
||||
// say, and the helper falls back to its own token.
|
||||
CallerSID string
|
||||
}
|
||||
|
||||
type WindowsSandboxSetupMarker struct {
|
||||
@@ -43,6 +152,21 @@ type WindowsSandboxSetupMarker struct {
|
||||
NetworkInfraHash string `json:"networkInfraHash"`
|
||||
OfflineFilterSID string `json:"offlineFilterSid"`
|
||||
NetworkFilters int `json:"networkFilters"`
|
||||
// PrincipalOptIn records whether the run that wrote this marker provisioned a
|
||||
// sandbox principal. Without it the two halves each sampled their own
|
||||
// environment and could disagree silently — see
|
||||
// ValidateWindowsSandboxSetupMarker.
|
||||
PrincipalOptIn bool `json:"principalOptIn"`
|
||||
// PrincipalPlanHash fingerprints the PRINCIPAL ACL plan, which ACLPlanHash
|
||||
// above does not cover: that one hashes BuildWindowsACLPlan, the
|
||||
// capability-SID plan, while principal grants are built separately by
|
||||
// buildWindowsPrincipalACLPlan from the same profile.
|
||||
//
|
||||
// Without it, narrowing or removing a principal read root left setup looking
|
||||
// current, so the old AllowRead ACEs stayed on disk with nothing to notice
|
||||
// they no longer matched the policy. Empty when the principal backend is not
|
||||
// opted into, which keeps the marker stable for the default install.
|
||||
PrincipalPlanHash string `json:"principalPlanHash,omitempty"`
|
||||
}
|
||||
|
||||
func WindowsSandboxSetupMarkerPath(sandboxHome string) string {
|
||||
@@ -66,6 +190,43 @@ func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]str
|
||||
if len(workspaceRoots) == 0 {
|
||||
workspaceRoots = []string{commandCWD}
|
||||
}
|
||||
// THE PRINCIPAL BACKEND HAS NO LAUNCH PATH YET, SO IT IS NOT PROVISIONED.
|
||||
//
|
||||
// Launching a command as a separate account needs SeAssignPrimaryTokenPrivilege
|
||||
// and SeIncreaseQuotaPrivilege. An ordinary unelevated process holds neither,
|
||||
// and the command runs LATER, from the caller, not from setup. So the
|
||||
// advertised lifecycle -- provision once from an elevated terminal, then run
|
||||
// ordinary commands -- cannot execute: the elevated setup process holds the
|
||||
// privileges and the process that needs them does not.
|
||||
//
|
||||
// An earlier version of this check tested the CALLING process's token, which
|
||||
// is the wrong lifetime. It refused unelevated setup, which was never the
|
||||
// dangerous case, and PASSED elevated setup, which is exactly the case that
|
||||
// creates a local account, its password, its logon-right assignments,
|
||||
// workspace ACEs, the recovery ledger and network filter state for a backend
|
||||
// no later command can use. Durable machine state serving something that
|
||||
// cannot run is the outcome the check existed to prevent, and it produced it.
|
||||
//
|
||||
// Requiring every sandboxed command to run elevated would make the privileges
|
||||
// line up, and is deliberately not done: it is a worse boundary than the
|
||||
// restricted token this would replace, and it is not the lifecycle the feature
|
||||
// is for. Until a reviewed launch mechanism exists -- a bootstrap or an
|
||||
// authenticated broker that carries launch authority across the UAC boundary
|
||||
// -- provisioning stays unavailable rather than half-working.
|
||||
//
|
||||
// Everything below the launch path (the plans, the ledger, the ACL and filter
|
||||
// work) still builds and is still tested; it is the provisioning entry point
|
||||
// that is closed. See #662, which this PR is foundation for and does not
|
||||
// close.
|
||||
if options.principalOptIn() {
|
||||
if err := windowsPrincipalLaunchAvailable(); err != nil {
|
||||
return nil, fmt.Errorf("refusing to provision a sandbox principal: %w", err)
|
||||
}
|
||||
}
|
||||
// Augmented here, in the caller's shell, before the args cross into the
|
||||
// elevated helper. Same reason the opt-in and caller SID are: the value has to
|
||||
// be resolved where the environment is the operator's.
|
||||
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)
|
||||
@@ -74,6 +235,16 @@ func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]str
|
||||
"--sandbox-home", sandboxHome,
|
||||
"--command-cwd", commandCWD,
|
||||
"--permission-profile", string(profileJSON),
|
||||
// Always explicit, never omitted-means-false: the elevated helper must be
|
||||
// able to tell "the caller wants no principal" from "an older caller said
|
||||
// nothing", and only the first of those is safe to run silently.
|
||||
"--sandbox-principal", windowsSandboxPrincipalOptInValue(options.principalOptIn()),
|
||||
}
|
||||
// Omitted rather than sent empty when the caller's identity cannot be read:
|
||||
// absence means "this caller did not say", which the helper answers by
|
||||
// sampling its own token exactly as it did before this flag existed.
|
||||
if callerSID := options.callerSID(); callerSID != "" {
|
||||
args = append(args, "--caller-sid", callerSID)
|
||||
}
|
||||
for _, root := range workspaceRoots {
|
||||
args = append(args, "--workspace-root", root)
|
||||
@@ -117,6 +288,31 @@ func ParseWindowsSandboxSetupArgs(args []string) (WindowsSandboxSetupConfig, err
|
||||
}
|
||||
profileJSON = strings.TrimSpace(value)
|
||||
index = next
|
||||
case "--sandbox-principal":
|
||||
value, next, err := nextWindowsSandboxFlagValue(args, index)
|
||||
if err != nil {
|
||||
return WindowsSandboxSetupConfig{}, err
|
||||
}
|
||||
switch strings.TrimSpace(value) {
|
||||
case "1":
|
||||
config.PrincipalOptIn = true
|
||||
case "0":
|
||||
config.PrincipalOptIn = false
|
||||
default:
|
||||
// Refused rather than treated as off: a value this helper cannot read
|
||||
// is a caller it does not understand, and guessing "no principal"
|
||||
// there would provision a weaker sandbox than the caller asked for
|
||||
// while reporting success.
|
||||
return WindowsSandboxSetupConfig{}, fmt.Errorf("invalid --sandbox-principal %q, want 0 or 1", value)
|
||||
}
|
||||
index = next
|
||||
case "--caller-sid":
|
||||
value, next, err := nextWindowsSandboxFlagValue(args, index)
|
||||
if err != nil {
|
||||
return WindowsSandboxSetupConfig{}, err
|
||||
}
|
||||
config.CallerSID = strings.TrimSpace(value)
|
||||
index = next
|
||||
default:
|
||||
return WindowsSandboxSetupConfig{}, fmt.Errorf("unknown windows sandbox setup flag %q", arg)
|
||||
}
|
||||
@@ -148,22 +344,49 @@ func RunWindowsSandboxSetup(args []string, stderr io.Writer) int {
|
||||
return runWindowsSandboxSetup(config, stderr)
|
||||
}
|
||||
|
||||
// commandConfig is the command-shaped view the setup half plans against. Its Env
|
||||
// carries the opt-in the caller serialized into the setup args, so every
|
||||
// downstream windowsSandboxIdentityEnabled call — the gate that decides whether
|
||||
// elevated setup provisions a principal at all — reads the caller's intent
|
||||
// rather than sampling the elevated helper's own environment, which UAC does not
|
||||
// inherit from the shell the user typed in.
|
||||
func (config WindowsSandboxSetupConfig) commandConfig() WindowsSandboxCommandConfig {
|
||||
return WindowsSandboxCommandConfig{
|
||||
SandboxHome: config.SandboxHome,
|
||||
CommandCWD: config.CommandCWD,
|
||||
WorkspaceRoots: cloneStrings(config.WorkspaceRoots),
|
||||
SandboxHome: config.SandboxHome,
|
||||
CommandCWD: config.CommandCWD,
|
||||
WorkspaceRoots: cloneStrings(config.WorkspaceRoots),
|
||||
// Taken as given. This computes the hash BOTH halves compare, and it runs
|
||||
// in whichever process is asking — including the command runner, whose TEMP
|
||||
// points at the sandbox runtime temp. Deriving the candidates here made the
|
||||
// answer depend on the caller's environment, which is the one thing a
|
||||
// shared fingerprint cannot afford. WindowsSandboxProfileWithRuntimeRoots
|
||||
// is applied by the callers whose TEMP is still the operator's.
|
||||
PermissionProfile: config.PermissionProfile,
|
||||
Env: map[string]string{windowsSandboxIdentityEnv: windowsSandboxPrincipalOptInValue(config.PrincipalOptIn)},
|
||||
SandboxLevel: WindowsSandboxLevelRestrictedToken,
|
||||
// Carried through for the same reason as the opt-in above: every principal
|
||||
// name, ledger and secret path the setup half plans is derived from the
|
||||
// invoking user, and that is the caller's identity, not this process's.
|
||||
CallerSID: config.CallerSID,
|
||||
}
|
||||
}
|
||||
|
||||
// WindowsSandboxSetupConfigFromCommand is how a command asks "was setup run for
|
||||
// what I need?". It carries the command's own opt-in so marker validation can
|
||||
// compare it against what setup actually provisioned.
|
||||
func WindowsSandboxSetupConfigFromCommand(config WindowsSandboxCommandConfig) WindowsSandboxSetupConfig {
|
||||
return WindowsSandboxSetupConfig{
|
||||
SandboxHome: config.SandboxHome,
|
||||
CommandCWD: config.CommandCWD,
|
||||
WorkspaceRoots: cloneStrings(config.WorkspaceRoots),
|
||||
SandboxHome: config.SandboxHome,
|
||||
CommandCWD: config.CommandCWD,
|
||||
WorkspaceRoots: cloneStrings(config.WorkspaceRoots),
|
||||
// NOT augmented here. This runs inside the command runner, whose TEMP and
|
||||
// TMP are the sandbox runtime's, so deriving the temp-based candidate here
|
||||
// yields a path no other process agrees on. The parent already folded the
|
||||
// candidates into the profile it serialized, back when TEMP was still the
|
||||
// operator's, so take the profile as given.
|
||||
PermissionProfile: config.PermissionProfile,
|
||||
PrincipalOptIn: windowsSandboxIdentityEnabled(config.Env),
|
||||
CallerSID: config.CallerSID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,16 +414,63 @@ func BuildWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSa
|
||||
if len(infraPlan.IdentitySIDs) > 0 {
|
||||
offlineSID = infraPlan.IdentitySIDs[0]
|
||||
}
|
||||
principalHash, err := windowsPrincipalPlanFingerprint(config)
|
||||
if err != nil {
|
||||
return WindowsSandboxSetupMarker{}, err
|
||||
}
|
||||
return WindowsSandboxSetupMarker{
|
||||
SchemaVersion: windowsSandboxSetupMarkerSchemaVersion,
|
||||
ACLPlanHash: hash,
|
||||
ACLPlanEntries: len(plan.Entries),
|
||||
NetworkInfraHash: infraHash,
|
||||
OfflineFilterSID: offlineSID,
|
||||
NetworkFilters: len(infraPlan.Filters),
|
||||
SchemaVersion: windowsSandboxSetupMarkerSchemaVersion,
|
||||
ACLPlanHash: hash,
|
||||
ACLPlanEntries: len(plan.Entries),
|
||||
NetworkInfraHash: infraHash,
|
||||
OfflineFilterSID: offlineSID,
|
||||
NetworkFilters: len(infraPlan.Filters),
|
||||
PrincipalOptIn: config.PrincipalOptIn,
|
||||
PrincipalPlanHash: principalHash,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// windowsPrincipalPlanFingerprint hashes the principal ACL plan so a change to
|
||||
// principal read or write roots invalidates setup.
|
||||
//
|
||||
// The SID is a fixed placeholder rather than the real principal's, deliberately.
|
||||
// The account is recreated with a fresh SID on reprovision, so hashing the real
|
||||
// one would make the fingerprint change every time the account is rebuilt even
|
||||
// though the GRANTED PATHS are identical, and every command would then rerun
|
||||
// setup. What must invalidate the marker is the set of paths and actions, which
|
||||
// is exactly what this captures.
|
||||
//
|
||||
// Returns empty when the principal backend is not opted into, so the default
|
||||
// install's marker is unchanged.
|
||||
func windowsPrincipalPlanFingerprint(config WindowsSandboxSetupConfig) (string, error) {
|
||||
if !config.PrincipalOptIn {
|
||||
return "", nil
|
||||
}
|
||||
filesystem := config.commandConfig().PermissionProfile.FileSystem
|
||||
// EVERY FIELD apply and teardown pass, or the fingerprint describes a
|
||||
// different plan than the one that gets applied. This omitted DenyWrite while
|
||||
// both buildWindowsPrincipalACLPlan call sites in windows_identity_runtime_windows.go
|
||||
// passed it, so a change to the policy's deny-write paths moved the applied
|
||||
// plan and left this hash where it was. The capability ACLPlanHash happens to
|
||||
// cover the same paths today, which is what kept it from being a live hole
|
||||
// and also what would have kept it invisible.
|
||||
plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: windowsPrincipalFingerprintSID,
|
||||
WriteRoots: filesystem.WriteRoots,
|
||||
ReadRoots: filesystem.ReadRoots,
|
||||
DenyRead: filesystem.DenyRead,
|
||||
DenyWrite: filesystem.DenyWrite,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fingerprint windows principal ACL plan: %w", err)
|
||||
}
|
||||
return WindowsACLPlanHash(plan)
|
||||
}
|
||||
|
||||
// windowsPrincipalFingerprintSID is a placeholder trustee used only for hashing.
|
||||
// It never reaches an ACE.
|
||||
const windowsPrincipalFingerprintSID = "S-1-0-0"
|
||||
|
||||
func WriteWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSandboxSetupMarker, error) {
|
||||
marker, err := BuildWindowsSandboxSetupMarker(config)
|
||||
if err != nil {
|
||||
@@ -255,8 +525,56 @@ func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error {
|
||||
if actual.SchemaVersion != expected.SchemaVersion {
|
||||
return fmt.Errorf("windows sandbox setup is out of date: schema %d, want %d", actual.SchemaVersion, expected.SchemaVersion)
|
||||
}
|
||||
// The two halves of the protocol run in different processes, so they can
|
||||
// disagree about the principal opt-in. Refuse the command rather than pick a
|
||||
// winner.
|
||||
//
|
||||
// The direction that matters is the first one: the opt-in is on, setup never
|
||||
// provisioned an account, and the runtime's lookup declines with a nil error —
|
||||
// so without this the command runs on the restricted token, which does not
|
||||
// confine reads, while the operator believes a principal is isolating them.
|
||||
// A sandbox that is weaker than advertised has to be loud.
|
||||
//
|
||||
// The reverse is refused too. It is not the dangerous direction — the command
|
||||
// gets the well-worn restricted token it asked for — but setup did create a
|
||||
// local account and grant it ACEs on the workspace, and letting commands run
|
||||
// as if that had not happened leaves nothing to reconcile it. Both directions
|
||||
// clear the same way: run `zero sandbox setup` again with the environment you
|
||||
// actually want.
|
||||
if actual.PrincipalOptIn != expected.PrincipalOptIn {
|
||||
if expected.PrincipalOptIn {
|
||||
return fmt.Errorf("windows sandbox setup is out of date: %s=1 asks for a sandbox principal, but setup provisioned none — "+
|
||||
"re-run `zero sandbox setup` from an elevated (Administrator) terminal with %s=1, or unset it to use the restricted-token sandbox",
|
||||
windowsSandboxIdentityEnv, windowsSandboxIdentityEnv)
|
||||
}
|
||||
// "principals", plural: a workspace gets an offline and an online account,
|
||||
// and the opt-out path retires the legacy one alongside them. The singular
|
||||
// described the pre-split world and understated what the re-run does.
|
||||
return fmt.Errorf("windows sandbox setup is out of date: setup provisioned sandbox principals, but %s is not set for this command — "+
|
||||
"set %s=1, or re-run `zero sandbox setup` from an elevated (Administrator) terminal without it to retire them",
|
||||
windowsSandboxIdentityEnv, windowsSandboxIdentityEnv)
|
||||
}
|
||||
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")
|
||||
// Say WHAT disagrees. The bare "permission roots or deny lists changed"
|
||||
// left an operator with a command that refuses to run and no way to tell
|
||||
// which side is wrong, or even whether the marker belongs to this
|
||||
// workspace at all. The entry counts separate the two shapes this takes:
|
||||
// equal counts mean the same roots spelled differently, unequal counts
|
||||
// mean one side has roots the other has never heard of.
|
||||
return fmt.Errorf("windows sandbox setup is out of date: permission roots or deny lists changed "+
|
||||
"(marker %s has %d entries, hash %s; this command expects %d entries, hash %s) — "+
|
||||
"re-run `zero sandbox setup` from an elevated (Administrator) terminal",
|
||||
path, actual.ACLPlanEntries, shortWindowsACLPlanHash(actual.ACLPlanHash),
|
||||
expected.ACLPlanEntries, shortWindowsACLPlanHash(expected.ACLPlanHash))
|
||||
}
|
||||
// The capability-SID plan above and the principal plan are built separately
|
||||
// from the same profile, so the hash above does not cover principal grants.
|
||||
// Without this check, removing a principal read root left setup looking
|
||||
// current and the stale AllowRead ACE in place, which is the opposite of
|
||||
// what narrowing a policy is supposed to do.
|
||||
if actual.PrincipalPlanHash != expected.PrincipalPlanHash {
|
||||
return errors.New("windows sandbox setup is out of date: sandbox principal grants changed — " +
|
||||
"re-run `zero sandbox setup` from an elevated (Administrator) terminal so the old grants are revoked")
|
||||
}
|
||||
// Mode-agnostic: validate the provisioned infrastructure, never the
|
||||
// per-command network mode — so an approved (allow) network command and an
|
||||
@@ -306,3 +624,234 @@ func canonicalWindowsACLEntries(entries []WindowsACLEntry) []WindowsACLEntry {
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// windowsSandboxRuntimeCandidates returns every runtime root setup provisions.
|
||||
//
|
||||
// BOTH candidates, not the one this process would select. sandboxRuntimeRootFor
|
||||
// prefers the cache-derived root and falls back to the temp-derived one when the
|
||||
// first would land inside the workspace or its lease cannot be taken, and that
|
||||
// choice is made per process. Setup that granted only its own choice left the
|
||||
// other unprovisioned, so a command that fell back wrote to a tree with no ACE
|
||||
// on it. Both are deterministic now, so setup can cover both and command
|
||||
// selection lands on a provisioned root either way.
|
||||
func windowsSandboxRuntimeCandidates(workspaceRoots []string) []string {
|
||||
workspaceRoot := ""
|
||||
for _, candidate := range workspaceRoots {
|
||||
if trimmed := strings.TrimSpace(candidate); trimmed != "" {
|
||||
workspaceRoot = canonicalSandboxWorkspaceRoot(trimmed)
|
||||
break
|
||||
}
|
||||
}
|
||||
if workspaceRoot == "" || workspaceRoot == "." {
|
||||
return nil
|
||||
}
|
||||
var roots []string
|
||||
if cacheRoot, err := sandboxUserCacheDir(); err == nil {
|
||||
if cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot); cacheRoot != "" && cacheRoot != "." {
|
||||
if root, ok := deterministicSandboxRuntimeRoot(workspaceRoot, cacheRoot); ok {
|
||||
roots = append(roots, root)
|
||||
}
|
||||
}
|
||||
}
|
||||
if root, err := fallbackSandboxRuntimeRoot(workspaceRoot); err == nil {
|
||||
roots = append(roots, root)
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Adding the full candidate set on both sides makes the two hashes agree without
|
||||
// the command having to know which root setup happened to pick, and it puts the
|
||||
// runtime roots 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 := windowsSandboxRuntimeCandidates(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
|
||||
}
|
||||
|
||||
// ensureWindowsSandboxRuntimeCandidates creates every runtime root setup grants.
|
||||
//
|
||||
// Paired with windowsSandboxProfileWithRuntime: that function puts the candidates
|
||||
// into the ACL plan, and this one makes them 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. Whenever one of these grows a candidate, so must the other.
|
||||
//
|
||||
// Called on the setup side only. A command must never create these: setup is the
|
||||
// gate that decides which trees the sandbox may write to, and a command that
|
||||
// created its own root would be granting itself one.
|
||||
func ensureWindowsSandboxRuntimeCandidates(workspaceRoots []string) error {
|
||||
for _, root := range windowsSandboxRuntimeCandidates(workspaceRoots) {
|
||||
if err := ensureRuntimeCandidateDir(root); err != nil {
|
||||
return fmt.Errorf("create sandbox runtime root %s: %w", root, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureRuntimeCandidateDir creates one candidate without following a reparse
|
||||
// point into anything Zero does not own.
|
||||
//
|
||||
// VALIDATION BEFORE THE PRIVILEGED MUTATION, NOT AFTER IT. This runs as
|
||||
// Administrator, and os.MkdirAll follows links: the invoking user can prepare
|
||||
// either candidate's ancestry, so a junction planted at a not-yet-created
|
||||
// component below the cache directory, or at the fallback anchor, made elevated
|
||||
// setup create the tree beneath a redirected, Administrator-writable target. The
|
||||
// ACL applier's own no-follow check runs later and correctly spots the
|
||||
// redirection, but by then the privileged create has already happened, outside
|
||||
// the applier's rollback boundary. A leaf check after MkdirAll would leave the
|
||||
// same gap.
|
||||
//
|
||||
// EnsurePrivateDir is the descent that does not have it: handle-relative,
|
||||
// no-follow, refusing any component that is a link and any leaf this user does
|
||||
// not own. It is given a PHYSICAL base first, because it walks from the volume
|
||||
// root and a redirected cache or TEMP above the owned tail is the operator's
|
||||
// business, not ours -- the same pairing the fallback anchor uses, and the same
|
||||
// mistake that refused every fallback on macOS when the parent was left
|
||||
// unresolved.
|
||||
func ensureRuntimeCandidateDir(root string) error {
|
||||
base, ok := runtimeCandidateBase(root)
|
||||
if !ok {
|
||||
return fmt.Errorf("candidate %s does not sit beneath a known base, so its trust boundary cannot be established", root)
|
||||
}
|
||||
tail, err := filepath.Rel(base, root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("locate %s beneath %s: %w", root, base, err)
|
||||
}
|
||||
// A resolve failure keeps the unresolved base deliberately: EnsurePrivateDir
|
||||
// is still the fail-closed check, and refusing there names the real component
|
||||
// rather than hiding it behind a resolver error.
|
||||
physical := base
|
||||
if resolved, resolveErr := physicalDir(base); resolveErr == nil && resolved != "" {
|
||||
physical = resolved
|
||||
}
|
||||
return peermsg.EnsurePrivateDir(filepath.Join(physical, tail))
|
||||
}
|
||||
|
||||
// runtimeCandidateBase returns the ancestor of a candidate that belongs to the
|
||||
// operator rather than to Zero. Everything below it was created by us and has
|
||||
// no business being a link.
|
||||
func runtimeCandidateBase(root string) (string, bool) {
|
||||
// The fallback candidate is <temp>/zero-runtime-<tag>/v1/<digest>, and the
|
||||
// anchor's own parent is the operator's temp directory.
|
||||
if isFallbackRuntimeRoot(root) {
|
||||
return filepath.Dir(fallbackRuntimeAnchor()), true
|
||||
}
|
||||
// The cache candidate is <cacheRoot>/zero/runtime/v1/<digest>.
|
||||
if cacheRoot, err := sandboxUserCacheDir(); err == nil {
|
||||
if cleaned := canonicalSandboxWorkspaceRoot(cacheRoot); cleaned != "" && cleaned != "." && pathWithinRoot(cleaned, root) {
|
||||
return cleaned, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// WindowsSandboxPrincipalRoleForNetwork names the principal a command in this
|
||||
// network mode runs as.
|
||||
//
|
||||
// Exported and portable so `zero doctor` and the Windows runtime answer from ONE
|
||||
// rule. The helper this replaces existed for that reason and said so, and the
|
||||
// thing it was protecting against happened anyway once dual-role provisioning
|
||||
// changed the behaviour under NetworkDeny: the runtime started using an offline
|
||||
// principal while doctor still reported the old restricted-token standdown, so
|
||||
// operators were told their reads were unconfined when they were confined.
|
||||
// windowsSandboxRoleForNetwork delegates here rather than repeating the switch.
|
||||
//
|
||||
// Anything that is not an EXACT allow is offline: a mode this does not recognise
|
||||
// should lose the network, not keep it.
|
||||
//
|
||||
// Deliberately not normalized first. NormalizeNetworkMode case-folds, so routing
|
||||
// through it made "ALLOW" select the online principal where the runtime required
|
||||
// an exact match and failed closed to offline. Sharing one rule is only an
|
||||
// improvement if it shares the STRICTER one; a shared rule that quietly widened
|
||||
// the runtime would be worse than the drift it fixes.
|
||||
func WindowsSandboxPrincipalRoleForNetwork(network NetworkMode) string {
|
||||
if network == NetworkAllow {
|
||||
return "online"
|
||||
}
|
||||
return "offline"
|
||||
}
|
||||
|
||||
// errWindowsPrincipalLaunchUnavailable is why provisioning refuses.
|
||||
//
|
||||
// Stated as one sentence about the MECHANISM rather than about the caller's
|
||||
// token, because the caller's token was never the question: the process that
|
||||
// needs the launch privileges is a later, ordinary one, and no supported path
|
||||
// carries launch authority to it. A message about the current process invited
|
||||
// the operator to re-run from an elevated terminal, which passes such a check
|
||||
// and still cannot run a command.
|
||||
var errWindowsPrincipalLaunchUnavailable = errors.New(
|
||||
"launching a command as a separate account needs SeAssignPrimaryTokenPrivilege and SeIncreaseQuotaPrivilege, " +
|
||||
"which an ordinary command process does not hold; elevated setup cannot grant them to it, and no bootstrap " +
|
||||
"or broker exists yet to carry that authority across. The restricted-token sandbox is unaffected: unset " +
|
||||
windowsSandboxIdentityEnv + " to use it",
|
||||
)
|
||||
|
||||
// windowsPrincipalLaunchAvailable reports whether a provisioned principal could
|
||||
// actually be launched by the process that will run the command.
|
||||
//
|
||||
// ONE SEAM, DEFAULTING TO UNAVAILABLE. The answer does not depend on the token
|
||||
// of whoever calls setup, so this takes no argument: it is a statement about
|
||||
// which mechanisms exist, not about the current process. When a bootstrap or
|
||||
// broker lands, this is the single place that learns to say yes, and the plans,
|
||||
// ledger and ACL work below it are already built and tested against it.
|
||||
//
|
||||
// It stays a var so the argument-plumbing tests can exercise the opt-in path
|
||||
// without depending on a capability no machine currently has.
|
||||
var windowsPrincipalLaunchAvailable = func() error {
|
||||
return errWindowsPrincipalLaunchUnavailable
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// windowsSetupSeams stubs every external effect of the setup transaction so a
|
||||
// test can drive its control flow on an ordinary machine. Each seam defaults to
|
||||
// a benign success, and a test overrides only the one it is about.
|
||||
type windowsSetupSeams struct {
|
||||
elevated bool
|
||||
// principalStillInstalled is what the account lookup reports AFTER retirement
|
||||
// ran. It is the whole of the opt-out branch's fatality rule, so it is stated
|
||||
// separately from retireErr rather than inferred from it.
|
||||
principalStillInstalled bool
|
||||
aclRollback func() error
|
||||
retireErr error
|
||||
provisionErr error
|
||||
networkErr error
|
||||
markerErr error
|
||||
rollbackCalled *bool
|
||||
markerWritten *bool
|
||||
}
|
||||
|
||||
func withWindowsSetupSeams(t *testing.T, seams windowsSetupSeams) {
|
||||
t.Helper()
|
||||
|
||||
originalElevated := windowsProcessIsElevatedFn
|
||||
originalLock := acquireWindowsSandboxSetupLockFn
|
||||
originalACL := applyWindowsACLPlanFn
|
||||
originalPrincipal := setupWindowsSandboxPrincipalFn
|
||||
originalRetire := removeWindowsSandboxPrincipalsForSetupFn
|
||||
originalGroupSID := resolveWindowsSandboxOfflineGroupSIDHook
|
||||
originalLookup := lookupWindowsSandboxIdentityFn
|
||||
originalNetwork := applyWindowsNetworkPlanFn
|
||||
originalMarker := writeWindowsSandboxSetupMarkerFn
|
||||
t.Cleanup(func() {
|
||||
lookupWindowsSandboxIdentityFn = originalLookup
|
||||
windowsProcessIsElevatedFn = originalElevated
|
||||
acquireWindowsSandboxSetupLockFn = originalLock
|
||||
applyWindowsACLPlanFn = originalACL
|
||||
setupWindowsSandboxPrincipalFn = originalPrincipal
|
||||
removeWindowsSandboxPrincipalsForSetupFn = originalRetire
|
||||
resolveWindowsSandboxOfflineGroupSIDHook = originalGroupSID
|
||||
applyWindowsNetworkPlanFn = originalNetwork
|
||||
writeWindowsSandboxSetupMarkerFn = originalMarker
|
||||
})
|
||||
|
||||
windowsProcessIsElevatedFn = func() bool { return seams.elevated }
|
||||
// A zero lock is safe to release: release() tolerates a nil handle and
|
||||
// runtime.UnlockOSThread is a no-op when the thread was never pinned.
|
||||
acquireWindowsSandboxSetupLockFn = func(string) (*windowsSandboxSetupLock, error) {
|
||||
return &windowsSandboxSetupLock{}, nil
|
||||
}
|
||||
applyWindowsACLPlanFn = func(WindowsACLPlan) (func() error, error) {
|
||||
return func() error {
|
||||
if seams.rollbackCalled != nil {
|
||||
*seams.rollbackCalled = true
|
||||
}
|
||||
if seams.aclRollback != nil {
|
||||
return seams.aclRollback()
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
setupWindowsSandboxPrincipalFn = func(WindowsSandboxCommandConfig) (func() error, error) {
|
||||
if seams.provisionErr != nil {
|
||||
return nil, seams.provisionErr
|
||||
}
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
removeWindowsSandboxPrincipalsForSetupFn = func(WindowsSandboxCommandConfig) error {
|
||||
return seams.retireErr
|
||||
}
|
||||
// Dual-role setup asserts the block filters name the offline group, which a
|
||||
// stubbed provisioning never creates.
|
||||
resolveWindowsSandboxOfflineGroupSIDHook = func() (string, error) { return "S-1-5-32-999", nil }
|
||||
lookupWindowsSandboxIdentityFn = func(string, windowsSandboxRole) (windowsSandboxIdentity, error) {
|
||||
if seams.principalStillInstalled {
|
||||
return windowsSandboxIdentity{Username: "zero-sbx-stub"}, nil
|
||||
}
|
||||
return windowsSandboxIdentity{}, errWindowsSandboxIdentityUnavailable
|
||||
}
|
||||
applyWindowsNetworkPlanFn = func(WindowsNetworkPlan) error { return seams.networkErr }
|
||||
writeWindowsSandboxSetupMarkerFn = func(config WindowsSandboxSetupConfig) (WindowsSandboxSetupMarker, error) {
|
||||
if seams.markerWritten != nil {
|
||||
*seams.markerWritten = true
|
||||
}
|
||||
if seams.markerErr != nil {
|
||||
return WindowsSandboxSetupMarker{}, seams.markerErr
|
||||
}
|
||||
return BuildWindowsSandboxSetupMarker(config)
|
||||
}
|
||||
}
|
||||
|
||||
func windowsSetupTestConfig(t *testing.T, optIn bool) WindowsSandboxSetupConfig {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
return WindowsSandboxSetupConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: root,
|
||||
WorkspaceRoots: []string{root},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: root}}},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
},
|
||||
PrincipalOptIn: optIn,
|
||||
}
|
||||
}
|
||||
|
||||
// windowsForeignSID is a well-formed account SID that no test machine's own
|
||||
// process can be running as.
|
||||
const windowsForeignSID = "S-1-5-21-1111111111-2222222222-3333333333-1001"
|
||||
|
||||
// The finding: opting out printed the retirement failure and carried on to write
|
||||
// an opted-out marker and exit 0.
|
||||
//
|
||||
// That is the one outcome this branch must never produce. Teardown is
|
||||
// idempotent, so a failure here means a real account, secret, logon right, ACE
|
||||
// set and ledger are still installed — and the marker now says none of them
|
||||
// exist, so nothing afterwards will ever look for them again.
|
||||
//
|
||||
// Asserting on the marker as well as the exit code is deliberate: an exit code
|
||||
// alone would still pass if the marker had already been written, and the marker
|
||||
// is what makes the residue permanent.
|
||||
func TestOptOutRetirementFailureFailsSetupAndWritesNoMarker(t *testing.T) {
|
||||
markerWritten := false
|
||||
rollbackCalled := false
|
||||
withWindowsSetupSeams(t, windowsSetupSeams{
|
||||
elevated: true,
|
||||
retireErr: errors.New("sandbox principal zero-sbx-abc could not be deleted"),
|
||||
principalStillInstalled: true,
|
||||
markerWritten: &markerWritten,
|
||||
rollbackCalled: &rollbackCalled,
|
||||
})
|
||||
|
||||
var stderr bytes.Buffer
|
||||
config := windowsSetupTestConfig(t, false)
|
||||
if code := runWindowsSandboxSetup(config, &stderr); code == 0 {
|
||||
t.Fatal("setup reported success after failing to retire an existing sandbox principal, so the operator is told the machine is clean while the account is still installed")
|
||||
}
|
||||
if markerWritten {
|
||||
t.Error("setup wrote an opted-out marker after the retirement failed, which is what makes the leftover principal permanently invisible")
|
||||
}
|
||||
if !rollbackCalled {
|
||||
t.Error("setup failed without undoing the ACL grants it had already applied")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "could not be deleted") {
|
||||
t.Errorf("the underlying retirement error must reach the operator, got %q", stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(WindowsSandboxSetupMarkerPath(config.SandboxHome)); !os.IsNotExist(err) {
|
||||
t.Errorf("no marker file should exist on disk after a failed setup, stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The other side of that rule, and a regression I introduced fixing the first
|
||||
// one: failing on ANY retirement error is wrong.
|
||||
//
|
||||
// removeWindowsSandboxPrincipalForSetup deliberately carries on past a secret
|
||||
// file owned by another administrator's setup (errWindowsSandboxSecretNotOurs
|
||||
// goes into secretErr and is reported at the end via errors.Join) so one
|
||||
// unlinkable file cannot strand the account, its logon rights and its ACEs. The
|
||||
// account is therefore already gone when that error is returned, and treating it
|
||||
// as fatal made `zero sandbox setup` fail permanently on such a machine, taking
|
||||
// the DEFAULT restricted-token sandbox down with it over inert residue.
|
||||
//
|
||||
// Setup must complete, and must still say what was left behind.
|
||||
func TestOptOutSucceedsWhenOnlyInertResidueSurvivesRetirement(t *testing.T) {
|
||||
markerWritten := false
|
||||
rollbackCalled := false
|
||||
withWindowsSetupSeams(t, windowsSetupSeams{
|
||||
elevated: true,
|
||||
// What teardown reports when the only leftover is another administrator's
|
||||
// secret file: the account, its rights, its ACEs and its ledger are gone.
|
||||
retireErr: errWindowsSandboxSecretNotOurs,
|
||||
principalStillInstalled: false,
|
||||
markerWritten: &markerWritten,
|
||||
rollbackCalled: &rollbackCalled,
|
||||
})
|
||||
|
||||
var stderr bytes.Buffer
|
||||
if code := runWindowsSandboxSetup(windowsSetupTestConfig(t, false), &stderr); code != 0 {
|
||||
t.Fatalf("setup failed over residue left behind by an already-retired principal, which makes the default sandbox unsetupable on this machine: %s", stderr.String())
|
||||
}
|
||||
if !markerWritten {
|
||||
t.Error("setup that completed must write its marker")
|
||||
}
|
||||
if rollbackCalled {
|
||||
t.Error("setup rolled back its ACL work over residue it had already reported as survivable")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "residue") {
|
||||
t.Errorf("the leftover must still be named for whoever cleans it up, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The opt-out path still succeeds when retirement succeeds. Without this the
|
||||
// test above would pass against a branch that always failed.
|
||||
func TestOptOutSucceedsWhenRetirementSucceeds(t *testing.T) {
|
||||
markerWritten := false
|
||||
withWindowsSetupSeams(t, windowsSetupSeams{elevated: true, markerWritten: &markerWritten})
|
||||
|
||||
var stderr bytes.Buffer
|
||||
if code := runWindowsSandboxSetup(windowsSetupTestConfig(t, false), &stderr); code != 0 {
|
||||
t.Fatalf("opting out with nothing to retire must succeed, got %d: %s", code, stderr.String())
|
||||
}
|
||||
if !markerWritten {
|
||||
t.Error("a successful setup must write its marker")
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the caller-identity finding: a principal provisioned by an
|
||||
// administrator who is not the caller can never be used, because its password is
|
||||
// sealed to the user that stores it. Refuse before anything is created rather
|
||||
// than leave an account, a secret and a grant nobody can reach.
|
||||
func TestPrincipalSetupRefusesWhenElevatedAsAnotherUser(t *testing.T) {
|
||||
markerWritten := false
|
||||
withWindowsSetupSeams(t, windowsSetupSeams{elevated: true, markerWritten: &markerWritten})
|
||||
|
||||
config := windowsSetupTestConfig(t, true)
|
||||
config.CallerSID = windowsForeignSID
|
||||
if current := windowsCurrentUserSID(); current == "" || strings.EqualFold(current, config.CallerSID) {
|
||||
t.Skipf("cannot distinguish the caller from this process (current SID %q)", current)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
if code := runWindowsSandboxSetup(config, &stderr); code == 0 {
|
||||
t.Fatal("setup provisioned a sandbox principal for a caller whose secret it cannot seal, so every later command would find the account and fail to unseal its password")
|
||||
}
|
||||
if markerWritten {
|
||||
t.Error("setup wrote a marker for a principal it refused to provision")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "different Windows user") {
|
||||
t.Errorf("the refusal must say what is wrong and how to clear it, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal is scoped to the principal opt-in. The default restricted-token
|
||||
// sandbox stores no secret and names no account after the user, so it works
|
||||
// perfectly well across this boundary and must keep doing so.
|
||||
func TestRestrictedTokenSetupIsUnaffectedByACrossUserElevation(t *testing.T) {
|
||||
withWindowsSetupSeams(t, windowsSetupSeams{elevated: true})
|
||||
|
||||
config := windowsSetupTestConfig(t, false)
|
||||
config.CallerSID = windowsForeignSID
|
||||
|
||||
var stderr bytes.Buffer
|
||||
if code := runWindowsSandboxSetup(config, &stderr); code != 0 {
|
||||
t.Fatalf("the restricted-token sandbox does not depend on the invoking user and must still set up, got %d: %s", code, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown caller SID is treated as a match. That is the pre-existing
|
||||
// behaviour for an older caller or a token query that failed, and refusing on
|
||||
// absence would break setup on machines where nothing is wrong.
|
||||
func TestPrincipalSetupProceedsWhenTheCallerIsUnknown(t *testing.T) {
|
||||
withWindowsSetupSeams(t, windowsSetupSeams{elevated: true})
|
||||
|
||||
var stderr bytes.Buffer
|
||||
if code := runWindowsSandboxSetup(windowsSetupTestConfig(t, true), &stderr); code != 0 {
|
||||
t.Fatalf("an unstated caller must not block setup, got %d: %s", code, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The account name, its ledger and its ACEs are all derived from this key, and
|
||||
// under over-the-shoulder UAC the elevated helper is a different user than the
|
||||
// caller. Keying off the caller is what makes setup provision the account the
|
||||
// caller's next command will actually look for.
|
||||
func TestPrincipalKeyFollowsTheCallerNotTheCurrentProcess(t *testing.T) {
|
||||
roots := []string{`C:\ws\shared`}
|
||||
self := windowsSandboxPrincipalKey(WindowsSandboxCommandConfig{WorkspaceRoots: roots})
|
||||
caller := windowsSandboxPrincipalKey(WindowsSandboxCommandConfig{WorkspaceRoots: roots, CallerSID: windowsForeignSID})
|
||||
if self == caller {
|
||||
t.Fatal("the principal key ignored the caller SID, so elevated setup would provision an account named after the administrator who typed the credentials")
|
||||
}
|
||||
if again := windowsSandboxPrincipalKey(WindowsSandboxCommandConfig{WorkspaceRoots: roots, CallerSID: windowsForeignSID}); again != caller {
|
||||
t.Error("the caller-scoped key is not stable, so setup and the command half would derive different accounts")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Serializing elevated setup across processes.
|
||||
//
|
||||
// WHY THIS EXISTS. Setup is not one write, it is a transaction: it rotates the
|
||||
// principal's password, stores the secret, read-modify-writes the ACL ledger,
|
||||
// installs WFP filters and finally writes the marker. Two setups for the same
|
||||
// workspace running at once interleave those steps, and the interleavings are
|
||||
// not merely untidy. The account ends up with process B's password while the
|
||||
// stored secret is process A's, so every later command fails to log on. The
|
||||
// ledger's read-modify-write loses one process's path set, so ACEs that should
|
||||
// have been revoked stay on disk with nothing recording them.
|
||||
//
|
||||
// Atomic individual writes do not help. Each step being all-or-nothing is
|
||||
// orthogonal to two processes interleaving BETWEEN steps, which is the actual
|
||||
// failure. The lock has to span the whole transaction.
|
||||
//
|
||||
// Worth recording: the reference implementation this was compared against has
|
||||
// the identical bug. Its password rotation and secret write hold no lock at
|
||||
// all, its named mutex guards only an unrelated read-ACL-only mode, and the
|
||||
// singleflight that looks like a fix is process-local and gives no cross-process
|
||||
// guarantee. There was nothing to port, so this is built.
|
||||
|
||||
// windowsSetupLockTimeout bounds the wait. Setup is interactive and elevated,
|
||||
// so a minute is generous for a legitimate concurrent run and short enough that
|
||||
// a stuck holder gets reported rather than hung on. A var rather than a const
|
||||
// only so a contention test does not have to wait a real minute to prove the
|
||||
// two setups exclude each other.
|
||||
var windowsSetupLockTimeout = 60 * time.Second
|
||||
|
||||
// windowsSetupLockSDDL grants full control to Administrators and SYSTEM and
|
||||
// nobody else, with the DACL protected so no inherited ACE widens it.
|
||||
//
|
||||
// Load-bearing rather than tidy. A sandbox principal able to open this object
|
||||
// could hold it and stall every future setup, or create it first and squat the
|
||||
// name so real setup believes it holds a lock it does not. Only accounts that
|
||||
// could already run setup may touch it.
|
||||
//
|
||||
// A var so a test can exercise the mutex wait path without elevation. Setup
|
||||
// itself always runs elevated, so unelevated callers cannot even open the
|
||||
// object under this DACL, which would make the contention test unrunnable
|
||||
// anywhere it matters. TestSetupLockIsAdministratorsOnly pins the production
|
||||
// value so loosening it needs an explicit change here.
|
||||
var windowsSetupLockSDDL = "D:P(A;;GA;;;BA)(A;;GA;;;SY)"
|
||||
|
||||
const (
|
||||
// windowsWaitTimeout is WAIT_TIMEOUT. x/sys exports WAIT_ABANDONED,
|
||||
// WAIT_OBJECT_0 and WAIT_FAILED at this version but not this one.
|
||||
windowsWaitTimeout = 0x00000102
|
||||
|
||||
// windowsSetupLockKeyChars keeps the object name short while staying
|
||||
// collision-free in practice: the key is already a hash, so a prefix of it
|
||||
// distinguishes workspaces.
|
||||
windowsSetupLockKeyChars = 32
|
||||
)
|
||||
|
||||
// windowsSandboxSetupLock is a held cross-process setup lock. Release it once.
|
||||
type windowsSandboxSetupLock struct {
|
||||
handle windows.Handle
|
||||
released bool
|
||||
// abandoned records that the previous holder died without releasing, which
|
||||
// means it stopped somewhere inside the transaction.
|
||||
abandoned bool
|
||||
}
|
||||
|
||||
// windowsSandboxSetupLockName derives the object name for a workspace.
|
||||
//
|
||||
// Per-workspace rather than machine-wide because every artifact the lock
|
||||
// protects is per-workspace: this account, this secret, this ledger. A global
|
||||
// lock would serialize unrelated workspaces for no reason.
|
||||
//
|
||||
// Global rather than Local because the protected state is not session-scoped.
|
||||
// The local account, its LSA logon rights and the WFP filters are machine
|
||||
// state, so two setups in different logon sessions must still exclude each
|
||||
// other. Creating a Global object needs SeCreateGlobalPrivilege, which the
|
||||
// elevated setup helper has and which is the only caller.
|
||||
func windowsSandboxSetupLockName(workspaceKey string) string {
|
||||
key := strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
return r
|
||||
case r >= 'A' && r <= 'Z':
|
||||
return r + ('a' - 'A')
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}, workspaceKey)
|
||||
if key == "" {
|
||||
key = "default"
|
||||
}
|
||||
if len(key) > windowsSetupLockKeyChars {
|
||||
key = key[:windowsSetupLockKeyChars]
|
||||
}
|
||||
return `Global\ZeroSandboxSetup-` + key
|
||||
}
|
||||
|
||||
// acquireWindowsSandboxSetupLock blocks until this process owns the setup lock
|
||||
// for a workspace, or fails with an actionable error.
|
||||
//
|
||||
// The OS thread is pinned for the lock's lifetime. Win32 mutex ownership is
|
||||
// thread-affine, so a goroutine that migrated between the wait and the release
|
||||
// would call ReleaseMutex from a thread that does not own the object, leaving
|
||||
// the mutex held until the process exits. That is a deadlock for every later
|
||||
// setup on the machine, so the pinning is not optional.
|
||||
func acquireWindowsSandboxSetupLock(workspaceKey string) (*windowsSandboxSetupLock, error) {
|
||||
descriptor, err := windows.SecurityDescriptorFromString(windowsSetupLockSDDL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build the sandbox setup lock security descriptor: %w", err)
|
||||
}
|
||||
attributes := windows.SecurityAttributes{SecurityDescriptor: descriptor}
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
|
||||
name := windowsSandboxSetupLockName(workspaceKey)
|
||||
namePtr, err := windows.UTF16PtrFromString(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode the sandbox setup lock name: %w", err)
|
||||
}
|
||||
// initialOwner false, then wait explicitly. Asking for ownership at creation
|
||||
// would take the lock only when this process created the object, so the
|
||||
// create and open paths would differ and only one of them would ever wait.
|
||||
handle, err := windows.CreateMutex(&attributes, false, namePtr)
|
||||
if handle == 0 {
|
||||
return nil, fmt.Errorf("open the sandbox setup lock %s: %w", name, err)
|
||||
}
|
||||
runtime.LockOSThread()
|
||||
|
||||
lock := &windowsSandboxSetupLock{handle: handle}
|
||||
state, waitErr := windows.WaitForSingleObject(handle, uint32(windowsSetupLockTimeout/time.Millisecond))
|
||||
switch state {
|
||||
case windows.WAIT_OBJECT_0:
|
||||
return lock, nil
|
||||
case windows.WAIT_ABANDONED:
|
||||
// The previous holder died inside the transaction. We own the mutex now.
|
||||
// Proceeding is right rather than refusing: setup is idempotent and
|
||||
// re-running it is exactly how a half-finished transaction is repaired,
|
||||
// whereas refusing would leave the machine stuck in that half state with
|
||||
// no way forward.
|
||||
lock.abandoned = true
|
||||
return lock, nil
|
||||
case windowsWaitTimeout:
|
||||
lock.releaseThreadAndHandle()
|
||||
return nil, fmt.Errorf("another `zero sandbox setup` is already running for this workspace and did not finish within %s; "+
|
||||
"wait for it to finish, or look for a stuck elevated setup process before re-running", windowsSetupLockTimeout)
|
||||
default:
|
||||
lock.releaseThreadAndHandle()
|
||||
if waitErr != nil {
|
||||
return nil, fmt.Errorf("wait for the sandbox setup lock %s: %w", name, waitErr)
|
||||
}
|
||||
return nil, fmt.Errorf("wait for the sandbox setup lock %s returned an unexpected state %#x", name, state)
|
||||
}
|
||||
}
|
||||
|
||||
// Abandoned reports that the previous holder crashed mid-transaction, so this
|
||||
// run is repairing a half-finished setup rather than starting a clean one.
|
||||
func (lock *windowsSandboxSetupLock) Abandoned() bool {
|
||||
return lock != nil && lock.abandoned
|
||||
}
|
||||
|
||||
// release drops ownership and unpins the thread. Safe to call twice, so a defer
|
||||
// and an explicit call cannot double-release.
|
||||
func (lock *windowsSandboxSetupLock) release() {
|
||||
if lock == nil || lock.released {
|
||||
return
|
||||
}
|
||||
lock.released = true
|
||||
// Release before closing. Closing a still-held mutex leaves it abandoned for
|
||||
// the next waiter, which is recoverable but reports a crash that never
|
||||
// happened.
|
||||
_ = windows.ReleaseMutex(lock.handle)
|
||||
lock.releaseThreadAndHandle()
|
||||
}
|
||||
|
||||
// releaseThreadAndHandle unpins the thread and drops the handle without
|
||||
// releasing ownership, for the paths that never acquired it.
|
||||
func (lock *windowsSandboxSetupLock) releaseThreadAndHandle() {
|
||||
if lock.handle != 0 {
|
||||
_ = windows.CloseHandle(lock.handle)
|
||||
lock.handle = 0
|
||||
}
|
||||
runtime.UnlockOSThread()
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A unique key per test run, so a leftover object from an earlier run cannot
|
||||
// make these pass or fail for the wrong reason.
|
||||
func lockTestKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
return fmt.Sprintf("t%dx%d", time.Now().UnixNano(), len(t.Name()))
|
||||
}
|
||||
|
||||
// The production DACL is Administrators only, so an unelevated test process
|
||||
// cannot OPEN an existing lock and never reaches the mutex wait at all. These
|
||||
// tests are about the wait, so they relax the DACL for themselves.
|
||||
// TestSetupLockIsAdministratorsOnly pins the real value.
|
||||
func withOpenableLock(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := windowsSetupLockSDDL
|
||||
windowsSetupLockSDDL = "D:P(A;;GA;;;WD)"
|
||||
t.Cleanup(func() { windowsSetupLockSDDL = previous })
|
||||
}
|
||||
|
||||
func withShortLockTimeout(t *testing.T, d time.Duration) {
|
||||
t.Helper()
|
||||
previous := windowsSetupLockTimeout
|
||||
windowsSetupLockTimeout = d
|
||||
t.Cleanup(func() { windowsSetupLockTimeout = previous })
|
||||
}
|
||||
|
||||
// THE POINT OF THE WHOLE FILE. A second setup for the same workspace must not
|
||||
// get in while the first holds the lock.
|
||||
//
|
||||
// Without this, two elevated setups interleave: the account ends up with one
|
||||
// process's password while the stored secret is the other's, and every later
|
||||
// command fails to log on. The second acquisition runs on its own goroutine
|
||||
// because Win32 mutex ownership is per THREAD and acquire pins the thread, so a
|
||||
// same-thread re-entry would succeed recursively and prove nothing.
|
||||
func TestSecondSetupIsExcludedWhileTheFirstHoldsTheLock(t *testing.T) {
|
||||
withShortLockTimeout(t, 300*time.Millisecond)
|
||||
withOpenableLock(t)
|
||||
key := lockTestKey(t)
|
||||
|
||||
first, err := acquireWindowsSandboxSetupLock(key)
|
||||
if err != nil {
|
||||
t.Fatalf("first acquisition: %v", err)
|
||||
}
|
||||
defer first.release()
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
second, err := acquireWindowsSandboxSetupLock(key)
|
||||
if err == nil {
|
||||
second.release()
|
||||
}
|
||||
result <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-result:
|
||||
if err == nil {
|
||||
t.Fatal("a second setup acquired the lock while the first still held it, so the transaction is not serialized")
|
||||
}
|
||||
// It has to say what to do, since the operator's next question is always
|
||||
// whether to wait or to go looking for a stuck process.
|
||||
if !strings.Contains(err.Error(), "already running") {
|
||||
t.Errorf("contention error does not explain itself: %v", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the second acquisition neither succeeded nor timed out, so the bounded wait is not bounded")
|
||||
}
|
||||
}
|
||||
|
||||
// Releasing must actually hand the lock on. A lock that excludes forever is a
|
||||
// worse failure than one that never excluded: the machine could never be set up
|
||||
// again without a reboot.
|
||||
func TestReleasingTheLockLetsTheNextSetupIn(t *testing.T) {
|
||||
withShortLockTimeout(t, 5*time.Second)
|
||||
withOpenableLock(t)
|
||||
key := lockTestKey(t)
|
||||
|
||||
first, err := acquireWindowsSandboxSetupLock(key)
|
||||
if err != nil {
|
||||
t.Fatalf("first acquisition: %v", err)
|
||||
}
|
||||
first.release()
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
second, err := acquireWindowsSandboxSetupLock(key)
|
||||
if err == nil {
|
||||
second.release()
|
||||
}
|
||||
result <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-result:
|
||||
if err != nil {
|
||||
t.Fatalf("the lock was not released, so no later setup could ever run: %v", err)
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("acquiring a released lock hung")
|
||||
}
|
||||
}
|
||||
|
||||
// Different workspaces must not block each other. A machine-wide lock would
|
||||
// serialize unrelated setups for no reason.
|
||||
func TestDifferentWorkspacesDoNotExcludeEachOther(t *testing.T) {
|
||||
withShortLockTimeout(t, 5*time.Second)
|
||||
withOpenableLock(t)
|
||||
base := lockTestKey(t)
|
||||
|
||||
first, err := acquireWindowsSandboxSetupLock(base + "alpha")
|
||||
if err != nil {
|
||||
t.Fatalf("first workspace: %v", err)
|
||||
}
|
||||
defer first.release()
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
second, err := acquireWindowsSandboxSetupLock(base + "beta")
|
||||
if err == nil {
|
||||
second.release()
|
||||
}
|
||||
result <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-result:
|
||||
if err != nil {
|
||||
t.Fatalf("a second workspace was blocked by an unrelated one: %v", err)
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("a second workspace hung behind an unrelated lock")
|
||||
}
|
||||
}
|
||||
|
||||
// Release must be safe to call twice, since it is both deferred and reachable
|
||||
// explicitly. A double ReleaseMutex would fail and, worse, a double
|
||||
// UnlockOSThread panics the runtime.
|
||||
func TestReleasingTwiceIsSafe(t *testing.T) {
|
||||
withShortLockTimeout(t, 5*time.Second)
|
||||
withOpenableLock(t)
|
||||
lock, err := acquireWindowsSandboxSetupLock(lockTestKey(t))
|
||||
if err != nil {
|
||||
t.Fatalf("acquire: %v", err)
|
||||
}
|
||||
lock.release()
|
||||
lock.release()
|
||||
}
|
||||
|
||||
// The object name has to be per workspace and stable, since that is what makes
|
||||
// the exclusion scoped correctly rather than machine-wide or per-run.
|
||||
func TestLockNameIsPerWorkspaceAndStable(t *testing.T) {
|
||||
first := windowsSandboxSetupLockName("workspace-one")
|
||||
again := windowsSandboxSetupLockName("workspace-one")
|
||||
other := windowsSandboxSetupLockName("workspace-two")
|
||||
|
||||
if first != again {
|
||||
t.Errorf("the same workspace derived two names, %q then %q", first, again)
|
||||
}
|
||||
if first == other {
|
||||
t.Errorf("two workspaces share the lock name %q, so unrelated setups would serialize", first)
|
||||
}
|
||||
// Global rather than Local: the account, its logon rights and the WFP
|
||||
// filters are machine state, so setups in different logon sessions must
|
||||
// still exclude each other.
|
||||
if !strings.HasPrefix(first, `Global\`) {
|
||||
t.Errorf("lock name %q is not in the Global namespace, so it would not span logon sessions", first)
|
||||
}
|
||||
// A backslash past the namespace prefix is rejected by the object manager,
|
||||
// so a key that smuggled one in would fail at CreateMutex.
|
||||
if strings.Contains(strings.TrimPrefix(first, `Global\`), `\`) {
|
||||
t.Errorf("lock name %q contains a separator past its namespace prefix", first)
|
||||
}
|
||||
}
|
||||
|
||||
// The production lock must stay reachable only by accounts that could already
|
||||
// run setup. A sandbox principal able to open it could hold it and stall every
|
||||
// future setup, or squat the name so real setup believes it holds a lock it
|
||||
// does not.
|
||||
func TestSetupLockIsAdministratorsOnly(t *testing.T) {
|
||||
// BA is Administrators, SY is SYSTEM, and the leading P protects the DACL so
|
||||
// no inherited ACE can widen it.
|
||||
for _, want := range []string{"D:P", "(A;;GA;;;BA)", "(A;;GA;;;SY)"} {
|
||||
if !strings.Contains(windowsSetupLockSDDL, want) {
|
||||
t.Errorf("lock SDDL %q is missing %q", windowsSetupLockSDDL, want)
|
||||
}
|
||||
}
|
||||
// WD is Everyone. Granting it here would let any sandboxed process stall
|
||||
// setup for the whole machine.
|
||||
if strings.Contains(windowsSetupLockSDDL, ";WD)") {
|
||||
t.Errorf("lock SDDL %q grants Everyone, so any process could hold the setup lock", windowsSetupLockSDDL)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty or unusable key must still produce a valid name rather than a bare
|
||||
// prefix, which every such workspace would then share.
|
||||
func TestLockNameHandlesAnEmptyKey(t *testing.T) {
|
||||
name := windowsSandboxSetupLockName("")
|
||||
if name == `Global\ZeroSandboxSetup-` {
|
||||
t.Fatal("an empty key produced a bare prefix, so every such workspace would share one lock")
|
||||
}
|
||||
if !strings.HasPrefix(name, `Global\ZeroSandboxSetup-`) {
|
||||
t.Errorf("unexpected name %q", name)
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,9 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer)
|
||||
fmt.Fprintln(stderr, WindowsSandboxSetupName+": Windows sandbox setup is only available on Windows")
|
||||
return 1
|
||||
}
|
||||
|
||||
// windowsCurrentUserSID has no meaning off Windows. Returning empty keeps
|
||||
// BuildWindowsSandboxSetupArgs from emitting a --caller-sid nobody could
|
||||
// interpret, which is also what a Windows caller does when its own token cannot
|
||||
// be read.
|
||||
func windowsCurrentUserSID() string { return "" }
|
||||
@@ -0,0 +1,112 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func principalFingerprintConfig(sandboxHome string, readRoots []string) WindowsSandboxSetupConfig {
|
||||
workspace := filepath.FromSlash("/ws/project")
|
||||
return WindowsSandboxSetupConfig{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PrincipalOptIn: true,
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: workspace}},
|
||||
ReadRoots: readRoots,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Narrowing a principal's read roots MUST invalidate setup.
|
||||
//
|
||||
// ACLPlanHash covers BuildWindowsACLPlan, the capability-SID plan. Principal
|
||||
// grants are built separately by buildWindowsPrincipalACLPlan from the same
|
||||
// profile, so before this the marker was blind to them: remove a read root and
|
||||
// setup still looked current while the AllowRead ACE stayed on disk. Narrowing
|
||||
// a policy has to be able to take access away.
|
||||
func TestSetupMarkerInvalidatesWhenPrincipalReadRootsShrink(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
wide, err := BuildWindowsSandboxSetupMarker(principalFingerprintConfig(home, []string{
|
||||
filepath.FromSlash("/ws/project"),
|
||||
filepath.FromSlash("/ws/extra-read"),
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("build wide marker: %v", err)
|
||||
}
|
||||
narrow, err := BuildWindowsSandboxSetupMarker(principalFingerprintConfig(home, []string{
|
||||
filepath.FromSlash("/ws/project"),
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("build narrow marker: %v", err)
|
||||
}
|
||||
|
||||
if wide.PrincipalPlanHash == "" {
|
||||
t.Fatal("no principal fingerprint recorded while opted in, so principal grants are unfingerprinted")
|
||||
}
|
||||
if wide.PrincipalPlanHash == narrow.PrincipalPlanHash {
|
||||
t.Error("dropping a principal read root did not change the fingerprint, so stale AllowRead ACEs survive a narrowed policy")
|
||||
}
|
||||
}
|
||||
|
||||
// A stale principal fingerprint must be refused through the real validator,
|
||||
// which reads the marker off disk, with a message that says what to do.
|
||||
func TestValidateRefusesAChangedPrincipalFingerprint(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
config := principalFingerprintConfig(home, []string{filepath.FromSlash("/ws/project")})
|
||||
if _, err := WriteWindowsSandboxSetupMarker(config); err != nil {
|
||||
t.Fatalf("write marker: %v", err)
|
||||
}
|
||||
if err := ValidateWindowsSandboxSetupMarker(config); err != nil {
|
||||
t.Fatalf("a freshly written marker did not validate, so this test cannot isolate the fingerprint: %v", err)
|
||||
}
|
||||
|
||||
// Rewrite only the principal fingerprint, the way a policy change would.
|
||||
path := WindowsSandboxSetupMarkerPath(home)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read marker: %v", err)
|
||||
}
|
||||
var marker map[string]any
|
||||
if err := json.Unmarshal(raw, &marker); err != nil {
|
||||
t.Fatalf("parse marker: %v", err)
|
||||
}
|
||||
marker["principalPlanHash"] = "stale-hash-from-an-earlier-policy"
|
||||
rewritten, err := json.Marshal(marker)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal marker: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, rewritten, 0o600); err != nil {
|
||||
t.Fatalf("rewrite marker: %v", err)
|
||||
}
|
||||
|
||||
err = ValidateWindowsSandboxSetupMarker(config)
|
||||
if err == nil {
|
||||
t.Fatal("a marker whose principal grants no longer match the policy was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "principal grants changed") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The default install must be untouched: opted out means no fingerprint, so the
|
||||
// marker does not churn for the overwhelming majority of users.
|
||||
func TestSetupMarkerHasNoPrincipalFingerprintWhenOptedOut(t *testing.T) {
|
||||
config := principalFingerprintConfig(t.TempDir(), []string{filepath.FromSlash("/ws/project")})
|
||||
config.PrincipalOptIn = false
|
||||
|
||||
marker, err := BuildWindowsSandboxSetupMarker(config)
|
||||
if err != nil {
|
||||
t.Fatalf("build marker: %v", err)
|
||||
}
|
||||
if marker.PrincipalPlanHash != "" {
|
||||
t.Errorf("principal fingerprint %q recorded while opted out", marker.PrincipalPlanHash)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
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.
|
||||
func runtimeRootTestConfig(t *testing.T) WindowsSandboxCommandConfig {
|
||||
t.Helper()
|
||||
// REDIRECT THE AMBIENT CACHE PRODUCER, not only the obvious inputs.
|
||||
//
|
||||
// The workspace and sandbox home below are test-owned, but
|
||||
// windowsSandboxRuntimeCandidates still derives a candidate from
|
||||
// sandboxUserCacheDir, and the runtime-root test creates every candidate and
|
||||
// registers os.RemoveAll cleanup for them. That reaches into the real user
|
||||
// cache: it fails outright on a read-only home, and on an ordinary developer
|
||||
// or CI account it creates and then deletes a path outside the test's
|
||||
// storage. The workspace hash makes a collision unlikely; it does not make
|
||||
// somebody else's directory test-owned.
|
||||
cache := t.TempDir()
|
||||
previousCache := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return cache, nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = previousCache })
|
||||
|
||||
workspace := t.TempDir()
|
||||
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 := WindowsSandboxSetupConfigFromCommand(config)
|
||||
setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(setup.PermissionProfile, config.WorkspaceRoots)
|
||||
if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil {
|
||||
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
|
||||
}
|
||||
|
||||
candidates := windowsSandboxRuntimeCandidates(config.WorkspaceRoots)
|
||||
if len(candidates) == 0 {
|
||||
t.Fatal("windowsSandboxRuntimeCandidates returned none, so this test proves nothing")
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
augmented := config
|
||||
// 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.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(
|
||||
permissionProfileWithRuntime(config.PermissionProfile, SandboxRuntime{Root: candidate}),
|
||||
config.WorkspaceRoots,
|
||||
)
|
||||
err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(augmented))
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateWindowsSandboxSetupMarker with runtime root %s: %v", candidate, 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: candidates[0]}),
|
||||
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 := windowsSandboxRuntimeCandidates(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\<hash>
|
||||
//
|
||||
// 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 := windowsSandboxRuntimeCandidates(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 := ensureWindowsSandboxRuntimeCandidates(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 := windowsSandboxRuntimeCandidates(config.WorkspaceRoots)
|
||||
if len(first) == 0 {
|
||||
t.Fatal("windowsSandboxRuntimeCandidates returned none, so this test proves nothing")
|
||||
}
|
||||
second := windowsSandboxRuntimeCandidates(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 := windowsSandboxRuntimeCandidates(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -156,6 +157,178 @@ func TestWindowsSandboxSetupMarkerRejectsOldSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The principal opt-in has to travel in the setup args, because the elevated
|
||||
// half runs in its own process: a UAC-elevated helper does not inherit the
|
||||
// environment of the shell that asked for setup. Sampling the ambient
|
||||
// environment there let the two halves disagree, so the serialized value must
|
||||
// win over the environment in BOTH directions.
|
||||
func TestWindowsSandboxSetupPrincipalOptInSurvivesElevatedEnvironment(t *testing.T) {
|
||||
allowPrincipalLaunchForTest(t)
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}},
|
||||
Network: NetworkPolicy{Mode: NetworkAllow},
|
||||
}
|
||||
testCases := []struct {
|
||||
name string
|
||||
optIn bool
|
||||
ambientEnv string
|
||||
}{
|
||||
// The reported case: the caller's shell opted in, the elevated helper's
|
||||
// environment has nothing. Without the serialized value setup provisions no
|
||||
// principal and every later command silently falls back.
|
||||
{name: "opted in, elevated environment empty", optIn: true, ambientEnv: ""},
|
||||
// The mirror: the elevated helper happens to have a machine-wide opt-in the
|
||||
// caller did not ask for. Setup must not create an account on its own say-so.
|
||||
{name: "opted out, elevated environment opted in", optIn: false, ambientEnv: "1"},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Setenv(windowsSandboxIdentityEnv, testCase.ambientEnv)
|
||||
optIn := testCase.optIn
|
||||
args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: `C:\workspace`,
|
||||
WorkspaceRoots: []string{`C:\workspace`},
|
||||
// The parent folds in the runtime roots before the runner sees the profile,
|
||||
// so a command half that skips it no longer matches the setup half.
|
||||
PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:\workspace`}),
|
||||
PrincipalOptIn: &optIn,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err)
|
||||
}
|
||||
config, err := ParseWindowsSandboxSetupArgs(args)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWindowsSandboxSetupArgs: %v", err)
|
||||
}
|
||||
if config.PrincipalOptIn != testCase.optIn {
|
||||
t.Fatalf("parsed PrincipalOptIn = %v, want %v", config.PrincipalOptIn, testCase.optIn)
|
||||
}
|
||||
// This is the value the elevated setup gate actually reads before it
|
||||
// decides to provision an account.
|
||||
if got := windowsSandboxIdentityEnabled(config.commandConfig().Env); got != testCase.optIn {
|
||||
t.Fatalf("elevated setup opt-in = %v, want %v (ambient %s=%q must not decide)",
|
||||
got, testCase.optIn, windowsSandboxIdentityEnv, testCase.ambientEnv)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A setup helper that cannot read the opt-in must refuse rather than default to
|
||||
// "no principal": provisioning less than the caller asked for and reporting
|
||||
// success is the silent downgrade this protocol exists to prevent.
|
||||
func TestParseWindowsSandboxSetupArgsRejectsUnreadablePrincipalOptIn(t *testing.T) {
|
||||
allowPrincipalLaunchForTest(t)
|
||||
optIn := true
|
||||
args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: `C:\workspace`,
|
||||
WorkspaceRoots: []string{`C:\workspace`},
|
||||
PermissionProfile: PermissionProfile{FileSystem: FileSystemPolicy{Kind: FileSystemRestricted}, Network: NetworkPolicy{Mode: NetworkDeny}},
|
||||
PrincipalOptIn: &optIn,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err)
|
||||
}
|
||||
for index, arg := range args {
|
||||
if arg == "--sandbox-principal" {
|
||||
args[index+1] = "yes"
|
||||
}
|
||||
}
|
||||
if _, err := ParseWindowsSandboxSetupArgs(args); err == nil || !strings.Contains(err.Error(), "--sandbox-principal") {
|
||||
t.Fatalf("ParseWindowsSandboxSetupArgs error = %v, want rejection of the unreadable opt-in", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup and the commands that follow it run in separate processes, so they can
|
||||
// disagree about the opt-in. The marker records what setup provisioned and the
|
||||
// command refuses on a mismatch — most of all when the command opted in and
|
||||
// setup did not, because the runtime's principal lookup declines with a nil
|
||||
// error and the command would otherwise run on the read-unconfined
|
||||
// restricted-token backend while the operator believes a principal is isolating
|
||||
// it.
|
||||
func TestWindowsSandboxSetupMarkerRejectsPrincipalOptInMismatch(t *testing.T) {
|
||||
// Neutral ambient environment: the disagreement under test is between the two
|
||||
// recorded intents, not between either of them and this process.
|
||||
t.Setenv(windowsSandboxIdentityEnv, "")
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}},
|
||||
Network: NetworkPolicy{Mode: NetworkAllow},
|
||||
}
|
||||
command := func(home string, env map[string]string) WindowsSandboxCommandConfig {
|
||||
return WindowsSandboxCommandConfig{
|
||||
SandboxHome: home,
|
||||
CommandCWD: `C:\workspace`,
|
||||
WorkspaceRoots: []string{`C:\workspace`},
|
||||
// The parent folds in the runtime roots before the runner sees the profile,
|
||||
// so a command half that skips it no longer matches the setup half.
|
||||
PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:\workspace`}),
|
||||
Env: env,
|
||||
SandboxLevel: WindowsSandboxLevelRestrictedToken,
|
||||
Command: []string{"cmd.exe", "/c", "echo"},
|
||||
}
|
||||
}
|
||||
testCases := []struct {
|
||||
name string
|
||||
setupOptIn bool
|
||||
commandEnv map[string]string
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "command opts in, setup provisioned no principal",
|
||||
setupOptIn: false,
|
||||
commandEnv: map[string]string{windowsSandboxIdentityEnv: "1"},
|
||||
wantError: "asks for a sandbox principal, but setup provisioned none",
|
||||
},
|
||||
{
|
||||
name: "setup provisioned a principal, command opts out",
|
||||
setupOptIn: true,
|
||||
commandEnv: map[string]string{windowsSandboxIdentityEnv: "0"},
|
||||
wantError: "setup provisioned sandbox principals",
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setupConfig := WindowsSandboxSetupConfig{
|
||||
SandboxHome: home,
|
||||
CommandCWD: `C:\workspace`,
|
||||
WorkspaceRoots: []string{`C:\workspace`},
|
||||
// The parent folds in the runtime roots before the runner sees the profile,
|
||||
// so a command half that skips it no longer matches the setup half.
|
||||
PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:\workspace`}),
|
||||
PrincipalOptIn: testCase.setupOptIn,
|
||||
}
|
||||
marker, err := WriteWindowsSandboxSetupMarker(setupConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
|
||||
}
|
||||
// Assert the setup half recorded what it was told before trusting what
|
||||
// the command half makes of it.
|
||||
if marker.PrincipalOptIn != testCase.setupOptIn {
|
||||
t.Fatalf("marker PrincipalOptIn = %v, want %v", marker.PrincipalOptIn, testCase.setupOptIn)
|
||||
}
|
||||
// An agreeing command still validates, so the refusal below is about the
|
||||
// disagreement and not about the marker being unusable.
|
||||
agreeing := command(home, map[string]string{windowsSandboxIdentityEnv: windowsSandboxPrincipalOptInValue(testCase.setupOptIn)})
|
||||
if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(agreeing)); err != nil {
|
||||
t.Fatalf("agreeing command must validate against its own setup: %v", err)
|
||||
}
|
||||
|
||||
err = ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command(home, testCase.commandEnv)))
|
||||
if err == nil {
|
||||
t.Fatalf("disagreeing command validated the marker, want refusal")
|
||||
}
|
||||
if !strings.Contains(err.Error(), testCase.wantError) {
|
||||
t.Fatalf("validate error = %v, want it to contain %q", err, testCase.wantError)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "zero sandbox setup") {
|
||||
t.Fatalf("validate error = %v, want the remedy to name `zero sandbox setup`", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsSandboxSetupConfigFromCommandPreservesProfileInputs(t *testing.T) {
|
||||
command := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
@@ -181,6 +354,111 @@ func TestWindowsSandboxSetupConfigFromCommandPreservesProfileInputs(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
// Serializing the opt-in makes it a field every caller of
|
||||
// BuildWindowsSandboxSetupArgs could get wrong, so the field is a tri-state and
|
||||
// its unset meaning is load-bearing: "consult the environment", never "opted
|
||||
// out". The command half still resolves the opt-in from the process environment
|
||||
// when its own Env carries no explicit entry, so if an unset setup caller
|
||||
// asserted false instead, the two halves would disagree under a machine-wide
|
||||
// opt-in and marker validation would refuse every command — safe, but it bricks
|
||||
// the caller, and it re-creates the very disagreement this flag removes. That is
|
||||
// exactly what the existing smoke callers
|
||||
// (runner_windows_integration_test.go:43 and :52) do: they never set the field.
|
||||
//
|
||||
// This test runs on every GOOS and pins the unset default in both ambient
|
||||
// states, so getting it backwards is a test failure here rather than a surprise
|
||||
// on a real elevated machine.
|
||||
func TestWindowsSandboxSetupArgsUnsetPrincipalOptInConsultsEnvironment(t *testing.T) {
|
||||
allowPrincipalLaunchForTest(t)
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}},
|
||||
Network: NetworkPolicy{Mode: NetworkAllow},
|
||||
}
|
||||
// The command half as an ambient caller declares it: no explicit entry in Env,
|
||||
// so it resolves the opt-in from the environment.
|
||||
command := func(home string) WindowsSandboxCommandConfig {
|
||||
return WindowsSandboxCommandConfig{
|
||||
SandboxHome: home,
|
||||
CommandCWD: `C:\workspace`,
|
||||
WorkspaceRoots: []string{`C:\workspace`},
|
||||
// The parent folds in the runtime roots before the runner sees the profile,
|
||||
// so a command half that skips it no longer matches the setup half.
|
||||
PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:\workspace`}),
|
||||
SandboxLevel: WindowsSandboxLevelRestrictedToken,
|
||||
Command: []string{"cmd.exe", "/c", "echo"},
|
||||
}
|
||||
}
|
||||
// setupMarkerFor runs the full caller path — build args, cross the (simulated)
|
||||
// UAC boundary by re-parsing them, write the marker — so what is asserted is
|
||||
// what an elevated helper would actually have provisioned.
|
||||
setupMarkerFor := func(t *testing.T, home string, optIn *bool) WindowsSandboxSetupMarker {
|
||||
t.Helper()
|
||||
args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{
|
||||
SandboxHome: home,
|
||||
CommandCWD: `C:\workspace`,
|
||||
WorkspaceRoots: []string{`C:\workspace`},
|
||||
// Deliberately NOT pre-augmented. BuildWindowsSandboxSetupArgs folds the
|
||||
// runtime roots in itself, and passing them in here would hide it from
|
||||
// this test if it ever stopped.
|
||||
PermissionProfile: profile,
|
||||
PrincipalOptIn: optIn,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err)
|
||||
}
|
||||
config, err := ParseWindowsSandboxSetupArgs(args)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWindowsSandboxSetupArgs: %v", err)
|
||||
}
|
||||
marker, err := WriteWindowsSandboxSetupMarker(config)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
|
||||
}
|
||||
return marker
|
||||
}
|
||||
|
||||
for _, ambient := range []string{"1", ""} {
|
||||
name := "machine-wide opt-in"
|
||||
if ambient == "" {
|
||||
name = "no opt-in"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Setenv(windowsSandboxIdentityEnv, ambient)
|
||||
want := ambient == "1"
|
||||
|
||||
// An unset caller must provision what the environment says. Assert the
|
||||
// setup half recorded that before trusting the agreement below: a marker
|
||||
// that recorded the wrong thing could still "agree" if the command half
|
||||
// were broken in the same direction.
|
||||
home := t.TempDir()
|
||||
marker := setupMarkerFor(t, home, nil)
|
||||
if marker.PrincipalOptIn != want {
|
||||
t.Fatalf("unset caller recorded PrincipalOptIn = %v, want %v (ambient %s=%q decides)",
|
||||
marker.PrincipalOptIn, want, windowsSandboxIdentityEnv, ambient)
|
||||
}
|
||||
if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command(home))); err != nil {
|
||||
t.Fatalf("an unset caller must agree with the ambient command half: %v", err)
|
||||
}
|
||||
|
||||
// And an explicit value still overrides the environment in both
|
||||
// directions, or the tri-state would have no third state.
|
||||
override := !want
|
||||
overrideHome := t.TempDir()
|
||||
overrideMarker := setupMarkerFor(t, overrideHome, &override)
|
||||
if overrideMarker.PrincipalOptIn != override {
|
||||
t.Fatalf("explicit caller recorded PrincipalOptIn = %v, want %v", overrideMarker.PrincipalOptIn, override)
|
||||
}
|
||||
err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command(overrideHome)))
|
||||
if err == nil {
|
||||
t.Fatalf("an explicit opt-in of %v validated against an ambient command half of %v, want refusal", override, want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), windowsSandboxIdentityEnv) {
|
||||
t.Fatalf("validate error = %v, want it to name %s", err, windowsSandboxIdentityEnv)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsACLPlanHashIsStableAcrossEntryOrder(t *testing.T) {
|
||||
left, err := WindowsACLPlanHash(WindowsACLPlan{Entries: []WindowsACLEntry{
|
||||
{Action: WindowsACLDenyRead, Path: `C:\workspace\secret`, Capability: "S-1-5-21-3", Materialize: true},
|
||||
@@ -202,3 +480,79 @@ func TestWindowsACLPlanHashIsStableAcrossEntryOrder(t *testing.T) {
|
||||
t.Fatalf("ACL plan hashes differ: %q vs %q", left, right)
|
||||
}
|
||||
}
|
||||
|
||||
// The caller's identity has to survive the UAC boundary for the same reason the
|
||||
// opt-in does: elevated setup is a separate process, and under over-the-shoulder
|
||||
// elevation it belongs to a different administrator. Every principal name, its
|
||||
// ACL ledger and its DPAPI secret are scoped to the invoking user, so a helper
|
||||
// left to sample its own token provisions for the wrong one.
|
||||
//
|
||||
// Runs on every GOOS. The identity is resolved before the boundary, so what is
|
||||
// pinned here is the transport, which is where it would silently go missing.
|
||||
func TestWindowsSandboxSetupArgsCarryTheCallerIdentity(t *testing.T) {
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
}
|
||||
const callerSID = "S-1-5-21-1111111111-2222222222-3333333333-1001"
|
||||
args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{
|
||||
SandboxHome: `C:\home`,
|
||||
CommandCWD: `C:\workspace`,
|
||||
WorkspaceRoots: []string{`C:\workspace`},
|
||||
// The parent folds in the runtime roots before the runner sees the profile,
|
||||
// so a command half that skips it no longer matches the setup half.
|
||||
PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:\workspace`}),
|
||||
CallerSID: callerSID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err)
|
||||
}
|
||||
config, err := ParseWindowsSandboxSetupArgs(args)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWindowsSandboxSetupArgs: %v", err)
|
||||
}
|
||||
if config.CallerSID != callerSID {
|
||||
t.Fatalf("the caller identity did not survive the setup args: got %q, want %q", config.CallerSID, callerSID)
|
||||
}
|
||||
// And on to the command-shaped view the setup half actually plans against —
|
||||
// the only place it changes what gets provisioned.
|
||||
if got := config.commandConfig().CallerSID; got != callerSID {
|
||||
t.Errorf("commandConfig dropped the caller identity: got %q, want %q", got, callerSID)
|
||||
}
|
||||
}
|
||||
|
||||
// A caller that cannot state its identity must not send an empty flag. Absence
|
||||
// means "this caller did not say", which the helper answers by sampling its own
|
||||
// token exactly as it did before the flag existed; an empty value would have to
|
||||
// be guessed at instead.
|
||||
func TestWindowsSandboxSetupArgsOmitAnUnknownCallerIdentity(t *testing.T) {
|
||||
args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{
|
||||
SandboxHome: `C:\home`,
|
||||
CommandCWD: `C:\workspace`,
|
||||
WorkspaceRoots: []string{`C:\workspace`},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{Kind: FileSystemRestricted},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
},
|
||||
// Left unset. On non-Windows builds windowsCurrentUserSID also returns
|
||||
// empty, which is the same case a Windows caller hits when its token query
|
||||
// fails.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
for _, arg := range args {
|
||||
if arg == "--caller-sid" {
|
||||
t.Fatalf("an unresolvable caller identity was still serialized: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
config, err := ParseWindowsSandboxSetupArgs(args)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWindowsSandboxSetupArgs: %v", err)
|
||||
}
|
||||
if runtime.GOOS != "windows" && config.CallerSID != "" {
|
||||
t.Errorf("CallerSID = %q, want empty so the helper falls back to its own token", config.CallerSID)
|
||||
}
|
||||
}
|
||||
@@ -3,39 +3,188 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Seams for the setup transaction's external effects.
|
||||
//
|
||||
// Every step below needs something a test machine cannot be asked for: an
|
||||
// elevated token, a machine-global mutex, real DACLs, the WFP engine, a local
|
||||
// account. That left the ORDER of those steps and the handling of each failure
|
||||
// — which is where this function's bugs actually live — with no coverage at all.
|
||||
// Each var defaults to its production function and is only ever reassigned by a
|
||||
// test. removeWindowsSandboxPrincipalForSetupFn and applyWindowsACLPlanFn
|
||||
// already existed in windows_identity_windows.go for the same reason; the rest
|
||||
// follow them, and the ACL call below now goes through that one so a single stub
|
||||
// covers both plans.
|
||||
var (
|
||||
windowsProcessIsElevatedFn = windowsProcessIsElevated
|
||||
acquireWindowsSandboxSetupLockFn = acquireWindowsSandboxSetupLock
|
||||
applyWindowsNetworkPlanFn = applyWindowsNetworkPlan
|
||||
setupWindowsSandboxPrincipalFn = setupWindowsSandboxPrincipal
|
||||
writeWindowsSandboxSetupMarkerFn = WriteWindowsSandboxSetupMarker
|
||||
)
|
||||
|
||||
func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) int {
|
||||
// Applying the WFP network filters and workspace ACLs requires Administrator
|
||||
// rights; without them WFP fails deep inside with a raw ACCESS_DENIED (0x5).
|
||||
// Check up front and return an actionable message instead.
|
||||
if !windowsProcessIsElevated() {
|
||||
if !windowsProcessIsElevatedFn() {
|
||||
fmt.Fprintln(stderr, WindowsSandboxSetupName+": Administrator rights are required. Re-run `zero sandbox setup` from an elevated (Run as administrator) terminal.")
|
||||
return 1
|
||||
}
|
||||
// Serialize the WHOLE transaction, not the individual writes.
|
||||
//
|
||||
// Setup rotates the principal's password, stores the secret,
|
||||
// read-modify-writes the ACL ledger, installs WFP filters and writes the
|
||||
// marker. Two setups for this workspace at once interleave those steps: the
|
||||
// account ends up with one process's password while the stored secret is the
|
||||
// other's, so every later command fails to log on, and the ledger's
|
||||
// read-modify-write silently loses a path set. Each write being individually
|
||||
// atomic does nothing about interleaving BETWEEN writes, which is the actual
|
||||
// failure, so the lock is taken here and held to the end.
|
||||
//
|
||||
// Immediately after the elevation check, because acquiring a Global object
|
||||
// needs the rights that check just confirmed.
|
||||
lock, err := acquireWindowsSandboxSetupLockFn(windowsSandboxWorkspaceKey(config.WorkspaceRoots))
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
defer lock.release()
|
||||
if lock.Abandoned() {
|
||||
// The previous holder died somewhere inside the transaction, so this
|
||||
// machine may be half set up. Setup is idempotent and re-running it is
|
||||
// the repair, but say so: a silent recovery hides that something crashed
|
||||
// while holding the machine's sandbox state open.
|
||||
fmt.Fprintf(stderr, "%s: a previous setup for this workspace exited without finishing; re-running to repair it.\n",
|
||||
WindowsSandboxSetupName)
|
||||
}
|
||||
|
||||
// Before anything is provisioned: a principal this helper could not hand back
|
||||
// to the caller must not be created at all.
|
||||
if config.PrincipalOptIn {
|
||||
if err := assertWindowsSetupRunsAsCaller(config.CallerSID); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// Create the runtime roots BEFORE building the plan that grants them.
|
||||
//
|
||||
// The capability plan deliberately does not materialize a write root: an
|
||||
// entry whose path is absent is a typo or a stale config, and inventing the
|
||||
// tree would silently grant write on a directory nobody asked for. So every
|
||||
// write root in the plan has to exist by the time it is applied, and the
|
||||
// runtime roots are the only ones setup owns rather than receives.
|
||||
//
|
||||
// Both of them, not the one this process would select. Setup grants the whole
|
||||
// candidate set so a later command that falls back still lands on a
|
||||
// provisioned tree, and granting a path setup never created fails the entire
|
||||
// run with "windows ACL target does not exist".
|
||||
if err := ensureWindowsSandboxRuntimeCandidates(config.WorkspaceRoots); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
|
||||
plan, err := BuildWindowsACLPlan(config.commandConfig())
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
// 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.
|
||||
rollback, err := applyWindowsACLPlanFn(plan)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
// Provision this workspace's sandbox principal, when opted in. A principal is
|
||||
// a separate local account, so it is created only on an explicit opt-in: it
|
||||
// is visible in `net user`, and account creation is exactly the kind of thing
|
||||
// endpoint protection and enterprise policy object to. Without the opt-in the
|
||||
// capability-SID backend above is the whole of setup, unchanged.
|
||||
if windowsSandboxIdentityEnabled(config.commandConfig().Env) {
|
||||
principalRollback, err := setupWindowsSandboxPrincipalFn(config.commandConfig())
|
||||
if 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())
|
||||
return 1
|
||||
}
|
||||
// Fold the principal into the existing rollback so every later failure
|
||||
// path undoes it too, rather than each one having to remember.
|
||||
aclRollback := rollback
|
||||
rollback = func() error {
|
||||
principalErr := principalRollback()
|
||||
aclErr := aclRollback()
|
||||
if principalErr != nil {
|
||||
return principalErr
|
||||
}
|
||||
return aclErr
|
||||
}
|
||||
} else if err := removeWindowsSandboxPrincipalsForSetupFn(config.commandConfig()); err != nil {
|
||||
// Opting out has to actually retire the principal, because that is what
|
||||
// we tell people it does. ValidateWindowsSandboxSetupMarker sends an
|
||||
// operator here in as many words: re-run setup from an elevated terminal
|
||||
// without the opt-in to retire the principal. Until now this branch did
|
||||
// not exist, so the marker flipped to opted-out while the account, its
|
||||
// secret, its logon rights, its ACEs and its ledger all stayed exactly
|
||||
// where they were. The instruction was a lie, and the leftovers were
|
||||
// invisible, because nothing afterwards looks for a principal it believes
|
||||
// was never provisioned.
|
||||
//
|
||||
// Fatal only when the ACCOUNT survived, which is a narrower rule than
|
||||
// either of the two this branch has had.
|
||||
//
|
||||
// It first printed and carried on, which was wrong: a marker that says "no
|
||||
// principal" while the account is still installed is a lie nothing
|
||||
// afterwards can detect, because nothing looks for a principal it believes
|
||||
// was never provisioned.
|
||||
//
|
||||
// Then it failed on any error at all, which was wrong in the other
|
||||
// direction. removeWindowsSandboxPrincipalForSetup deliberately continues
|
||||
// past a secret file owned by ANOTHER administrator's setup and reports it
|
||||
// at the end, so that one unlinkable file does not strand the account, its
|
||||
// logon rights and its ACEs. Treating that report as fatal made the whole
|
||||
// default sandbox unsetupable on such a machine, permanently, over inert
|
||||
// residue: the account is already gone and the leftover is a file with a
|
||||
// DACL we do not own.
|
||||
//
|
||||
// So the question is not whether teardown reported a problem, it is
|
||||
// whether the thing the marker is about to deny is still there. Asked of
|
||||
// the account rather than inferred from the error, because the error is
|
||||
// a join and its parts are not separable at this distance.
|
||||
if windowsSandboxPrincipalIsInstalled(config.commandConfig()) {
|
||||
if rollbackErr := rollback(); rollbackErr != nil {
|
||||
fmt.Fprintf(stderr, "%s: opted out, but the existing sandbox principal is still installed: %v; rollback failed: %v\n",
|
||||
WindowsSandboxSetupName, err, rollbackErr)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stderr, "%s: opted out, but the existing sandbox principal is still installed: %v\n",
|
||||
WindowsSandboxSetupName, err)
|
||||
return 1
|
||||
}
|
||||
// Retired, with something inert left over. Named rather than swallowed:
|
||||
// whoever has to clean it up needs to know it is there.
|
||||
fmt.Fprintf(stderr, "%s: opted out; the sandbox principal was retired, but some of its residue could not be removed: %v\n",
|
||||
WindowsSandboxSetupName, err)
|
||||
}
|
||||
// Built AFTER any principal provisioning, not before. The block filters are
|
||||
// keyed to the offline group as well as the offline-marker SID, and that group
|
||||
// is created as part of provisioning: planning first would install filters
|
||||
// that name only the marker, leaving every offline principal with an open
|
||||
// network while looking correctly set up.
|
||||
//
|
||||
// Mode-INDEPENDENT by design. Which identity a command runs under is what
|
||||
// selects allow or deny, so one setup serves both modes.
|
||||
networkPlan, err := BuildWindowsNetworkInfraPlan(config.commandConfig())
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
rollback, err := applyWindowsACLPlan(plan)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
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
|
||||
@@ -43,7 +192,47 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer)
|
||||
fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
if _, err := WriteWindowsSandboxSetupMarker(config); err != nil {
|
||||
// Refuse to install filters that would not cover the principals just
|
||||
// provisioned. The ordering above is what makes them cover it, and nothing at
|
||||
// this call site shows that, so assert it rather than trusting it to survive
|
||||
// a later refactor. Installing anyway would leave a machine reporting a
|
||||
// successful setup while every offline principal had an open network.
|
||||
// Gated on the SAME opt-in that decides whether principals were provisioned
|
||||
// at all (line 36). The offline group is created inside provisioning, so on
|
||||
// an opt-out machine it legitimately does not exist, and asserting there
|
||||
// would refuse a setup that has nothing to get wrong.
|
||||
if err := assertWindowsNetworkPlanCoversOfflineGroup(networkPlan, resolveWindowsSandboxOfflineGroupSIDHook,
|
||||
windowsSandboxIdentityEnabled(config.commandConfig().Env)); 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())
|
||||
return 1
|
||||
}
|
||||
// Install the machine's plan, not this home's. The filters are global and are
|
||||
// replaced wholesale on every setup, so an opted-out run here would otherwise
|
||||
// strip the offline group SID that another workspace's principals depend on
|
||||
// and hand them egress under a NetworkDeny profile. networkPlan itself is left
|
||||
// alone because it is what this home's marker fingerprints.
|
||||
planToInstall, planErr := WindowsNetworkPlanForApply(networkPlan, resolveWindowsSandboxOfflineGroupSIDHook)
|
||||
if planErr != nil {
|
||||
if rollbackErr := rollback(); rollbackErr != nil {
|
||||
fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, planErr, rollbackErr)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+planErr.Error())
|
||||
return 1
|
||||
}
|
||||
if err := applyWindowsNetworkPlanFn(planToInstall); 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())
|
||||
return 1
|
||||
}
|
||||
if _, err := writeWindowsSandboxSetupMarkerFn(config); err != nil {
|
||||
if rollbackErr := rollback(); rollbackErr != nil {
|
||||
fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, err, rollbackErr)
|
||||
return 1
|
||||
@@ -54,6 +243,94 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer)
|
||||
return 0
|
||||
}
|
||||
|
||||
// windowsSandboxPrincipalIsInstalled reports whether this workspace's principal
|
||||
// account still resolves.
|
||||
//
|
||||
// Fails CLOSED. Only an outright "not provisioned" counts as gone; a name that
|
||||
// resolves to something unexpected, or a lookup that fails for its own reasons,
|
||||
// is treated as still installed. The consumer uses this to decide whether an
|
||||
// opted-out marker would be a lie, and the expensive mistake there is claiming a
|
||||
// principal is gone when nobody actually checked.
|
||||
// A workspace now has TWO principals, and either one still installed makes the
|
||||
// answer yes. Checking a single role would let an opted-out marker claim the
|
||||
// workspace is clean while the other account is still on the machine, which is
|
||||
// the exact lie this guard exists to prevent.
|
||||
func windowsSandboxPrincipalIsInstalled(config WindowsSandboxCommandConfig) bool {
|
||||
// EVERY RETIRABLE ROLE, not just the live pair. Teardown retires legacy too,
|
||||
// so an upgraded machine still holding the untagged pre-split account would
|
||||
// otherwise be reported clean here while that account was still installed,
|
||||
// and the opted-out marker would be exactly the lie this guard prevents.
|
||||
key := windowsSandboxPrincipalKey(config)
|
||||
for _, role := range windowsSandboxRetirableRoles {
|
||||
if _, err := lookupWindowsSandboxIdentityFn(key, role); !errors.Is(err, errWindowsSandboxIdentityUnavailable) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// assertWindowsSetupRunsAsCaller refuses to provision a sandbox principal when
|
||||
// this elevated helper belongs to a different Windows user than the caller that
|
||||
// launched it.
|
||||
//
|
||||
// A principal is unusable across that boundary, and not for a reason more
|
||||
// plumbing can fix. Its password is sealed with CryptProtectData, which derives
|
||||
// its key from the CALLING user, so a blob written here is decryptable only by
|
||||
// this administrator; the caller's next command would find the account, find the
|
||||
// secret file, and fail to unseal it. Threading the caller's SID through the
|
||||
// setup args fixes the account NAME, the ledger and the ACEs — everything that
|
||||
// is merely named after the invoking user — but it cannot re-key DPAPI for a
|
||||
// user whose token this process does not hold.
|
||||
//
|
||||
// So the answer is to say so before anything exists, rather than provision an
|
||||
// account, a secret and a grant that resolve to a sandbox nobody can log into.
|
||||
//
|
||||
// Same-account elevation is unaffected: a UAC consent prompt splits the caller's
|
||||
// token but leaves the user SID identical, which is the ordinary path. An
|
||||
// unknown caller SID (an older caller, or a token query that failed) is treated
|
||||
// as a match: that is the pre-existing behaviour, and refusing on absence would
|
||||
// break setup on machines where nothing is wrong.
|
||||
//
|
||||
// What this does NOT do, stated plainly because the name suggests otherwise:
|
||||
// it cannot fire on any path Zero itself takes. Zero never elevates the helper.
|
||||
// runSandboxSetupHelper (internal/cli/app.go) is a plain exec.Command with no
|
||||
// token work, and runWindowsSandboxSetup refuses unless the process is ALREADY
|
||||
// elevated, so the operator supplies elevation by opening an elevated terminal.
|
||||
// Both halves then run in that terminal: BuildWindowsSandboxSetupArgs resolves
|
||||
// the caller SID from the very process that later becomes the helper, so the two
|
||||
// are equal by construction and this returns nil every time.
|
||||
//
|
||||
// It is kept rather than deleted because it is a correctness assertion, not a
|
||||
// no-op. It becomes load-bearing the moment anyone adds a ShellExecute "runas"
|
||||
// path, which is the natural way this command grows, and it already covers a
|
||||
// helper .exe invoked directly with hand-written args. What it must not do is be
|
||||
// mistaken for a live defence against over-the-shoulder elevation today.
|
||||
//
|
||||
// The residual hazard that boundary was supposed to describe is real but has a
|
||||
// different shape: an operator who elevates a terminal as a DIFFERENT
|
||||
// administrator runs both halves as that administrator, so setup provisions into
|
||||
// THAT account's sandbox home. Their own unelevated session resolves its own
|
||||
// home, finds no marker, and is told to run setup. Loud, not silent, and not a
|
||||
// weakened sandbox.
|
||||
//
|
||||
// Scoped to the opt-in by its caller, deliberately. The default restricted-token
|
||||
// sandbox stores no secret and names no account after the user, so it works
|
||||
// perfectly well across this boundary and must keep doing so.
|
||||
func assertWindowsSetupRunsAsCaller(callerSID string) error {
|
||||
caller := strings.TrimSpace(callerSID)
|
||||
if caller == "" {
|
||||
return nil
|
||||
}
|
||||
current := windowsCurrentUserSID()
|
||||
if current == "" || strings.EqualFold(current, caller) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("this elevated setup is running as a different Windows user (%s) than the one that started it (%s), "+
|
||||
"and a sandbox principal's password is sealed to the user that stores it, so the account provisioned here could never be used. "+
|
||||
"Re-run `zero sandbox setup` from a terminal elevated as your own account, or unset %s to use the restricted-token sandbox",
|
||||
current, caller, windowsSandboxIdentityEnv)
|
||||
}
|
||||
|
||||
// windowsProcessIsElevated reports whether the current process runs with an
|
||||
// elevated (Administrator) token. On any error obtaining the token it returns
|
||||
// true so the setup proceeds and surfaces the real WFP/ACL error rather than a
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// applyWindowsACLPlan merges into the existing DACL, so narrowing a policy and
|
||||
// re-running setup used to leave the wider ACEs in place next to the new ones.
|
||||
// The principal kept access the current policy no longer grants — a silent
|
||||
// widening of the sandbox produced by tightening it.
|
||||
func TestRevokeDropsStalePrincipalACEsBeforeReapply(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
kept := filepath.Join(root, "kept")
|
||||
dropped := filepath.Join(root, "dropped")
|
||||
for _, dir := range []string{kept, dropped} {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
// Guests: a real, resolvable trustee that the test process is not a member
|
||||
// of, so the ACEs below are observable without affecting this process.
|
||||
principal := "S-1-5-32-546"
|
||||
|
||||
// First setup: both roots writable.
|
||||
wide, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: principal,
|
||||
WriteRoots: []WritableRoot{{Root: kept}, {Root: dropped}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("wide plan: %v", err)
|
||||
}
|
||||
if _, err := applyWindowsACLPlan(wide); err != nil {
|
||||
t.Fatalf("apply wide plan: %v", err)
|
||||
}
|
||||
if !hasACEForTrustee(t, dropped, principal) {
|
||||
t.Fatal("precondition: the wide plan should have granted the root it is about to lose")
|
||||
}
|
||||
|
||||
// Policy narrows: "dropped" is no longer a write root.
|
||||
narrow, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{
|
||||
PrincipalSID: principal,
|
||||
WriteRoots: []WritableRoot{{Root: kept}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("narrow plan: %v", err)
|
||||
}
|
||||
// Revocation has to cover the paths the OLD plan touched, not just the new
|
||||
// one — the whole point is the path that left the policy.
|
||||
if _, err := revokeWindowsPrincipalACEs(principal, windowsACLPlanPaths(wide)); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
if _, err := applyWindowsACLPlan(narrow); err != nil {
|
||||
t.Fatalf("apply narrow plan: %v", err)
|
||||
}
|
||||
|
||||
if hasACEForTrustee(t, dropped, principal) {
|
||||
t.Error("principal kept its grant on a root the narrowed policy removed")
|
||||
}
|
||||
if !hasACEForTrustee(t, kept, principal) {
|
||||
t.Error("revocation also dropped the grant the narrowed policy still wants")
|
||||
}
|
||||
}
|
||||
|
||||
// Revoking a path that was never created is cleanup with nothing to clean, not
|
||||
// an error — setup would otherwise fail on any carveout git has not made yet.
|
||||
func TestRevokeIgnoresPathsThatDoNotExist(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "never-created")
|
||||
if _, err := revokeWindowsPrincipalACEs("S-1-5-32-546", []string{missing}); err != nil {
|
||||
t.Fatalf("revoke over a missing path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func hasACEForTrustee(t *testing.T, path string, trustee string) bool {
|
||||
t.Helper()
|
||||
descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Fatalf("GetNamedSecurityInfo(%s): %v", path, err)
|
||||
}
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
t.Fatalf("DACL(%s): %v", path, err)
|
||||
}
|
||||
if dacl == nil {
|
||||
return false
|
||||
}
|
||||
want, err := windows.StringToSid(trustee)
|
||||
if err != nil {
|
||||
t.Fatalf("StringToSid(%s): %v", trustee, err)
|
||||
}
|
||||
// Deny ACEs count here as much as allow ACEs: revocation is by trustee and
|
||||
// drops both, so an assertion that only saw allows would call a leftover
|
||||
// deny "revoked".
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
ace := (*windows.ACCESS_ALLOWED_ACE)(unsafe.Pointer(header))
|
||||
switch ace.Header.AceType {
|
||||
case windows.ACCESS_ALLOWED_ACE_TYPE, windows.ACCESS_DENIED_ACE_TYPE:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if (*windows.SID)(unsafe.Pointer(&ace.SidStart)).Equals(want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The mechanism working is not the same as the production path using it. This
|
||||
// pins the call site and its ORDER: revocation is only worth anything if it
|
||||
// runs before the plan that re-adds the current grants.
|
||||
func TestApplyPrincipalACLsRevokesBeforeApplying(t *testing.T) {
|
||||
prevApply := applyWindowsACLPlanFn
|
||||
t.Cleanup(func() { applyWindowsACLPlanFn = prevApply })
|
||||
|
||||
var actions []WindowsACLAction
|
||||
applyWindowsACLPlanFn = func(plan WindowsACLPlan) (func() error, error) {
|
||||
if len(plan.Entries) > 0 {
|
||||
actions = append(actions, plan.Entries[0].Action)
|
||||
}
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
|
||||
workspace := t.TempDir()
|
||||
filesystem := FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: workspace}},
|
||||
}
|
||||
if _, err := applyWindowsPrincipalACLs(t.TempDir(), "zero-sbx-test", "S-1-5-32-546", filesystem, filesystem.WriteRoots); err != nil {
|
||||
t.Fatalf("applyWindowsPrincipalACLs: %v", err)
|
||||
}
|
||||
|
||||
if len(actions) != 2 {
|
||||
t.Fatalf("saw %d ACL plans (%v), want a revocation then the grants", len(actions), actions)
|
||||
}
|
||||
if actions[0] != windowsACLRevoke {
|
||||
t.Errorf("first plan was %q, want the trustee revocation to go first", actions[0])
|
||||
}
|
||||
if actions[1] == windowsACLRevoke {
|
||||
t.Error("second plan was another revocation; the current grants were never applied")
|
||||
}
|
||||
}
|
||||
|
||||
// The revocation's rollback has to be returned, not discarded.
|
||||
//
|
||||
// Discarding it was justified on the grounds that the only failure path from
|
||||
// applyWindowsPrincipalACLs removes the principal outright, so restoring ACEs
|
||||
// for a doomed account would be pointless. That holds only for a principal the
|
||||
// run CREATED. #812 keeps an ADOPTED principal alive on failure rather than
|
||||
// destroying a working account someone else provisioned — and then the discarded
|
||||
// snapshot left it logged-on and unable to reach its own workspace, with its
|
||||
// previous ACEs revoked and the new ones rolled back.
|
||||
func TestApplyPrincipalACLsRollbackRestoresTheRevokedACEs(t *testing.T) {
|
||||
prevApply := applyWindowsACLPlanFn
|
||||
t.Cleanup(func() { applyWindowsACLPlanFn = prevApply })
|
||||
|
||||
var applied []WindowsACLAction
|
||||
var reverted []WindowsACLAction
|
||||
applyWindowsACLPlanFn = func(plan WindowsACLPlan) (func() error, error) {
|
||||
action := WindowsACLAction("")
|
||||
if len(plan.Entries) > 0 {
|
||||
action = plan.Entries[0].Action
|
||||
}
|
||||
applied = append(applied, action)
|
||||
return func() error {
|
||||
reverted = append(reverted, action)
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
workspace := t.TempDir()
|
||||
filesystem := FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: workspace}},
|
||||
}
|
||||
rollback, err := applyWindowsPrincipalACLs(t.TempDir(), "zero-sbx-test", "S-1-5-32-546", filesystem, filesystem.WriteRoots)
|
||||
if err != nil {
|
||||
t.Fatalf("applyWindowsPrincipalACLs: %v", err)
|
||||
}
|
||||
if len(applied) != 2 || applied[0] != windowsACLRevoke {
|
||||
t.Fatalf("applied %v, want a revocation then the grants", applied)
|
||||
}
|
||||
if err := rollback(); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
|
||||
// Both halves must unwind, and in reverse order: the grant comes off first,
|
||||
// then the ACEs the revocation removed go back.
|
||||
if len(reverted) != 2 {
|
||||
t.Fatalf("rollback reverted %v, want both the grant and the revocation", reverted)
|
||||
}
|
||||
if reverted[0] == windowsACLRevoke {
|
||||
t.Error("rollback undid the revocation before the grant; the grant would survive")
|
||||
}
|
||||
if reverted[1] != windowsACLRevoke {
|
||||
t.Errorf("rollback never restored the revoked ACEs, got %v", reverted)
|
||||
}
|
||||
}
|
||||
|
||||
// THE LEDGER IS PART OF THE ROLLBACK, and nothing pinned it.
|
||||
//
|
||||
// applyWindowsPrincipalACLs narrows the ledger before the whole setup
|
||||
// transaction has succeeded. If network setup or the marker write then fails,
|
||||
// rollback restores the DACLs, and the ledger has to come back with them: a
|
||||
// ledger that still records only the narrowed path set leaves the restored
|
||||
// grants unnamed, so later cleanup cannot find them.
|
||||
//
|
||||
// The neighbouring test asserts only the ORDER of the two ACL reverts and never
|
||||
// reads the ledger afterwards, so the restore could be deleted and the suite
|
||||
// would stay green.
|
||||
func TestApplyPrincipalACLsRollbackRestoresTheLedger(t *testing.T) {
|
||||
prevApply := applyWindowsACLPlanFn
|
||||
t.Cleanup(func() { applyWindowsACLPlanFn = prevApply })
|
||||
applyWindowsACLPlanFn = func(WindowsACLPlan) (func() error, error) {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
|
||||
sandboxHome := t.TempDir()
|
||||
const username = "zero-sbx-test"
|
||||
|
||||
// A wider path set than the new plan will record, standing in for a previous
|
||||
// policy's grants.
|
||||
stale := []string{filepath.Join(t.TempDir(), "previously-granted")}
|
||||
if err := writeWindowsPrincipalACLLedger(sandboxHome, username, stale); err != nil {
|
||||
t.Fatalf("seed the ledger: %v", err)
|
||||
}
|
||||
|
||||
workspace := t.TempDir()
|
||||
filesystem := FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: workspace}},
|
||||
}
|
||||
rollback, err := applyWindowsPrincipalACLs(sandboxHome, username, "S-1-5-32-546", filesystem, filesystem.WriteRoots)
|
||||
if err != nil {
|
||||
t.Fatalf("applyWindowsPrincipalACLs: %v", err)
|
||||
}
|
||||
|
||||
narrowed, ok := readWindowsPrincipalACLLedger(sandboxHome, username)
|
||||
if !ok {
|
||||
t.Fatal("no ledger after apply")
|
||||
}
|
||||
if slicesContainsString(narrowed, stale[0]) {
|
||||
t.Fatalf("the ledger still names the old path after apply, so this test is not exercising the restore: %v", narrowed)
|
||||
}
|
||||
|
||||
if err := rollback(); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
restored, ok := readWindowsPrincipalACLLedger(sandboxHome, username)
|
||||
if !ok {
|
||||
t.Fatal("no ledger after rollback")
|
||||
}
|
||||
if !slicesContainsString(restored, stale[0]) {
|
||||
t.Errorf("rollback restored the DACLs but left the narrowed ledger %v; the path %q it put back is now unnamed, so cleanup cannot find it", restored, stale[0])
|
||||
}
|
||||
}
|
||||
|
||||
func slicesContainsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// "Absent beats stale" is the invariant the rollback's secret removal exists to
|
||||
// keep: the command path treats a missing secret as not-provisioned and falls
|
||||
// back, while a stale one fails the logon and reports a broken sandbox.
|
||||
//
|
||||
// So when the removal itself fails after a password rotation, the invariant was
|
||||
// NOT restored — a credential for a password that no longer works is still on
|
||||
// disk. Swallowing that error claims otherwise, and the operator finds out on
|
||||
// the next command instead of from the setup that broke it.
|
||||
func TestProvisionSurfacesFailedStaleSecretCleanup(t *testing.T) {
|
||||
for name, testCase := range map[string]struct {
|
||||
rotate bool
|
||||
removeErr error
|
||||
wantInErr string
|
||||
wantRemove bool
|
||||
}{
|
||||
"rotation happened and the stale secret cannot be removed": {
|
||||
rotate: true, removeErr: errors.New("access is denied"),
|
||||
wantRemove: true, wantInErr: "stale and could not be removed",
|
||||
},
|
||||
"rotation happened and cleanup succeeds": {
|
||||
rotate: true, wantRemove: true,
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
prevProvision := provisionWindowsSandboxIdentityFn
|
||||
prevRemove := removeWindowsSandboxSecretFn
|
||||
prevReset := resetWindowsSandboxUserPasswordFn
|
||||
prevGrant := grantWindowsSandboxLogonRightsFn
|
||||
prevWrite := writeWindowsSandboxSecretFn
|
||||
t.Cleanup(func() {
|
||||
provisionWindowsSandboxIdentityFn = prevProvision
|
||||
removeWindowsSandboxSecretFn = prevRemove
|
||||
resetWindowsSandboxUserPasswordFn = prevReset
|
||||
grantWindowsSandboxLogonRightsFn = prevGrant
|
||||
writeWindowsSandboxSecretFn = prevWrite
|
||||
})
|
||||
|
||||
sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// created=false so the run ADOPTS an account and rotation applies.
|
||||
provisionWindowsSandboxIdentityFn = func(string, windowsSandboxRole) (windowsSandboxIdentity, string, bool, error) {
|
||||
return windowsSandboxIdentity{Username: "zero-sbx-test", SID: sid}, "pw", false, nil
|
||||
}
|
||||
grantWindowsSandboxLogonRightsFn = func(*windows.SID) error { return nil }
|
||||
removed := false
|
||||
removeWindowsSandboxSecretFn = func(string) error {
|
||||
removed = true
|
||||
return testCase.removeErr
|
||||
}
|
||||
// Rotate, then fail immediately after so undo runs with rotated=true.
|
||||
resetWindowsSandboxUserPasswordFn = func(string, string) error {
|
||||
if testCase.rotate {
|
||||
return nil
|
||||
}
|
||||
return errors.New("no rotation")
|
||||
}
|
||||
|
||||
// The only step after rotation; failing it is what drives undo with
|
||||
// rotated=true, which is the state the invariant is about.
|
||||
writeWindowsSandboxSecretFn = func(string, string) error {
|
||||
return errors.New("secret store refused")
|
||||
}
|
||||
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: `C:\ws`,
|
||||
WorkspaceRoots: []string{`C:\ws`},
|
||||
}
|
||||
_, _, err = provisionWindowsSandboxPrincipalForSetup(config, windowsSandboxRoleOffline)
|
||||
|
||||
if removed != testCase.wantRemove {
|
||||
t.Fatalf("stale secret removal attempted = %v, want %v", removed, testCase.wantRemove)
|
||||
}
|
||||
if testCase.wantInErr == "" {
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("a failed stale-secret cleanup was swallowed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), testCase.wantInErr) {
|
||||
t.Fatalf("error = %q, want it to mention %q", err, testCase.wantInErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user