-
Notifications
You must be signed in to change notification settings - Fork 176
fix(sandbox): keep Windows restricted-token SIDs narrow and fail closed on DenyRead #1006
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
92ca672
fe07a52
5465b0f
d9d72e8
90d056a
a84626f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -73,6 +73,24 @@ func TestWindowsRestrictedTokenRealSandboxSmoke(t *testing.T) { | |
| t.Fatalf("sandboxed write marker = %q, %v; want ok", bytes, err) | ||
| } | ||
|
|
||
| // SID broadening is disabled, so the restricted-SID list never includes | ||
| // Users/Authenticated Users. The write grant those groups hold on | ||
| // C:\Users\Public must not be reachable through the restricted-SID check. | ||
| // Pin that a write there fails: an independent shared-writable directory | ||
| // outside every workspace write root. | ||
| publicDir := os.Getenv("PUBLIC") | ||
| if publicDir == "" { | ||
| t.Log("PUBLIC is not set; skipping C:\\Users\\Public write-jail probe") | ||
| } else { | ||
| publicProbe := allocateSharedDirectoryProbe(t, publicDir, "elevated-public") | ||
| runWindowsRealSmokeCommand(t, runnerExe, config, deniedWriteCommand(publicProbe.Path()), deniedWriteExitCode) | ||
| if _, err := os.Stat(publicProbe.Path()); err == nil { | ||
| t.Fatalf("Windows sandbox allowed a write to the shared C:\\Users\\Public directory") | ||
| } else if !os.IsNotExist(err) { | ||
| t.Fatalf("stat public marker: %v", err) | ||
| } | ||
| } | ||
|
|
||
| listener, err := net.Listen("tcp4", "127.0.0.1:0") | ||
| if err != nil { | ||
| t.Fatalf("listen loopback for Windows network smoke: %v", err) | ||
|
|
@@ -156,12 +174,14 @@ func TestWindowsUnelevatedRealSandboxSmoke(t *testing.T) { | |
| } | ||
|
|
||
| sandboxHome := filepath.Join(root, ".zero-sandbox") | ||
| // Success path: restricted FS write-jail with no DenyRead. Non-empty DenyRead | ||
| // is unsupported on both restricted-token tiers under the narrow SID set | ||
| // (PR #640); the rejection probe below covers that separately. | ||
| profile := PermissionProfile{ | ||
| FileSystem: FileSystemPolicy{ | ||
| Kind: FileSystemRestricted, | ||
| ReadRoots: []string{root}, | ||
| WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}}, | ||
| DenyRead: []string{privateDir}, | ||
| IncludePlatformRoots: true, | ||
| AllowTemp: true, | ||
| }, | ||
|
|
@@ -190,10 +210,18 @@ func TestWindowsUnelevatedRealSandboxSmoke(t *testing.T) { | |
| t.Fatalf("expected the unelevated setup marker to be recorded: %v", err) | ||
| } | ||
|
|
||
| // DenyRead check: reading from the privateDir must be blocked (exit code 1) | ||
| runWindowsRealSmokeCommand(t, runnerExe, config, []string{ | ||
| // DenyRead is unsupported on both restricted-token tiers under the narrow | ||
| // SID set (PR #640): the runner must reject before launch rather than | ||
| // attempting a fully restricted token that cannot load system tools. | ||
| denyReadConfig := config | ||
| denyReadConfig.PermissionProfile.FileSystem.DenyRead = []string{privateDir} | ||
| runWindowsRealSmokeCommandExpectError(t, runnerExe, denyReadConfig, []string{ | ||
| "cmd.exe", "/d", "/s", "/c", "type " + secretFile, | ||
| }, 1) | ||
| }, "DenyRead", "not supported") | ||
| // The secret must remain readable from the host; the sandbox never ran. | ||
| if data, err := os.ReadFile(secretFile); err != nil || string(data) != "super-secret" { | ||
| t.Fatalf("host secret file after rejected DenyRead launch: %q, %v", data, err) | ||
| } | ||
|
|
||
| outsideMarker := filepath.Join(outside, "unelevated-write-denied.txt") | ||
| runWindowsRealSmokeCommand(t, runnerExe, config, []string{ | ||
|
|
@@ -204,6 +232,18 @@ func TestWindowsUnelevatedRealSandboxSmoke(t *testing.T) { | |
| } else if !os.IsNotExist(err) { | ||
| t.Fatalf("stat outside marker: %v", err) | ||
| } | ||
|
|
||
| // Verify write to C:\ProgramData is blocked | ||
| programData := os.Getenv("ProgramData") | ||
| if programData != "" { | ||
| programDataProbe := allocateSharedDirectoryProbe(t, programData, "unelevated-programdata") | ||
| runWindowsRealSmokeCommand(t, runnerExe, config, deniedWriteCommand(programDataProbe.Path()), deniedWriteExitCode) | ||
| if _, err := os.Stat(programDataProbe.Path()); err == nil { | ||
| t.Fatalf("unelevated sandbox allowed a write to ProgramData shared directory") | ||
| } else if !os.IsNotExist(err) { | ||
| t.Fatalf("stat ProgramData marker: %v", err) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // TestWindowsRestrictedTokenNestedPipeCapture pins the fix in | ||
|
|
@@ -387,6 +427,34 @@ func runWindowsRealSmokeCommand(t *testing.T, runnerExe string, base WindowsSand | |
| } | ||
| } | ||
|
|
||
| // runWindowsRealSmokeCommandExpectError runs the command runner and requires a | ||
| // non-zero exit whose combined output contains each want substring (used for | ||
| // explicit unsupported-mode rejections rather than sandboxed command failures). | ||
| func runWindowsRealSmokeCommandExpectError(t *testing.T, runnerExe string, base WindowsSandboxCommandArgsOptions, command []string, wantSubstr ...string) { | ||
| t.Helper() | ||
| base.Command = command | ||
| args, err := BuildWindowsSandboxCommandArgs(base) | ||
| if err != nil { | ||
| t.Fatalf("BuildWindowsSandboxCommandArgs: %v", err) | ||
| } | ||
| ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) | ||
| defer cancel() | ||
| cmd := exec.CommandContext(ctx, runnerExe, args...) | ||
| output, err := cmd.CombinedOutput() | ||
| if ctx.Err() != nil { | ||
| t.Fatalf("Windows sandbox command timed out: %v\n%s", ctx.Err(), output) | ||
| } | ||
| if err == nil { | ||
| t.Fatalf("Windows sandbox command exit code = 0, want error containing %v\n%s", wantSubstr, output) | ||
| } | ||
| text := string(output) | ||
| for _, want := range wantSubstr { | ||
| if !strings.Contains(text, want) { | ||
| t.Fatalf("Windows sandbox command error missing %q: %v\n%s", want, err, output) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // The write jail must hold on a path whose DACL grants Everyone write access. | ||
| // | ||
| // A WRITE_RESTRICTED token runs TWO checks for a write and needs both to pass: | ||
|
|
@@ -505,7 +573,49 @@ const deniedWriteExitCode = 77 | |
| // deniedWriteCommand attempts a write and reports deniedWriteExitCode when the | ||
| // redirect is refused, so the exit code also proves cmd.exe actually ran. | ||
| func deniedWriteCommand(marker string) []string { | ||
| return []string{"cmd.exe", "/d", "/s", "/c", "echo leaked>" + marker + " || exit " + strconv.Itoa(deniedWriteExitCode)} | ||
| return []string{"cmd.exe", "/d", "/s", "/c", "echo leaked>" + cmdQuote(marker) + " || exit " + strconv.Itoa(deniedWriteExitCode)} | ||
| } | ||
|
|
||
| func cmdQuote(path string) string { | ||
| return `"` + strings.ReplaceAll(path, `"`, `\"`) + `"` | ||
| } | ||
|
|
||
| type sharedDirectoryProbe struct { | ||
| path string | ||
| } | ||
|
|
||
| func allocateSharedDirectoryProbe(t testing.TB, dir, prefix string) *sharedDirectoryProbe { | ||
| t.Helper() | ||
| if dir == "" { | ||
| t.Skip("shared directory path is not set") | ||
| } | ||
| var probePath string | ||
| for attempt := 0; attempt < 50; attempt++ { | ||
| candidate := filepath.Join(dir, fmt.Sprintf("zero-smoke-%s-%d-%d-%d.txt", prefix, os.Getpid(), time.Now().UnixNano(), attempt)) | ||
| if _, err := os.Lstat(candidate); os.IsNotExist(err) { | ||
| probePath = candidate | ||
| break | ||
| } | ||
| } | ||
| if probePath == "" { | ||
| t.Fatalf("allocate shared directory probe in %s: failed to find unused filename", dir) | ||
| } | ||
| p := &sharedDirectoryProbe{path: probePath} | ||
| t.Cleanup(func() { | ||
| p.cleanup(t) | ||
|
Comment on lines
+602
to
+605
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The allocator only observes that a shared-directory path is absent; cleanup later removes anything that occupies that path and ignores removal errors. This can delete another process's file created after allocation or silently leave the test's own probe behind. Context Used: AGENTS.md (source) |
||
| }) | ||
| return p | ||
| } | ||
|
|
||
| func (p *sharedDirectoryProbe) Path() string { | ||
| return p.path | ||
| } | ||
|
|
||
| func (p *sharedDirectoryProbe) cleanup(t testing.TB) { | ||
| t.Helper() | ||
| if _, err := os.Lstat(p.path); err == nil { | ||
| _ = os.Remove(p.path) | ||
| } | ||
| } | ||
|
|
||
| func powershellSingleQuote(value string) string { | ||
|
|
@@ -519,3 +629,39 @@ func powershellSingleQuote(value string) string { | |
| } | ||
| return out + "'" | ||
| } | ||
|
|
||
| func TestSharedDirectoryProbeLifecycle(t *testing.T) { | ||
| dir := t.TempDir() | ||
|
|
||
| // 1. Two simultaneous allocations produce distinct non-colliding paths. | ||
| probe1 := allocateSharedDirectoryProbe(t, dir, "p1") | ||
| probe2 := allocateSharedDirectoryProbe(t, dir, "p2") | ||
| if probe1.Path() == probe2.Path() { | ||
| t.Fatalf("expected distinct probe paths, got %q and %q", probe1.Path(), probe2.Path()) | ||
| } | ||
|
|
||
| // 2. Pre-existing unrelated file is never selected or deleted. | ||
| unrelatedFile := filepath.Join(dir, "unrelated.txt") | ||
| if err := os.WriteFile(unrelatedFile, []byte("preserve me"), 0o600); err != nil { | ||
| t.Fatalf("write unrelated file: %v", err) | ||
| } | ||
| probe3 := allocateSharedDirectoryProbe(t, dir, "p3") | ||
| probe3.cleanup(t) | ||
| if data, err := os.ReadFile(unrelatedFile); err != nil || string(data) != "preserve me" { | ||
| t.Fatalf("unrelated file was modified or deleted: data=%q, err=%v", data, err) | ||
| } | ||
|
|
||
| // 3. Interrupted / no-create path: cleanup on absent file succeeds quietly. | ||
| probe4 := allocateSharedDirectoryProbe(t, dir, "p4") | ||
| probe4.cleanup(t) | ||
|
|
||
| // 4. Unexpected write created during test is cleaned up. | ||
| probe5 := allocateSharedDirectoryProbe(t, dir, "p5") | ||
| if err := os.WriteFile(probe5.Path(), []byte("leaked"), 0o600); err != nil { | ||
| t.Fatalf("write probe5 file: %v", err) | ||
| } | ||
| probe5.cleanup(t) | ||
| if _, err := os.Lstat(probe5.Path()); !os.IsNotExist(err) { | ||
| t.Fatalf("expected probe5 to be cleaned up after creation, stat err=%v", err) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Every denied-write probe uses
/d /s /c, which misses the runner's raw/d /chandling and causessyscall.EscapeArgto backslash-escapecmdQuote's inner quotes. The redirect then fails on a malformed target and returns the expected denial code even when sandbox confinement is broken, producing a false-positive smoke test.