From a6e84e0ed3890d0307754b9a97bd74652b777bb1 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:42:50 -0400 Subject: [PATCH 01/26] fix(windows): add Users and Authenticated Users SIDs to restricted token SIDs --- internal/sandbox/windows_token_windows.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/sandbox/windows_token_windows.go b/internal/sandbox/windows_token_windows.go index a02e9b001..5075dfb33 100644 --- a/internal/sandbox/windows_token_windows.go +++ b/internal/sandbox/windows_token_windows.go @@ -92,14 +92,24 @@ func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []w if err != nil { return 0, fmt.Errorf("create world SID: %w", err) } + usersSID, err := windows.CreateWellKnownSid(windows.WinBuiltinUsersSid) + if err != nil { + return 0, fmt.Errorf("create users SID: %w", err) + } + authUserSID, err := windows.CreateWellKnownSid(windows.WinAuthenticatedUserSid) + if err != nil { + return 0, fmt.Errorf("create authenticated user SID: %w", err) + } - entries := make([]windows.SIDAndAttributes, 0, len(capabilitySIDs)+2) + entries := make([]windows.SIDAndAttributes, 0, len(capabilitySIDs)+4) for _, sid := range capabilitySIDs { entries = append(entries, windows.SIDAndAttributes{Sid: sid.sid}) } entries = append(entries, windows.SIDAndAttributes{Sid: sidFromBytes(logonSID)}, windows.SIDAndAttributes{Sid: worldSID}, + windows.SIDAndAttributes{Sid: usersSID}, + windows.SIDAndAttributes{Sid: authUserSID}, ) // WRITE_RESTRICTED scopes the restricted-SID check to write-type accesses: From 3fe705d6cd61b3c9aa605103d4d58e4707b61d72 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:43:36 -0400 Subject: [PATCH 02/26] fix(sandbox): add DenyWrite ACEs for system drive, ProgramData, and Windows Temp --- .../runner_windows_integration_test.go | 16 ++++ internal/sandbox/windows_acl.go | 74 +++++++++++++++++++ internal/sandbox/windows_acl_apply_windows.go | 8 +- internal/sandbox/windows_acl_test.go | 52 ++++++++++++- 4 files changed, 142 insertions(+), 8 deletions(-) diff --git a/internal/sandbox/runner_windows_integration_test.go b/internal/sandbox/runner_windows_integration_test.go index d0f715744..5357e5957 100644 --- a/internal/sandbox/runner_windows_integration_test.go +++ b/internal/sandbox/runner_windows_integration_test.go @@ -203,6 +203,22 @@ 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 != "" { + programDataMarker := filepath.Join(programData, "zero-unelevated-write-denied.txt") + _ = os.Remove(programDataMarker) + + runWindowsRealSmokeCommand(t, runnerExe, config, []string{ + "cmd.exe", "/d", "/s", "/c", "echo leaked>" + programDataMarker, + }, 1) + + if _, err := os.Stat(programDataMarker); err == nil { + _ = os.Remove(programDataMarker) + t.Fatalf("unelevated sandbox allowed a write to ProgramData shared directory") + } + } } // TestWindowsRestrictedTokenNestedPipeCapture pins the fix in diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index 55d37d347..8778f2851 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -2,6 +2,7 @@ package sandbox import ( "errors" + "os" "path/filepath" "strings" ) @@ -19,6 +20,7 @@ type WindowsACLEntry struct { Path string `json:"path"` Capability string `json:"capability"` Materialize bool `json:"materialize,omitempty"` + NoInherit bool `json:"no_inherit,omitempty"` } type WindowsACLPlan struct { @@ -76,9 +78,81 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er }) } } + + // Deny write to shared Windows-writable directories (C:\, C:\ProgramData, C:\Windows\Temp) + // to prevent write-jail escape via the added Users and Authenticated Users SIDs. + systemDrive := os.Getenv("SystemDrive") + if systemDrive == "" { + systemDrive = "C:" + } + systemRoot := os.Getenv("SystemRoot") + if systemRoot == "" { + systemRoot = systemDrive + `\Windows` + } + programData := os.Getenv("ProgramData") + if programData == "" { + programData = systemDrive + `\ProgramData` + } + + sharedDenyPaths := []string{ + systemDrive + `\`, + programData, + systemRoot + `\Temp`, + } + + caps, err := LoadOrCreateWindowsCapabilitySIDs(config.SandboxHome) + if err != nil { + return WindowsACLPlan{}, err + } + var allSIDs []string + for _, cap := range writeCapabilities { + allSIDs = append(allSIDs, cap.SID) + } + allSIDs = append(allSIDs, caps.ReadOnly) + + for _, denyPath := range sharedDenyPaths { + isParent := false + isEqual := false + for _, cap := range writeCapabilities { + if isParentOrEqual(denyPath, cap.Root) { + if windowsCapabilityPathKey(denyPath) == windowsCapabilityPathKey(cap.Root) { + isEqual = true + } else { + isParent = true + } + } + } + if isEqual { + continue // Do not deny write if it is exactly an allowed write root + } + for _, sid := range allSIDs { + entries = append(entries, WindowsACLEntry{ + Action: WindowsACLDenyWrite, + Path: denyPath, + Capability: sid, + NoInherit: isParent, // Disable inheritance if a write root exists inside this path + }) + } + } + return WindowsACLPlan{Entries: dedupeWindowsACLEntries(entries)}, nil } +func isParentOrEqual(parent, child string) bool { + p := windowsCapabilityPathKey(parent) + c := windowsCapabilityPathKey(child) + if p == "" || c == "" { + return false + } + if p == c { + return true + } + if !strings.HasSuffix(p, `\`) { + p += `\` + } + return strings.HasPrefix(c, p) +} + type windowsWriteRootCapability struct { Root string SID string diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index c666aa9ef..103dc9ebe 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -193,10 +193,6 @@ func windowsACLGroupRequiresExistingTarget(group windowsACLPathGroup) bool { func windowsExplicitAccessEntries(entries []WindowsACLEntry, isDir bool) ([]windows.EXPLICIT_ACCESS, error) { out := make([]windows.EXPLICIT_ACCESS, 0, len(entries)) - inheritance := uint32(0) - if isDir { - inheritance = windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT - } for _, entry := range entries { sid, err := windows.StringToSid(entry.Capability) if err != nil { @@ -206,6 +202,10 @@ func windowsExplicitAccessEntries(entries []WindowsACLEntry, isDir bool) ([]wind if err != nil { return nil, err } + inheritance := uint32(0) + if isDir && !entry.NoInherit { + inheritance = windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT + } out = append(out, windows.EXPLICIT_ACCESS{ AccessPermissions: permissions, AccessMode: accessMode, diff --git a/internal/sandbox/windows_acl_test.go b/internal/sandbox/windows_acl_test.go index 1925bd8a9..13e78b4d5 100644 --- a/internal/sandbox/windows_acl_test.go +++ b/internal/sandbox/windows_acl_test.go @@ -52,6 +52,29 @@ func TestBuildWindowsACLPlanForWorkspaceWriteProfile(t *testing.T) { assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\secret-write`, cacheSID, false) assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, workspaceSID, true) assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, cacheSID, true) + + caps, err := LoadOrCreateWindowsCapabilitySIDs(home) + if err != nil { + t.Fatalf("LoadOrCreateWindowsCapabilitySIDs: %v", err) + } + systemDrive := os.Getenv("SystemDrive") + if systemDrive == "" { + systemDrive = "C:" + } + systemRoot := os.Getenv("SystemRoot") + if systemRoot == "" { + systemRoot = systemDrive + `\Windows` + } + programData := os.Getenv("ProgramData") + if programData == "" { + programData = systemDrive + `\ProgramData` + } + + for _, sid := range []string{workspaceSID, cacheSID, caps.ReadOnly} { + assertWindowsACLEntryExt(t, plan, WindowsACLDenyWrite, systemDrive+`\`, sid, false, true) + assertWindowsACLEntryExt(t, plan, WindowsACLDenyWrite, programData, sid, false, false) + assertWindowsACLEntryExt(t, plan, WindowsACLDenyWrite, systemRoot+`\Temp`, sid, false, false) + } } func TestBuildWindowsACLPlanUsesReadOnlySIDWithoutWriteRoots(t *testing.T) { @@ -73,10 +96,25 @@ 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) + if len(plan.Entries) != 4 { + t.Fatalf("ACL entries = %#v, want four entries (1 deny-read, 3 deny-write)", plan.Entries) } assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, caps.ReadOnly, true) + systemDrive := os.Getenv("SystemDrive") + if systemDrive == "" { + systemDrive = "C:" + } + systemRoot := os.Getenv("SystemRoot") + if systemRoot == "" { + systemRoot = systemDrive + `\Windows` + } + programData := os.Getenv("ProgramData") + if programData == "" { + programData = systemDrive + `\ProgramData` + } + assertWindowsACLEntryExt(t, plan, WindowsACLDenyWrite, systemDrive+`\`, caps.ReadOnly, false, false) + assertWindowsACLEntryExt(t, plan, WindowsACLDenyWrite, programData, caps.ReadOnly, false, false) + assertWindowsACLEntryExt(t, plan, WindowsACLDenyWrite, systemRoot+`\Temp`, caps.ReadOnly, false, false) } func TestBuildWindowsACLPlanRejectsUnrestrictedProfiles(t *testing.T) { @@ -117,16 +155,22 @@ func TestPlanWindowsDenyReadPathsIncludesCanonicalExistingPath(t *testing.T) { } func assertWindowsACLEntry(t *testing.T, plan WindowsACLPlan, action WindowsACLAction, path string, capability string, materialize bool) { + t.Helper() + assertWindowsACLEntryExt(t, plan, action, path, capability, materialize, false) +} + +func assertWindowsACLEntryExt(t *testing.T, plan WindowsACLPlan, action WindowsACLAction, path string, capability string, materialize bool, noInherit bool) { t.Helper() for _, entry := range plan.Entries { if entry.Action == action && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(path) && strings.EqualFold(entry.Capability, capability) && - entry.Materialize == materialize { + entry.Materialize == materialize && + entry.NoInherit == noInherit { return } } - t.Fatalf("ACL entries = %#v, want %s %q capability %q materialize=%v", plan.Entries, action, path, capability, materialize) + t.Fatalf("ACL entries = %#v, want %s %q capability %q materialize=%v noInherit=%v", plan.Entries, action, path, capability, materialize, noInherit) } func windowsPathListContains(paths []string, want string) bool { From 5a6129ad49b7914e1678cd8b194a40e4dbf4cef8 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:56:20 -0400 Subject: [PATCH 03/26] fix(sandbox): scope broadened restricted-token SIDs to the elevated tier Only the elevated restricted-token tier (zero sandbox setup, run as Administrator) now gets WinBuiltinUsersSid/WinAuthenticatedUserSid on the restricted token. That tier is also the only one with the WRITE_DAC needed to enforce BuildWindowsACLPlan's DenyWrite mitigation on shared system paths, so the unelevated tier keeps the narrower pre-widening SID set instead of aborting every command with access denied when it can't edit C:\, ProgramData, or Windows\Temp. Also add C:\Users\Public as an explicit DenyWrite target: inheriting a deny from C:\ never actually protected pre-existing children like it, since NTFS does not retroactively propagate inherited ACEs onto objects that already exist. Drop the NoInherit toggle that tried to route around this by disabling inheritance whenever a write root sat under C:\; it wasn't needed, since a write root's own explicit Allow ACE already takes precedence over anything inherited by canonical ACE ordering. --- .../runner_windows_integration_test.go | 23 ++++ internal/sandbox/windows_acl.go | 129 ++++++++++-------- internal/sandbox/windows_acl_apply_windows.go | 2 +- internal/sandbox/windows_acl_test.go | 100 +++++++++----- .../sandbox/windows_command_runner_windows.go | 6 +- internal/sandbox/windows_token_windows.go | 43 ++++-- 6 files changed, 194 insertions(+), 109 deletions(-) diff --git a/internal/sandbox/runner_windows_integration_test.go b/internal/sandbox/runner_windows_integration_test.go index 5357e5957..35823cd53 100644 --- a/internal/sandbox/runner_windows_integration_test.go +++ b/internal/sandbox/runner_windows_integration_test.go @@ -72,6 +72,29 @@ func TestWindowsRestrictedTokenRealSandboxSmoke(t *testing.T) { t.Fatalf("sandboxed write marker = %q, %v; want ok", bytes, err) } + // The elevated tier's restricted token carries the Users/Authenticated + // Users SIDs (added for Program Files/System32 reads), which also match + // the write grant those groups already hold on C:\Users\Public. Pin that + // BuildWindowsACLPlan's DenyWrite mitigation still blocks a write there: + // an independent shared-writable directory outside every carved-out + // system path (ProgramData, Windows\Temp), and outside every workspace + // write root. + publicDir := os.Getenv("PUBLIC") + if publicDir == "" { + t.Skip("PUBLIC is not set; cannot probe C:\\Users\\Public write jail") + } + publicMarker := filepath.Join(publicDir, "zero-elevated-write-denied.txt") + _ = os.Remove(publicMarker) + runWindowsRealSmokeCommand(t, runnerExe, config, []string{ + "cmd.exe", "/d", "/s", "/c", "echo leaked>" + publicMarker, + }, 1) + if _, err := os.Stat(publicMarker); err == nil { + _ = os.Remove(publicMarker) + 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) diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index 8778f2851..2f623d180 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -20,7 +20,6 @@ type WindowsACLEntry struct { Path string `json:"path"` Capability string `json:"capability"` Materialize bool `json:"materialize,omitempty"` - NoInherit bool `json:"no_inherit,omitempty"` } type WindowsACLPlan struct { @@ -79,78 +78,88 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er } } - // Deny write to shared Windows-writable directories (C:\, C:\ProgramData, C:\Windows\Temp) - // to prevent write-jail escape via the added Users and Authenticated Users SIDs. - systemDrive := os.Getenv("SystemDrive") - if systemDrive == "" { - systemDrive = "C:" - } - systemRoot := os.Getenv("SystemRoot") - if systemRoot == "" { - systemRoot = systemDrive + `\Windows` - } - programData := os.Getenv("ProgramData") - if programData == "" { - programData = systemDrive + `\ProgramData` - } - - sharedDenyPaths := []string{ - systemDrive + `\`, - programData, - systemRoot + `\Temp`, - } + // Deny write to shared Windows-writable directories (C:\, C:\ProgramData, + // C:\Windows\Temp, C:\Users\Public) to prevent write-jail escape via the + // added Users and Authenticated Users SIDs. Only the elevated tier + // (WindowsSandboxLevelRestrictedToken, applied by `zero sandbox setup` + // running as Administrator) reaches here with those SIDs on the token in + // the first place — see createWindowsRestrictedTokenFromBase — and only + // that tier has the WRITE_DAC needed to edit these system-owned DACLs. + // The unelevated tier keeps the narrower (pre-widening) restricting-SID + // set and never needs these entries. + if config.SandboxLevel == WindowsSandboxLevelRestrictedToken { + systemDrive := os.Getenv("SystemDrive") + if systemDrive == "" { + systemDrive = "C:" + } + systemRoot := os.Getenv("SystemRoot") + if systemRoot == "" { + systemRoot = systemDrive + `\Windows` + } + programData := os.Getenv("ProgramData") + if programData == "" { + programData = systemDrive + `\ProgramData` + } + publicDir := os.Getenv("PUBLIC") + if publicDir == "" { + publicDir = systemDrive + `\Users\Public` + } - caps, err := LoadOrCreateWindowsCapabilitySIDs(config.SandboxHome) - if err != nil { - return WindowsACLPlan{}, err - } - var allSIDs []string - for _, cap := range writeCapabilities { - allSIDs = append(allSIDs, cap.SID) - } - allSIDs = append(allSIDs, caps.ReadOnly) + sharedDenyPaths := []string{ + systemDrive + `\`, + programData, + systemRoot + `\Temp`, + publicDir, + } - for _, denyPath := range sharedDenyPaths { - isParent := false - isEqual := false - for _, cap := range writeCapabilities { - if isParentOrEqual(denyPath, cap.Root) { - if windowsCapabilityPathKey(denyPath) == windowsCapabilityPathKey(cap.Root) { - isEqual = true - } else { - isParent = true - } - } + caps, err := LoadOrCreateWindowsCapabilitySIDs(config.SandboxHome) + if err != nil { + return WindowsACLPlan{}, err } - if isEqual { - continue // Do not deny write if it is exactly an allowed write root + var allSIDs []string + for _, cap := range writeCapabilities { + allSIDs = append(allSIDs, cap.SID) } - for _, sid := range allSIDs { - entries = append(entries, WindowsACLEntry{ - Action: WindowsACLDenyWrite, - Path: denyPath, - Capability: sid, - NoInherit: isParent, // Disable inheritance if a write root exists inside this path - }) + allSIDs = append(allSIDs, caps.ReadOnly) + + for _, denyPath := range sharedDenyPaths { + if windowsPathEqualsAnyRoot(denyPath, writeCapabilities) { + continue // Do not deny write if it is exactly an allowed write root + } + // Inheritance is intentionally left on: the write root's own + // explicit Allow ACE (set directly on that path in its own group, + // above) is a non-inherited entry, and canonical ACE ordering + // always evaluates explicit entries before inherited ones — so an + // inherited Deny from a shared ancestor here can never shadow it. + // It also still defends newly created objects elsewhere under the + // shared path: NTFS does not retroactively propagate an + // inheritable ACE onto pre-existing children, which is exactly + // why C:\Users\Public above is listed explicitly rather than + // relied on via inheritance from C:\. + for _, sid := range allSIDs { + entries = append(entries, WindowsACLEntry{ + Action: WindowsACLDenyWrite, + Path: denyPath, + Capability: sid, + }) + } } } return WindowsACLPlan{Entries: dedupeWindowsACLEntries(entries)}, nil } -func isParentOrEqual(parent, child string) bool { - p := windowsCapabilityPathKey(parent) - c := windowsCapabilityPathKey(child) - if p == "" || c == "" { +func windowsPathEqualsAnyRoot(path string, capabilities []windowsWriteRootCapability) bool { + key := windowsCapabilityPathKey(path) + if key == "" { return false } - if p == c { - return true - } - if !strings.HasSuffix(p, `\`) { - p += `\` + for _, cap := range capabilities { + if windowsCapabilityPathKey(cap.Root) == key { + return true + } } - return strings.HasPrefix(c, p) + return false } type windowsWriteRootCapability struct { diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index 103dc9ebe..30cafea20 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -203,7 +203,7 @@ func windowsExplicitAccessEntries(entries []WindowsACLEntry, isDir bool) ([]wind return nil, err } inheritance := uint32(0) - if isDir && !entry.NoInherit { + if isDir { inheritance = windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT } out = append(out, windows.EXPLICIT_ACCESS{ diff --git a/internal/sandbox/windows_acl_test.go b/internal/sandbox/windows_acl_test.go index 13e78b4d5..4313be3cd 100644 --- a/internal/sandbox/windows_acl_test.go +++ b/internal/sandbox/windows_acl_test.go @@ -12,6 +12,7 @@ func TestBuildWindowsACLPlanForWorkspaceWriteProfile(t *testing.T) { config := WindowsSandboxCommandConfig{ SandboxHome: home, WorkspaceRoots: []string{`C:\workspace`}, + SandboxLevel: WindowsSandboxLevelRestrictedToken, PermissionProfile: PermissionProfile{ FileSystem: FileSystemPolicy{ Kind: FileSystemRestricted, @@ -57,24 +58,70 @@ func TestBuildWindowsACLPlanForWorkspaceWriteProfile(t *testing.T) { if err != nil { t.Fatalf("LoadOrCreateWindowsCapabilitySIDs: %v", err) } - systemDrive := os.Getenv("SystemDrive") + systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest() + + for _, sid := range []string{workspaceSID, cacheSID, caps.ReadOnly} { + assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, systemDrive+`\`, sid, false) + assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, programData, sid, false) + assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, systemRoot+`\Temp`, sid, false) + assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, publicDir, sid, false) + } +} + +// TestBuildWindowsACLPlanOmitsSharedDenyPathsWhenUnelevated pins the fix for +// the unelevated tier aborting every sandboxed command: BuildWindowsACLPlan +// must not add DenyWrite entries for C:\, C:\ProgramData, C:\Windows\Temp, or +// C:\Users\Public when SandboxLevel is WindowsSandboxLevelUnelevated, because +// SetNamedSecurityInfo on those system-owned paths requires WRITE_DAC that an +// ordinary (non-Administrator) user does not have. The unelevated tier never +// puts the Users/Authenticated Users SIDs on the token in the first place +// (see createWindowsRestrictedTokenFromBase), so it does not need these +// mitigating entries. +func TestBuildWindowsACLPlanOmitsSharedDenyPathsWhenUnelevated(t *testing.T) { + home := t.TempDir() + plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ + SandboxHome: home, + WorkspaceRoots: []string{`C:\workspace`}, + SandboxLevel: WindowsSandboxLevelUnelevated, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: `C:\workspace`}}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + }) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest() + for _, path := range []string{systemDrive + `\`, programData, systemRoot + `\Temp`, publicDir} { + for _, entry := range plan.Entries { + if entry.Action == WindowsACLDenyWrite && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(path) { + t.Fatalf("unelevated ACL plan = %#v, want no DenyWrite entry for shared path %q", plan.Entries, path) + } + } + } +} + +func windowsSharedDenyPathsForTest() (systemDrive, systemRoot, programData, publicDir string) { + systemDrive = os.Getenv("SystemDrive") if systemDrive == "" { systemDrive = "C:" } - systemRoot := os.Getenv("SystemRoot") + systemRoot = os.Getenv("SystemRoot") if systemRoot == "" { systemRoot = systemDrive + `\Windows` } - programData := os.Getenv("ProgramData") + programData = os.Getenv("ProgramData") if programData == "" { programData = systemDrive + `\ProgramData` } - - for _, sid := range []string{workspaceSID, cacheSID, caps.ReadOnly} { - assertWindowsACLEntryExt(t, plan, WindowsACLDenyWrite, systemDrive+`\`, sid, false, true) - assertWindowsACLEntryExt(t, plan, WindowsACLDenyWrite, programData, sid, false, false) - assertWindowsACLEntryExt(t, plan, WindowsACLDenyWrite, systemRoot+`\Temp`, sid, false, false) + publicDir = os.Getenv("PUBLIC") + if publicDir == "" { + publicDir = systemDrive + `\Users\Public` } + return systemDrive, systemRoot, programData, publicDir } func TestBuildWindowsACLPlanUsesReadOnlySIDWithoutWriteRoots(t *testing.T) { @@ -84,7 +131,8 @@ func TestBuildWindowsACLPlanUsesReadOnlySIDWithoutWriteRoots(t *testing.T) { t.Fatalf("LoadOrCreateWindowsCapabilitySIDs: %v", err) } plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ - SandboxHome: home, + SandboxHome: home, + SandboxLevel: WindowsSandboxLevelRestrictedToken, PermissionProfile: PermissionProfile{ FileSystem: FileSystemPolicy{ Kind: FileSystemRestricted, @@ -96,25 +144,15 @@ func TestBuildWindowsACLPlanUsesReadOnlySIDWithoutWriteRoots(t *testing.T) { if err != nil { t.Fatalf("BuildWindowsACLPlan: %v", err) } - if len(plan.Entries) != 4 { - t.Fatalf("ACL entries = %#v, want four entries (1 deny-read, 3 deny-write)", plan.Entries) + if len(plan.Entries) != 5 { + t.Fatalf("ACL entries = %#v, want five entries (1 deny-read, 4 deny-write)", plan.Entries) } assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, caps.ReadOnly, true) - systemDrive := os.Getenv("SystemDrive") - if systemDrive == "" { - systemDrive = "C:" - } - systemRoot := os.Getenv("SystemRoot") - if systemRoot == "" { - systemRoot = systemDrive + `\Windows` - } - programData := os.Getenv("ProgramData") - if programData == "" { - programData = systemDrive + `\ProgramData` - } - assertWindowsACLEntryExt(t, plan, WindowsACLDenyWrite, systemDrive+`\`, caps.ReadOnly, false, false) - assertWindowsACLEntryExt(t, plan, WindowsACLDenyWrite, programData, caps.ReadOnly, false, false) - assertWindowsACLEntryExt(t, plan, WindowsACLDenyWrite, systemRoot+`\Temp`, caps.ReadOnly, false, false) + systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest() + assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, systemDrive+`\`, caps.ReadOnly, false) + assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, programData, caps.ReadOnly, false) + assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, systemRoot+`\Temp`, caps.ReadOnly, false) + assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, publicDir, caps.ReadOnly, false) } func TestBuildWindowsACLPlanRejectsUnrestrictedProfiles(t *testing.T) { @@ -155,22 +193,16 @@ func TestPlanWindowsDenyReadPathsIncludesCanonicalExistingPath(t *testing.T) { } func assertWindowsACLEntry(t *testing.T, plan WindowsACLPlan, action WindowsACLAction, path string, capability string, materialize bool) { - t.Helper() - assertWindowsACLEntryExt(t, plan, action, path, capability, materialize, false) -} - -func assertWindowsACLEntryExt(t *testing.T, plan WindowsACLPlan, action WindowsACLAction, path string, capability string, materialize bool, noInherit bool) { t.Helper() for _, entry := range plan.Entries { if entry.Action == action && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(path) && strings.EqualFold(entry.Capability, capability) && - entry.Materialize == materialize && - entry.NoInherit == noInherit { + entry.Materialize == materialize { return } } - t.Fatalf("ACL entries = %#v, want %s %q capability %q materialize=%v noInherit=%v", plan.Entries, action, path, capability, materialize, noInherit) + t.Fatalf("ACL entries = %#v, want %s %q capability %q materialize=%v", plan.Entries, action, path, capability, materialize) } func windowsPathListContains(paths []string, want string) bool { diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 0b9e8f64e..141960605 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -75,7 +75,11 @@ 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 - token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs, writeRestricted) + // Only the elevated restricted-token tier can enforce the DenyWrite + // mitigation BuildWindowsACLPlan adds for the broadened read SIDs (it + // requires Administrator rights); see createWindowsRestrictedTokenFromBase. + broadenReadSIDs := config.SandboxLevel == WindowsSandboxLevelRestrictedToken + token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs, writeRestricted, broadenReadSIDs) if err != nil { fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) return 1 diff --git a/internal/sandbox/windows_token_windows.go b/internal/sandbox/windows_token_windows.go index 5075dfb33..dfae6d223 100644 --- a/internal/sandbox/windows_token_windows.go +++ b/internal/sandbox/windows_token_windows.go @@ -48,7 +48,7 @@ func (sid windowsLocalSID) close() { } } -func createWindowsRestrictedTokenForCapabilitySIDs(capabilitySIDStrings []string, writeRestricted bool) (windows.Token, error) { +func createWindowsRestrictedTokenForCapabilitySIDs(capabilitySIDStrings []string, writeRestricted, broadenReadSIDs bool) (windows.Token, error) { if len(capabilitySIDStrings) == 0 { return 0, errors.New("windows restricted token requires at least one capability SID") } @@ -80,10 +80,23 @@ func createWindowsRestrictedTokenForCapabilitySIDs(capabilitySIDStrings []string return 0, fmt.Errorf("open process token: %w", err) } defer base.Close() - return createWindowsRestrictedTokenFromBase(base, capabilitySIDs, writeRestricted) + return createWindowsRestrictedTokenFromBase(base, capabilitySIDs, writeRestricted, broadenReadSIDs) } -func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []windowsLocalSID, writeRestricted bool) (windows.Token, error) { +// createWindowsRestrictedTokenFromBase builds the restricted token. When +// broadenReadSIDs is set, it also restricts to WinBuiltinUsersSid and +// WinAuthenticatedUserSid so the sandboxed process can read/execute binaries +// under paths like C:\Program Files or C:\Windows whose ACLs grant +// Users/Authenticated Users rather than Everyone. Because the restricting-SID +// check applies to writes as well as reads, this also grants write wherever +// those groups already have it — BuildWindowsACLPlan mitigates that by adding +// DenyWrite ACEs to the known shared Users/Authenticated-Users-writable +// directories, but it can only do so with Administrator rights (see +// WindowsSandboxLevelRestrictedToken). broadenReadSIDs must therefore stay +// false for WindowsSandboxLevelUnelevated, which cannot enforce that +// mitigation: it keeps the original (narrower) read scope instead of +// widening the write jail with nothing to close the gap. +func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []windowsLocalSID, writeRestricted, broadenReadSIDs bool) (windows.Token, error) { logonSID, err := copyWindowsLogonSID(base) if err != nil { return 0, err @@ -92,14 +105,6 @@ func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []w if err != nil { return 0, fmt.Errorf("create world SID: %w", err) } - usersSID, err := windows.CreateWellKnownSid(windows.WinBuiltinUsersSid) - if err != nil { - return 0, fmt.Errorf("create users SID: %w", err) - } - authUserSID, err := windows.CreateWellKnownSid(windows.WinAuthenticatedUserSid) - if err != nil { - return 0, fmt.Errorf("create authenticated user SID: %w", err) - } entries := make([]windows.SIDAndAttributes, 0, len(capabilitySIDs)+4) for _, sid := range capabilitySIDs { @@ -108,9 +113,21 @@ func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []w entries = append(entries, windows.SIDAndAttributes{Sid: sidFromBytes(logonSID)}, windows.SIDAndAttributes{Sid: worldSID}, - windows.SIDAndAttributes{Sid: usersSID}, - windows.SIDAndAttributes{Sid: authUserSID}, ) + if broadenReadSIDs { + usersSID, err := windows.CreateWellKnownSid(windows.WinBuiltinUsersSid) + if err != nil { + return 0, fmt.Errorf("create users SID: %w", err) + } + authUserSID, err := windows.CreateWellKnownSid(windows.WinAuthenticatedUserSid) + if err != nil { + return 0, fmt.Errorf("create authenticated user SID: %w", err) + } + entries = append(entries, + windows.SIDAndAttributes{Sid: usersSID}, + windows.SIDAndAttributes{Sid: authUserSID}, + ) + } // WRITE_RESTRICTED scopes the restricted-SID check to write-type accesses: // reads use only the normal token identity, so the sandboxed process can From a1af78f62e696c8107107be28e4a305d037a9684 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:19:00 -0400 Subject: [PATCH 04/26] fix(sandbox): fail loudly on unexpected ProgramData marker stat errors Align the ProgramData marker check with its outsideMarker and publicMarker siblings, which already report a fatal error if Stat fails for a reason other than the file not existing. --- internal/sandbox/runner_windows_integration_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/sandbox/runner_windows_integration_test.go b/internal/sandbox/runner_windows_integration_test.go index 35823cd53..c2f16ec21 100644 --- a/internal/sandbox/runner_windows_integration_test.go +++ b/internal/sandbox/runner_windows_integration_test.go @@ -240,6 +240,8 @@ func TestWindowsUnelevatedRealSandboxSmoke(t *testing.T) { if _, err := os.Stat(programDataMarker); err == nil { _ = os.Remove(programDataMarker) t.Fatalf("unelevated sandbox allowed a write to ProgramData shared directory") + } else if !os.IsNotExist(err) { + t.Fatalf("stat ProgramData marker: %v", err) } } } From c4a0e1e155d566198ed193060a2c5fb04cb2ac0a Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:13:36 -0400 Subject: [PATCH 05/26] fix(windows): resolve deny-paths from trusted APIs and stop ACL inheritance propagation Resolve the shared DenyWrite target paths (system drive, ProgramData, Windows\Temp, Users\Public) via GetSystemWindowsDirectory and SHGetKnownFolderPath instead of trusting the SystemDrive/SystemRoot/ ProgramData/PUBLIC environment variables, which an attacker able to influence the elevated setup process's environment could spoof to leave the real system paths unprotected. Also stop marking those four DenyWrite entries as inheritable. SetNamedSecurityInfo automatically propagates inheritable ACEs onto a target's existing descendants, so the previous entries recursively stamped a deny ACE across the entire existing subtree of the system drive rather than just the four intended directories, which was slow, polluted unrelated machine ACLs, and could shadow legitimate workspace allows for repos under the system drive. A plain, non-inherited deny directly on each of the four paths already blocks writes (including new children) at that path without touching any descendant's ACL. --- internal/sandbox/windows_acl.go | 67 ++++++++++--------- internal/sandbox/windows_acl_apply_windows.go | 2 +- internal/sandbox/windows_acl_paths_other.go | 34 ++++++++++ internal/sandbox/windows_acl_paths_windows.go | 44 ++++++++++++ internal/sandbox/windows_acl_test.go | 57 ++++++++-------- 5 files changed, 144 insertions(+), 60 deletions(-) create mode 100644 internal/sandbox/windows_acl_paths_other.go create mode 100644 internal/sandbox/windows_acl_paths_windows.go diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index 2f623d180..3a82999e8 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -2,7 +2,7 @@ package sandbox import ( "errors" - "os" + "fmt" "path/filepath" "strings" ) @@ -16,10 +16,18 @@ const ( ) type WindowsACLEntry struct { - Action WindowsACLAction `json:"action"` - Path string `json:"path"` - Capability string `json:"capability"` - Materialize bool `json:"materialize,omitempty"` + Action WindowsACLAction `json:"action"` + Path string `json:"path"` + Capability string `json:"capability"` + // NoInherit forces the applied ACE to carry no inheritance flags, even + // when the target is a directory. Without it, applyWindowsACLPlan makes + // every directory ACE inheritable (SUB_CONTAINERS_AND_OBJECTS_INHERIT), + // and SetNamedSecurityInfo automatically propagates any inheritable ACE + // down onto the target's EXISTING descendants (not just new ones it + // creates going forward) — see the shared-deny-path entries below for + // why that is unsafe on broad system roots. + NoInherit bool `json:"noInherit,omitempty"` + Materialize bool `json:"materialize,omitempty"` } type WindowsACLPlan struct { @@ -88,21 +96,13 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er // The unelevated tier keeps the narrower (pre-widening) restricting-SID // set and never needs these entries. if config.SandboxLevel == WindowsSandboxLevelRestrictedToken { - systemDrive := os.Getenv("SystemDrive") - if systemDrive == "" { - systemDrive = "C:" - } - systemRoot := os.Getenv("SystemRoot") - if systemRoot == "" { - systemRoot = systemDrive + `\Windows` - } - programData := os.Getenv("ProgramData") - if programData == "" { - programData = systemDrive + `\ProgramData` - } - publicDir := os.Getenv("PUBLIC") - if publicDir == "" { - publicDir = systemDrive + `\Users\Public` + // Resolved from trusted Win32 APIs, not from the + // SystemDrive/SystemRoot/ProgramData/PUBLIC environment variables: + // see resolveWindowsSharedDenyPaths for why trusting the environment + // here would be a spoofable security boundary. + systemDrive, systemRoot, programData, publicDir, err := resolveWindowsSharedDenyPaths() + if err != nil { + return WindowsACLPlan{}, fmt.Errorf("resolve shared deny paths: %w", err) } sharedDenyPaths := []string{ @@ -126,21 +126,28 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er if windowsPathEqualsAnyRoot(denyPath, writeCapabilities) { continue // Do not deny write if it is exactly an allowed write root } - // Inheritance is intentionally left on: the write root's own - // explicit Allow ACE (set directly on that path in its own group, - // above) is a non-inherited entry, and canonical ACE ordering - // always evaluates explicit entries before inherited ones — so an - // inherited Deny from a shared ancestor here can never shadow it. - // It also still defends newly created objects elsewhere under the - // shared path: NTFS does not retroactively propagate an - // inheritable ACE onto pre-existing children, which is exactly - // why C:\Users\Public above is listed explicitly rather than - // relied on via inheritance from C:\. + // NoInherit: these four shared paths must NOT carry an inheritable + // ACE. SetNamedSecurityInfo automatically propagates any + // inheritable ACE down onto the target's EXISTING descendants + // (per Microsoft's documented remarks for SetNamedSecurityInfoW), + // not just ones created afterward. C:\ in particular can have an + // enormous, slow-to-walk, and largely unrelated existing subtree + // (Program Files, Users, arbitrary installed software), and + // stamping a synthetic deny ACE onto all of it would also + // permanently pollute those machine ACLs and could shadow + // legitimate workspace Allow entries for repos that happen to + // live under the system drive. Each of these four paths is + // listed explicitly (rather than relied on via inheritance from + // C:\) precisely so a plain, non-inherited Deny placed directly + // on each one is sufficient: it blocks the denied SIDs from + // writing (including creating new children) directly under that + // path without ever touching any descendant's own ACL. for _, sid := range allSIDs { entries = append(entries, WindowsACLEntry{ Action: WindowsACLDenyWrite, Path: denyPath, Capability: sid, + NoInherit: true, }) } } diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index 30cafea20..103dc9ebe 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -203,7 +203,7 @@ func windowsExplicitAccessEntries(entries []WindowsACLEntry, isDir bool) ([]wind return nil, err } inheritance := uint32(0) - if isDir { + if isDir && !entry.NoInherit { inheritance = windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT } out = append(out, windows.EXPLICIT_ACCESS{ diff --git a/internal/sandbox/windows_acl_paths_other.go b/internal/sandbox/windows_acl_paths_other.go new file mode 100644 index 000000000..f3de23b0c --- /dev/null +++ b/internal/sandbox/windows_acl_paths_other.go @@ -0,0 +1,34 @@ +//go:build !windows + +package sandbox + +import "os" + +// resolveWindowsSharedDenyPaths mirrors resolveWindowsSharedDenyPaths from +// windows_acl_paths_windows.go using environment-variable fallbacks. The +// trusted-API resolution used on Windows cannot be exercised on other GOOS, +// but that carries none of the production risk it exists to close: +// BuildWindowsACLPlan's shared-deny-path logic only ever runs for real on +// Windows (applyWindowsACLPlan, which actually mutates a DACL, is itself +// windows-only), so on other platforms this is only reached from unit tests +// that inspect the plan's structure, not from an elevated setup process +// whose environment an attacker might control. +func resolveWindowsSharedDenyPaths() (systemDrive, systemRoot, programData, publicDir string, err error) { + systemDrive = os.Getenv("SystemDrive") + if systemDrive == "" { + systemDrive = "C:" + } + systemRoot = os.Getenv("SystemRoot") + if systemRoot == "" { + systemRoot = systemDrive + `\Windows` + } + programData = os.Getenv("ProgramData") + if programData == "" { + programData = systemDrive + `\ProgramData` + } + publicDir = os.Getenv("PUBLIC") + if publicDir == "" { + publicDir = systemDrive + `\Users\Public` + } + return systemDrive, systemRoot, programData, publicDir, nil +} diff --git a/internal/sandbox/windows_acl_paths_windows.go b/internal/sandbox/windows_acl_paths_windows.go new file mode 100644 index 000000000..ee19762b4 --- /dev/null +++ b/internal/sandbox/windows_acl_paths_windows.go @@ -0,0 +1,44 @@ +//go:build windows + +package sandbox + +import ( + "fmt" + "path/filepath" + + "golang.org/x/sys/windows" +) + +// resolveWindowsSharedDenyPaths resolves the canonical system paths that +// BuildWindowsACLPlan protects with shared DenyWrite entries (the system +// drive root, %SystemRoot%\Temp, ProgramData, and the Public user profile). +// +// These are resolved from trusted Win32 APIs (GetSystemWindowsDirectory, +// SHGetKnownFolderPath) rather than the SystemDrive/SystemRoot/ProgramData/ +// PUBLIC environment variables. Those variables are ordinary process +// environment state: anything able to influence the environment of the +// elevated `zero sandbox setup` process (which builds and applies this ACL +// plan) could spoof them to point the DenyWrite mitigation at the wrong +// paths, leaving the real system directories unprotected while the +// restricted token is still broadened with the Users and Authenticated +// Users SIDs (see createWindowsRestrictedTokenFromBase). The Win32 APIs used +// here are answered by the OS from its own configuration, not from the +// caller's environment block, so they are not spoofable the same way. +func resolveWindowsSharedDenyPaths() (systemDrive, systemRoot, programData, publicDir string, err error) { + windowsDir, err := windows.GetSystemWindowsDirectory() + if err != nil { + return "", "", "", "", fmt.Errorf("resolve system windows directory: %w", err) + } + systemRoot = filepath.Clean(windowsDir) + systemDrive = filepath.VolumeName(systemRoot) + if systemDrive == "" { + return "", "", "", "", fmt.Errorf("resolve system drive from windows directory %q", systemRoot) + } + if programData, err = windows.KnownFolderPath(windows.FOLDERID_ProgramData, 0); err != nil { + return "", "", "", "", fmt.Errorf("resolve ProgramData known folder: %w", err) + } + if publicDir, err = windows.KnownFolderPath(windows.FOLDERID_Public, 0); err != nil { + return "", "", "", "", fmt.Errorf("resolve Public known folder: %w", err) + } + return systemDrive, systemRoot, programData, publicDir, nil +} diff --git a/internal/sandbox/windows_acl_test.go b/internal/sandbox/windows_acl_test.go index 4313be3cd..2d2e2ae96 100644 --- a/internal/sandbox/windows_acl_test.go +++ b/internal/sandbox/windows_acl_test.go @@ -58,13 +58,13 @@ func TestBuildWindowsACLPlanForWorkspaceWriteProfile(t *testing.T) { if err != nil { t.Fatalf("LoadOrCreateWindowsCapabilitySIDs: %v", err) } - systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest() + systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest(t) for _, sid := range []string{workspaceSID, cacheSID, caps.ReadOnly} { - assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, systemDrive+`\`, sid, false) - assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, programData, sid, false) - assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, systemRoot+`\Temp`, sid, false) - assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, publicDir, sid, false) + assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, systemDrive+`\`, sid, false, true) + assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, programData, sid, false, true) + assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, systemRoot+`\Temp`, sid, false, true) + assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, publicDir, sid, false, true) } } @@ -94,7 +94,7 @@ func TestBuildWindowsACLPlanOmitsSharedDenyPathsWhenUnelevated(t *testing.T) { if err != nil { t.Fatalf("BuildWindowsACLPlan: %v", err) } - systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest() + systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest(t) for _, path := range []string{systemDrive + `\`, programData, systemRoot + `\Temp`, publicDir} { for _, entry := range plan.Entries { if entry.Action == WindowsACLDenyWrite && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(path) { @@ -104,22 +104,15 @@ func TestBuildWindowsACLPlanOmitsSharedDenyPathsWhenUnelevated(t *testing.T) { } } -func windowsSharedDenyPathsForTest() (systemDrive, systemRoot, programData, publicDir string) { - systemDrive = os.Getenv("SystemDrive") - if systemDrive == "" { - systemDrive = "C:" - } - systemRoot = os.Getenv("SystemRoot") - if systemRoot == "" { - systemRoot = systemDrive + `\Windows` - } - programData = os.Getenv("ProgramData") - if programData == "" { - programData = systemDrive + `\ProgramData` - } - publicDir = os.Getenv("PUBLIC") - if publicDir == "" { - publicDir = systemDrive + `\Users\Public` +// windowsSharedDenyPathsForTest calls the same trusted-path resolution +// BuildWindowsACLPlan itself uses, rather than reimplementing the +// resolution logic independently, so this test cannot silently drift out of +// sync with (or mask a regression in) the production resolver. +func windowsSharedDenyPathsForTest(t *testing.T) (systemDrive, systemRoot, programData, publicDir string) { + t.Helper() + systemDrive, systemRoot, programData, publicDir, err := resolveWindowsSharedDenyPaths() + if err != nil { + t.Fatalf("resolveWindowsSharedDenyPaths: %v", err) } return systemDrive, systemRoot, programData, publicDir } @@ -148,11 +141,11 @@ func TestBuildWindowsACLPlanUsesReadOnlySIDWithoutWriteRoots(t *testing.T) { t.Fatalf("ACL entries = %#v, want five entries (1 deny-read, 4 deny-write)", plan.Entries) } assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, caps.ReadOnly, true) - systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest() - assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, systemDrive+`\`, caps.ReadOnly, false) - assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, programData, caps.ReadOnly, false) - assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, systemRoot+`\Temp`, caps.ReadOnly, false) - assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, publicDir, caps.ReadOnly, false) + systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest(t) + assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, systemDrive+`\`, caps.ReadOnly, false, true) + assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, programData, caps.ReadOnly, false, true) + assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, systemRoot+`\Temp`, caps.ReadOnly, false, true) + assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, publicDir, caps.ReadOnly, false, true) } func TestBuildWindowsACLPlanRejectsUnrestrictedProfiles(t *testing.T) { @@ -193,16 +186,22 @@ func TestPlanWindowsDenyReadPathsIncludesCanonicalExistingPath(t *testing.T) { } func assertWindowsACLEntry(t *testing.T, plan WindowsACLPlan, action WindowsACLAction, path string, capability string, materialize bool) { + t.Helper() + assertWindowsACLEntryInheritance(t, plan, action, path, capability, materialize, false) +} + +func assertWindowsACLEntryInheritance(t *testing.T, plan WindowsACLPlan, action WindowsACLAction, path string, capability string, materialize bool, noInherit bool) { t.Helper() for _, entry := range plan.Entries { if entry.Action == action && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(path) && strings.EqualFold(entry.Capability, capability) && - entry.Materialize == materialize { + entry.Materialize == materialize && + entry.NoInherit == noInherit { return } } - t.Fatalf("ACL entries = %#v, want %s %q capability %q materialize=%v", plan.Entries, action, path, capability, materialize) + t.Fatalf("ACL entries = %#v, want %s %q capability %q materialize=%v noInherit=%v", plan.Entries, action, path, capability, materialize, noInherit) } func windowsPathListContains(paths []string, want string) bool { From fa64859cec2179fa46e96b1fb2203e4a746b0bd6 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:00:33 -0400 Subject: [PATCH 06/26] fix(sandbox): scope SID broadening to fully restricted tokens, stable deny identity - Users/Authenticated Users are only added to the restricted-SID list when the token is fully restricted (a DenyRead profile): a WRITE_RESTRICTED token already reads with its normal identity, so broadening it gained nothing for Program Files/System32 reads while letting those groups' write grants pass the restricted-SID write check. The default elevated profile therefore no longer takes the write-jail risk at all. - The shared system-path DenyWrite mitigation (C:\, ProgramData, Windows\Temp, Public) is only planned for DenyRead profiles, the ones whose tokens actually carry the broadened SIDs, and its deny ACEs name only the stable read-only capability SID, which every broadened token now carries. Machine-wide DACLs stay at a constant four entries total instead of growing by four per distinct sandboxed project. - The real-Windows smoke's Public-directory probe now pins that a non-DenyRead profile's token cannot reach the Users write grant at all, and plan-level tests pin both the stable deny identity and the absence of shared entries for non-DenyRead profiles. --- .../runner_windows_integration_test.go | 14 ++--- internal/sandbox/windows_acl.go | 45 ++++++++-------- internal/sandbox/windows_acl_test.go | 51 +++++++++++++++++-- .../sandbox/windows_command_runner_windows.go | 28 ++++++++-- internal/sandbox/windows_token_windows.go | 21 +++++--- 5 files changed, 114 insertions(+), 45 deletions(-) diff --git a/internal/sandbox/runner_windows_integration_test.go b/internal/sandbox/runner_windows_integration_test.go index c2f16ec21..d4921c2ec 100644 --- a/internal/sandbox/runner_windows_integration_test.go +++ b/internal/sandbox/runner_windows_integration_test.go @@ -72,13 +72,13 @@ func TestWindowsRestrictedTokenRealSandboxSmoke(t *testing.T) { t.Fatalf("sandboxed write marker = %q, %v; want ok", bytes, err) } - // The elevated tier's restricted token carries the Users/Authenticated - // Users SIDs (added for Program Files/System32 reads), which also match - // the write grant those groups already hold on C:\Users\Public. Pin that - // BuildWindowsACLPlan's DenyWrite mitigation still blocks a write there: - // an independent shared-writable directory outside every carved-out - // system path (ProgramData, Windows\Temp), and outside every workspace - // write root. + // This profile has no DenyRead paths, so its WRITE_RESTRICTED token is + // never broadened with the Users/Authenticated Users SIDs (see + // createWindowsRestrictedTokenFromBase): the write grant those groups + // hold on C:\Users\Public must not be reachable through the restricted + // SID check at all. Pin that a write there fails: an independent + // shared-writable directory outside every carved-out system path + // (ProgramData, Windows\Temp), and outside every workspace write root. publicDir := os.Getenv("PUBLIC") if publicDir == "" { t.Skip("PUBLIC is not set; cannot probe C:\\Users\\Public write jail") diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index 3a82999e8..9bb5cb192 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -88,14 +88,15 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er // Deny write to shared Windows-writable directories (C:\, C:\ProgramData, // C:\Windows\Temp, C:\Users\Public) to prevent write-jail escape via the - // added Users and Authenticated Users SIDs. Only the elevated tier - // (WindowsSandboxLevelRestrictedToken, applied by `zero sandbox setup` - // running as Administrator) reaches here with those SIDs on the token in - // the first place — see createWindowsRestrictedTokenFromBase — and only - // that tier has the WRITE_DAC needed to edit these system-owned DACLs. - // The unelevated tier keeps the narrower (pre-widening) restricting-SID - // set and never needs these entries. - if config.SandboxLevel == WindowsSandboxLevelRestrictedToken { + // added Users and Authenticated Users SIDs. Only DenyRead profiles on the + // elevated tier (WindowsSandboxLevelRestrictedToken, applied by `zero + // sandbox setup` running as Administrator) carry those SIDs at all: a + // WRITE_RESTRICTED token reads with its normal identity and is never + // broadened, so the default profile needs no shared entries, and only + // the elevated tier has the WRITE_DAC needed to edit these system-owned + // DACLs. The unelevated tier keeps the narrower restricting-SID set and + // never needs these entries either. + if config.SandboxLevel == WindowsSandboxLevelRestrictedToken && len(config.PermissionProfile.FileSystem.DenyRead) > 0 { // Resolved from trusted Win32 APIs, not from the // SystemDrive/SystemRoot/ProgramData/PUBLIC environment variables: // see resolveWindowsSharedDenyPaths for why trusting the environment @@ -112,15 +113,19 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er publicDir, } + // The deny ACEs name only the stable read-only capability SID, which + // every broadened token carries (see the runner): a deny ACE blocks + // when it matches ANY SID on the token, so one shared identity is + // sufficient, and it keeps these machine-wide DACLs at a constant + // four entries total. Naming the per-workspace/per-root capability + // SIDs here instead would append four permanent deny ACEs for every + // distinct project ever sandboxed on the machine, growing C:\, + // ProgramData, Windows\Temp, and Public's DACLs without bound. caps, err := LoadOrCreateWindowsCapabilitySIDs(config.SandboxHome) if err != nil { return WindowsACLPlan{}, err } - var allSIDs []string - for _, cap := range writeCapabilities { - allSIDs = append(allSIDs, cap.SID) - } - allSIDs = append(allSIDs, caps.ReadOnly) + denySID := caps.ReadOnly for _, denyPath := range sharedDenyPaths { if windowsPathEqualsAnyRoot(denyPath, writeCapabilities) { @@ -142,14 +147,12 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er // on each one is sufficient: it blocks the denied SIDs from // writing (including creating new children) directly under that // path without ever touching any descendant's own ACL. - for _, sid := range allSIDs { - entries = append(entries, WindowsACLEntry{ - Action: WindowsACLDenyWrite, - Path: denyPath, - Capability: sid, - NoInherit: true, - }) - } + entries = append(entries, WindowsACLEntry{ + Action: WindowsACLDenyWrite, + Path: denyPath, + Capability: denySID, + NoInherit: true, + }) } } diff --git a/internal/sandbox/windows_acl_test.go b/internal/sandbox/windows_acl_test.go index 2d2e2ae96..a18b6b641 100644 --- a/internal/sandbox/windows_acl_test.go +++ b/internal/sandbox/windows_acl_test.go @@ -60,11 +60,52 @@ func TestBuildWindowsACLPlanForWorkspaceWriteProfile(t *testing.T) { } systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest(t) - for _, sid := range []string{workspaceSID, cacheSID, caps.ReadOnly} { - assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, systemDrive+`\`, sid, false, true) - assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, programData, sid, false, true) - assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, systemRoot+`\Temp`, sid, false, true) - assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, publicDir, sid, false, true) + // The shared system-path denies name only the one stable read-only SID + // that every broadened token carries. Naming the per-workspace/per-root + // SIDs here instead would append four permanent deny ACEs to these + // machine-wide DACLs for every distinct project ever sandboxed. + for _, path := range []string{systemDrive + `\`, programData, systemRoot + `\Temp`, publicDir} { + assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, path, caps.ReadOnly, false, true) + for _, entry := range plan.Entries { + if entry.Action != WindowsACLDenyWrite || windowsCapabilityPathKey(entry.Path) != windowsCapabilityPathKey(path) { + continue + } + if entry.Capability == workspaceSID || entry.Capability == cacheSID { + t.Fatalf("shared deny path %q names per-root SID %q; machine DACLs must only carry the stable read-only SID", path, entry.Capability) + } + } + } +} + +// TestBuildWindowsACLPlanOmitsSharedDenyPathsWithoutDenyRead pins the +// scoping of the Users/Authenticated Users broadening: profiles without +// DenyRead run under a WRITE_RESTRICTED token, which reads with its normal +// identity and is never broadened, so their plans must not touch the shared +// system-path DACLs at all. +func TestBuildWindowsACLPlanOmitsSharedDenyPathsWithoutDenyRead(t *testing.T) { + home := t.TempDir() + plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ + SandboxHome: home, + WorkspaceRoots: []string{`C:\workspace`}, + SandboxLevel: WindowsSandboxLevelRestrictedToken, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: `C:\workspace`}}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + }) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest(t) + for _, path := range []string{systemDrive + `\`, programData, systemRoot + `\Temp`, publicDir} { + for _, entry := range plan.Entries { + if entry.Action == WindowsACLDenyWrite && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(path) { + t.Fatalf("plan without DenyRead touches shared path %q: %#v", path, entry) + } + } } } diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 141960605..96e5be58f 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -75,10 +75,30 @@ 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 - // Only the elevated restricted-token tier can enforce the DenyWrite - // mitigation BuildWindowsACLPlan adds for the broadened read SIDs (it - // requires Administrator rights); see createWindowsRestrictedTokenFromBase. - broadenReadSIDs := config.SandboxLevel == WindowsSandboxLevelRestrictedToken + // Broadening with Users/Authenticated Users is only useful on the fully + // restricted token, where READS also require a restricted-SID match and + // system paths like Program Files and System32 grant those groups rather + // than Everyone. A WRITE_RESTRICTED token already performs reads with the + // normal token identity, so broadening there cannot improve reads at all; + // it would only let Users/Authenticated Users write grants pass the + // restricted-SID write check and weaken the default write jail for no + // benefit. It also needs the elevated tier: only that tier can enforce + // the shared-directory DenyWrite mitigation BuildWindowsACLPlan adds for + // the broadened SIDs (it requires Administrator rights); see + // createWindowsRestrictedTokenFromBase. + broadenReadSIDs := config.SandboxLevel == WindowsSandboxLevelRestrictedToken && !writeRestricted + if broadenReadSIDs { + // The shared-directory DenyWrite mitigation names the one stable + // read-only capability SID rather than the per-workspace SIDs (see + // BuildWindowsACLPlan), so every broadened token must carry it for + // those deny ACEs to bind. + caps, err := LoadOrCreateWindowsCapabilitySIDs(config.SandboxHome) + if err != nil { + fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) + return 1 + } + tokenSIDs = append(tokenSIDs, caps.ReadOnly) + } token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs, writeRestricted, broadenReadSIDs) if err != nil { fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) diff --git a/internal/sandbox/windows_token_windows.go b/internal/sandbox/windows_token_windows.go index dfae6d223..903f3d002 100644 --- a/internal/sandbox/windows_token_windows.go +++ b/internal/sandbox/windows_token_windows.go @@ -87,14 +87,19 @@ func createWindowsRestrictedTokenForCapabilitySIDs(capabilitySIDStrings []string // broadenReadSIDs is set, it also restricts to WinBuiltinUsersSid and // WinAuthenticatedUserSid so the sandboxed process can read/execute binaries // under paths like C:\Program Files or C:\Windows whose ACLs grant -// Users/Authenticated Users rather than Everyone. Because the restricting-SID -// check applies to writes as well as reads, this also grants write wherever -// those groups already have it — BuildWindowsACLPlan mitigates that by adding -// DenyWrite ACEs to the known shared Users/Authenticated-Users-writable -// directories, but it can only do so with Administrator rights (see -// WindowsSandboxLevelRestrictedToken). broadenReadSIDs must therefore stay -// false for WindowsSandboxLevelUnelevated, which cannot enforce that -// mitigation: it keeps the original (narrower) read scope instead of +// Users/Authenticated Users rather than Everyone. That only matters on the +// fully restricted token (writeRestricted=false), where reads also require a +// restricted-SID match; a WRITE_RESTRICTED token reads with its normal +// identity, so broadening it would gain nothing for reads while letting the +// groups' write grants pass the restricted-SID write check. Because the +// restricting-SID check applies to writes as well as reads, broadening also +// grants write wherever those groups already have it — BuildWindowsACLPlan +// mitigates that by adding DenyWrite ACEs to the known shared +// Users/Authenticated-Users-writable directories, but it can only do so +// with Administrator rights (see WindowsSandboxLevelRestrictedToken). +// broadenReadSIDs must therefore stay false both when writeRestricted is set +// and for WindowsSandboxLevelUnelevated, which cannot enforce that +// mitigation: those keep the original (narrower) SID scope instead of // widening the write jail with nothing to close the gap. func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []windowsLocalSID, writeRestricted, broadenReadSIDs bool) (windows.Token, error) { logonSID, err := copyWindowsLogonSID(base) From 39aa0f449cc63f1127ed25a3a989926414fbbe2e Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:49:16 -0400 Subject: [PATCH 07/26] fix(sandbox): include NoInherit in the ACL entry dedupe key A direct-only deny and an inheritable deny on the same path and SID are different ACL shapes; collapsing them could silently promote a deliberately non-inherited shared-path deny into an inheritable one that SetNamedSecurityInfo would propagate across a huge existing subtree. --- internal/sandbox/windows_acl.go | 6 +++++- internal/sandbox/windows_acl_test.go | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index 9bb5cb192..7d1ada8b7 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -277,7 +277,11 @@ func dedupeWindowsACLEntries(entries []WindowsACLEntry) []WindowsACLEntry { if entry.Action == "" || strings.TrimSpace(entry.Path) == "" || strings.TrimSpace(entry.Capability) == "" { continue } - key := string(entry.Action) + "\x00" + windowsCapabilityPathKey(entry.Path) + "\x00" + strings.ToLower(entry.Capability) + // NoInherit is part of the identity: a direct-only deny and an + // inheritable one on the same path/SID are different ACL shapes, and + // collapsing them could silently promote a deliberately non-inherited + // shared-path deny into an inheritable one (or vice versa). + key := string(entry.Action) + "\x00" + windowsCapabilityPathKey(entry.Path) + "\x00" + strings.ToLower(entry.Capability) + "\x00" + fmt.Sprintf("%t", entry.NoInherit) if _, ok := seen[key]; ok { continue } diff --git a/internal/sandbox/windows_acl_test.go b/internal/sandbox/windows_acl_test.go index a18b6b641..5666b15b0 100644 --- a/internal/sandbox/windows_acl_test.go +++ b/internal/sandbox/windows_acl_test.go @@ -254,3 +254,24 @@ func windowsPathListContains(paths []string, want string) bool { } return false } + +// TestDedupeWindowsACLEntriesKeepsInheritanceVariants pins NoInherit as part +// of the entry identity: a direct-only deny and an inheritable deny on the +// same path and SID are different ACL shapes, and collapsing them could +// silently promote a deliberately non-inherited shared-path deny into an +// inheritable one that SetNamedSecurityInfo would propagate across a huge +// existing subtree. +func TestDedupeWindowsACLEntriesKeepsInheritanceVariants(t *testing.T) { + entries := []WindowsACLEntry{ + {Action: WindowsACLDenyWrite, Path: `C:\shared`, Capability: "S-1-5-21-1", NoInherit: true}, + {Action: WindowsACLDenyWrite, Path: `C:\shared`, Capability: "S-1-5-21-1"}, + {Action: WindowsACLDenyWrite, Path: `C:\shared`, Capability: "S-1-5-21-1", NoInherit: true}, + } + out := dedupeWindowsACLEntries(entries) + if len(out) != 2 { + t.Fatalf("dedupe = %#v, want the NoInherit and inheritable variants kept distinct", out) + } + if !out[0].NoInherit || out[1].NoInherit { + t.Fatalf("dedupe order/shape = %#v, want first NoInherit then inheritable", out) + } +} From 7c03fbd584445cead5a3faf9b0c5a249a92c1dd6 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:55:24 -0400 Subject: [PATCH 08/26] fix(sandbox): deny existing writable descendants of shared deny roots The non-inheriting DenyWrite ACEs on the four shared roots (C:\, ProgramData, Windows\Temp, Public) protect only those root objects. A Windows access check for a pre-existing child never evaluates a non-inherited ACE on its parent, so an existing directory under one of those roots that independently grants Users or Authenticated Users write stays writable once the fully restricted (DenyRead) token is broadened with those two groups. That is a write outside every configured write root. Enumerate the existing writable descendants of exactly those four roots at apply time and place a direct, non-inheriting deny on each one found, naming the same stable read-only capability SID the root deny uses. The scan is bounded and targeted, not a blanket recursive inheritable deny (the pathology the earlier commits rejected): it always descends a small baseline depth, below that descends only into directories that are themselves writable by the broad groups, prunes reparse points and unreadable system trees, and stops at hard depth and directory-count caps. Configured write roots (and their subtrees) are excluded so legitimate workspace writes are never jailed. The ScanDescendants marker is not serialized into the hashed plan: the concrete descendant set is live-filesystem state, so it is derived deterministically from the same inputs on both sides and enforced only as a windows-only apply-time side effect. Add real-ACL regression tests (runnable unprivileged on test-owned temp trees) covering the DACL write probe, the enumeration of nested writable descendants with write-root exclusion, and the end-to-end apply and rollback of a descendant deny. --- internal/sandbox/windows_acl.go | 24 ++ internal/sandbox/windows_acl_apply_windows.go | 43 +++ .../windows_acl_descendants_windows.go | 245 ++++++++++++++++++ .../windows_acl_descendants_windows_test.go | 190 ++++++++++++++ internal/sandbox/windows_acl_test.go | 49 ++++ 5 files changed, 551 insertions(+) create mode 100644 internal/sandbox/windows_acl_descendants_windows.go create mode 100644 internal/sandbox/windows_acl_descendants_windows_test.go diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index 7d1ada8b7..d026ff505 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -28,6 +28,20 @@ type WindowsACLEntry struct { // why that is unsafe on broad system roots. NoInherit bool `json:"noInherit,omitempty"` Materialize bool `json:"materialize,omitempty"` + // ScanDescendants marks a shared-root DenyWrite entry whose EXISTING + // writable descendants must ALSO be denied, one direct (non-inheriting) + // deny per writable descendant, at apply time. A non-inherited deny on the + // root object alone does not cover a pre-existing child that independently + // grants Users/Authenticated Users write, because a Windows access check + // for that child never consults a non-inherited ACE on its parent. This is + // deliberately NOT serialized (json:"-"): the concrete descendant set is + // live-filesystem state that differs between the setup process and a later + // command run, so folding it into the hashed plan would make + // ValidateWindowsSandboxSetupMarker non-deterministic. The flag itself is + // derived deterministically from the same inputs on both sides, and the + // descendant enumeration/denies happen as an apply-time side effect in + // applyWindowsACLPlan (windows-only), never in the cross-platform plan hash. + ScanDescendants bool `json:"-"` } type WindowsACLPlan struct { @@ -152,6 +166,16 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er Path: denyPath, Capability: denySID, NoInherit: true, + // A non-inherited deny on this root object blocks new writes + // directly under it, but NOT writes to a pre-existing child + // that independently grants Users/Authenticated Users write + // (the access check for that child never evaluates a + // non-inherited parent ACE). applyWindowsACLPlan therefore + // enumerates this root's existing writable descendants and + // applies a direct, non-inheriting deny to each, a bounded, + // targeted scan that never rewrites the ACL of any descendant + // that is not itself already writable by those broad groups. + ScanDescendants: true, }) } } diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index 103dc9ebe..e2e2e65f3 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -28,6 +28,7 @@ type windowsACLSnapshot struct { func applyWindowsACLPlan(plan WindowsACLPlan) (func() error, error) { groups := groupWindowsACLPlanByPath(plan) + writeRoots := windowsPlanAllowWriteRoots(plan) snapshots := make([]windowsACLSnapshot, 0, len(groups)) for _, group := range groups { snapshot, applied, err := applyWindowsACLPathGroup(group) @@ -41,12 +42,54 @@ func applyWindowsACLPlan(plan WindowsACLPlan) (func() error, error) { if applied { snapshots = append(snapshots, snapshot) } + // A shared-root deny only protects the root object itself; its existing + // writable descendants each need their own direct deny (see + // windows_acl_descendants_windows.go). Only scan once the root deny + // actually applied (applied == true means the root exists). + if denySID, ok := windowsGroupScanDescendantsSID(group); ok && applied { + descendantSnapshots, err := applyWindowsSharedDescendantDenies(group.Path, denySID, writeRoots) + snapshots = append(snapshots, descendantSnapshots...) + if err != nil { + rollbackErr := rollbackWindowsACLSnapshots(snapshots) + if rollbackErr != nil { + return nil, fmt.Errorf("%w; rollback failed: %v", err, rollbackErr) + } + return nil, err + } + } } return func() error { return rollbackWindowsACLSnapshots(snapshots) }, nil } +// windowsPlanAllowWriteRoots collects the plan's allow-write root paths so the +// descendant scan can exclude a configured write root (and anything under it): +// a write root that happens to live under one of the shared roots must never be +// jailed by a compensating deny. +func windowsPlanAllowWriteRoots(plan WindowsACLPlan) []string { + var roots []string + for _, entry := range plan.Entries { + if entry.Action == WindowsACLAllowWrite { + if path := strings.TrimSpace(entry.Path); path != "" { + roots = append(roots, path) + } + } + } + return roots +} + +// windowsGroupScanDescendantsSID returns the deny SID of a group's shared-root +// DenyWrite entry when that entry requests descendant scanning. +func windowsGroupScanDescendantsSID(group windowsACLPathGroup) (string, bool) { + for _, entry := range group.Entries { + if entry.Action == WindowsACLDenyWrite && entry.ScanDescendants && strings.TrimSpace(entry.Capability) != "" { + return entry.Capability, true + } + } + return "", false +} + func groupWindowsACLPlanByPath(plan WindowsACLPlan) []windowsACLPathGroup { byPath := map[string]*windowsACLPathGroup{} for _, entry := range dedupeWindowsACLEntries(plan.Entries) { diff --git a/internal/sandbox/windows_acl_descendants_windows.go b/internal/sandbox/windows_acl_descendants_windows.go new file mode 100644 index 000000000..989390a79 --- /dev/null +++ b/internal/sandbox/windows_acl_descendants_windows.go @@ -0,0 +1,245 @@ +//go:build windows + +package sandbox + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +// The shared-root compensating deny (windows_acl.go) puts a direct, +// non-inheriting DenyWrite on each of C:\, %ProgramData%, %SystemRoot%\Temp, +// and C:\Users\Public. That blocks new writes directly under those objects, but +// a Windows access check for an EXISTING child never evaluates a non-inherited +// ACE on the child's parent, so a pre-existing descendant that independently +// grants BUILTIN\Users or Authenticated Users write stays writable once the +// elevated fully-restricted (DenyRead) token is broadened with those two groups, +// a write outside every configured write root. This file enumerates those +// existing writable descendants and denies each one directly. +// +// The scan is deliberately bounded and targeted, NOT a blanket recursive walk: +// stamping an inheritable deny (or rewriting every descendant's ACL) across the +// system drive is the exact slow/brittle/ACL-polluting pathology the +// inheritable-deny approach was rejected for. Instead the traversal only ever +// WRITES a deny to a descendant it has confirmed is already writable by those +// broad groups, and it prunes the enormous non-writable system trees +// (C:\Windows, C:\Program Files) so cost stays bounded: +// +// - It always descends the first windowsDescendantScanBaselineDepth levels, +// so a shallow, freshly installed writable directory is caught even when its +// parent is not itself writable. +// - Below the baseline it descends ONLY into a directory that is itself +// writable by the broad groups. A writable subtree is the real escape +// surface and is small in practice, while a non-writable directory cannot +// have been reached by such a write and so is pruned. +// - windowsDescendantScanMaxDepth and windowsDescendantScanMaxDirs are hard +// safety caps against a pathological deep or very broad writable tree; if +// either is hit the scan stops (leaving deeper writable descendants +// unscanned, a bounded residual gap, still strictly smaller than the +// root-object-only enforcement it replaces). +// +// Reparse points (junctions/symlinks) are skipped entirely: their target lives +// outside this subtree, so denying or descending them would touch unrelated +// objects and risk traversal loops. +const ( + windowsDescendantScanBaselineDepth = 2 + windowsDescendantScanMaxDepth = 24 + windowsDescendantScanMaxDirs = 8192 +) + +// windowsBroadenedWriteProbeMask is the set of access-mask bits that let a +// principal create, delete, or modify content in (or the security of) a +// directory, i.e. the bits that make a directory a usable write-jail escape. +// FILE_WRITE_DATA is FILE_ADD_FILE and FILE_APPEND_DATA is FILE_ADD_SUBDIRECTORY +// for a directory object. +const windowsBroadenedWriteProbeMask windows.ACCESS_MASK = windows.FILE_WRITE_DATA | + windows.FILE_APPEND_DATA | + windowsFileDeleteChild | + windows.DELETE | + windows.WRITE_DAC | + windows.WRITE_OWNER | + windows.GENERIC_WRITE | + windows.GENERIC_ALL + +// applyWindowsSharedDescendantDenies enumerates the existing writable +// descendants of a shared root and applies a direct, non-inheriting DenyWrite +// (naming denySID, the same stable read-only capability SID the root deny uses) +// to each. It returns every snapshot it applied (including on error) so the +// caller can roll the whole apply back. A descendant it identified as writable +// but could not deny is a hole it cannot close, so that failure is returned +// (fail closed); a descendant whose parent it merely could not list or whose +// DACL it could not read is treated as locked-down and skipped in the +// enumeration itself (see windowsEnumerateWritableDescendants). +func applyWindowsSharedDescendantDenies(root, denySID string, writeRoots []string) ([]windowsACLSnapshot, error) { + descendants, err := windowsEnumerateWritableDescendants(root, writeRoots) + if err != nil { + return nil, fmt.Errorf("enumerate writable descendants of %s: %w", root, err) + } + snapshots := make([]windowsACLSnapshot, 0, len(descendants)) + for _, dir := range descendants { + snapshot, applied, err := applyWindowsACLPathGroup(windowsACLPathGroup{ + Path: dir, + Entries: []WindowsACLEntry{{ + Action: WindowsACLDenyWrite, + Path: dir, + Capability: denySID, + NoInherit: true, + }}, + }) + if err != nil { + return snapshots, fmt.Errorf("deny writable descendant %s: %w", dir, err) + } + if applied { + snapshots = append(snapshots, snapshot) + } + } + return snapshots, nil +} + +// windowsEnumerateWritableDescendants returns the existing directories below +// root that grant BUILTIN\Users or Authenticated Users write, excluding any +// configured write root (and anything under it) so legitimate workspace writes +// are never jailed. See the package-level comment above for the traversal +// bounds and their rationale. +func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]string, error) { + if windowsCapabilityPathKey(root) == "" { + return nil, nil + } + excluded := make([]string, 0, len(writeRoots)) + for _, writeRoot := range writeRoots { + if key := windowsCapabilityPathKey(writeRoot); key != "" { + excluded = append(excluded, key) + } + } + isExcluded := func(key string) bool { + for _, prefix := range excluded { + if key == prefix || strings.HasPrefix(key, prefix+`\`) { + return true + } + } + return false + } + + type node struct { + path string + depth int + } + var out []string + visited := 0 + queue := []node{{path: root, depth: 0}} + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + entries, err := os.ReadDir(current.path) + if err != nil { + // A directory the elevated setup cannot even list is locked down + // (SYSTEM-owned); the broad groups cannot write there, so skipping + // it is safe. Traversal is best-effort so a full system-drive walk + // does not abort setup on the normal un-listable system dirs it must + // step over. + continue + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + child := filepath.Join(current.path, entry.Name()) + childKey := windowsCapabilityPathKey(child) + if isExcluded(childKey) { + continue + } + if windowsPathIsReparsePoint(child) { + continue + } + if visited >= windowsDescendantScanMaxDirs { + return out, nil + } + visited++ + writable, err := windowsDirGrantsBroadenedWrite(child) + if err != nil { + // Cannot read the child's DACL: same reasoning as an un-listable + // directory: locked down, not an escape target. Skip. + continue + } + if writable { + out = append(out, child) + } + childDepth := current.depth + 1 + if childDepth >= windowsDescendantScanMaxDepth { + continue + } + if childDepth < windowsDescendantScanBaselineDepth || writable { + queue = append(queue, node{path: child, depth: childDepth}) + } + } + } + return out, nil +} + +// windowsDirGrantsBroadenedWrite reports whether path's effective DACL lets +// BUILTIN\Users or Authenticated Users write. It walks the DACL (which, as +// returned by GetNamedSecurityInfo, already contains inherited ACEs) in order, +// honoring a deny ACE that precedes an allow for the same bits, the canonical +// evaluation. A NULL DACL grants everyone full access and is treated as +// writable. +func windowsDirGrantsBroadenedWrite(path string) (bool, error) { + // GetNamedSecurityInfo returns a self-relative descriptor copied onto the Go + // heap (it LocalFrees the Win32 allocation itself), so it must NOT be + // LocalFree'd here: doing so frees Go-managed memory and corrupts the heap. + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + return false, err + } + dacl, _, err := sd.DACL() + if err != nil { + return false, err + } + if dacl == nil { + return true, nil + } + var deniedWrite windows.ACCESS_MASK + for index := uint16(0); index < dacl.AceCount; index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, uint32(index), &ace); err != nil { + return false, fmt.Errorf("read ACE %d of %s: %w", index, path, err) + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if !sid.IsWellKnown(windows.WinBuiltinUsersSid) && !sid.IsWellKnown(windows.WinAuthenticatedUserSid) { + continue + } + writeBits := ace.Mask & windowsBroadenedWriteProbeMask + if writeBits == 0 { + continue + } + switch ace.Header.AceType { + case windows.ACCESS_DENIED_ACE_TYPE: + deniedWrite |= writeBits + case windows.ACCESS_ALLOWED_ACE_TYPE: + if writeBits&^deniedWrite != 0 { + return true, nil + } + } + } + return false, nil +} + +// windowsPathIsReparsePoint reports whether path carries the reparse-point +// attribute (a junction, symlink, or mount point). Any error resolving the +// attributes is reported as "not a reparse point" so the caller falls through +// to its own DACL read, which surfaces a real access problem there instead. +func windowsPathIsReparsePoint(path string) bool { + ptr, err := windows.UTF16PtrFromString(path) + if err != nil { + return false + } + attrs, err := windows.GetFileAttributes(ptr) + if err != nil { + return false + } + return attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 +} diff --git a/internal/sandbox/windows_acl_descendants_windows_test.go b/internal/sandbox/windows_acl_descendants_windows_test.go new file mode 100644 index 000000000..2473cb298 --- /dev/null +++ b/internal/sandbox/windows_acl_descendants_windows_test.go @@ -0,0 +1,190 @@ +//go:build windows + +package sandbox + +import ( + "path/filepath" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// dirDeniesSID reports whether path's DACL carries a deny ACE naming the given +// string SID. It reads the same way the descendant scan applies denies, so a +// test can confirm the compensating deny actually landed (and, after rollback, +// is gone) using the real Win32 ACL APIs on a test-owned temp tree. +func dirDeniesSID(t *testing.T, path, wantSID string) bool { + t.Helper() + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("GetNamedSecurityInfo %s: %v", path, err) + } + dacl, _, err := sd.DACL() + if err != nil { + t.Fatalf("DACL %s: %v", path, err) + } + if dacl == nil { + return false + } + for index := uint16(0); index < dacl.AceCount; index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, uint32(index), &ace); err != nil { + t.Fatalf("GetAce %d of %s: %v", index, path, err) + } + if ace.Header.AceType != windows.ACCESS_DENIED_ACE_TYPE { + continue + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if sid.String() == wantSID { + return true + } + } + return false +} + +// grantUsersWrite adds a direct (non-inheriting) allow-write ACE for +// BUILTIN\Users to path's DACL using the real Win32 ACL APIs. The test process +// owns the t.TempDir() tree, so this needs no elevation. +func grantUsersWrite(t *testing.T, path string) { + t.Helper() + usersSID, err := windows.CreateWellKnownSid(windows.WinBuiltinUsersSid) + if err != nil { + t.Fatalf("CreateWellKnownSid(Users): %v", err) + } + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("GetNamedSecurityInfo %s: %v", path, err) + } + oldDACL, _, err := sd.DACL() + if err != nil { + t.Fatalf("DACL %s: %v", path, err) + } + newDACL, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{ + AccessPermissions: windows.FILE_GENERIC_WRITE, + AccessMode: windows.GRANT_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(usersSID), + }, + }}, oldDACL) + if err != nil { + t.Fatalf("ACLFromEntries %s: %v", path, err) + } + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, newDACL, nil); err != nil { + t.Fatalf("SetNamedSecurityInfo %s: %v", path, err) + } +} + +// TestWindowsDirGrantsBroadenedWriteDetectsUsersWrite pins the DACL probe the +// descendant scan relies on: a directory whose DACL grants BUILTIN\Users write +// is reported writable; one that does not is reported not writable. +func TestWindowsDirGrantsBroadenedWriteDetectsUsersWrite(t *testing.T) { + root := t.TempDir() + writable := mkdir(t, filepath.Join(root, "writable")) + grantUsersWrite(t, writable) + plain := mkdir(t, filepath.Join(root, "plain")) + + got, err := windowsDirGrantsBroadenedWrite(writable) + if err != nil { + t.Fatalf("windowsDirGrantsBroadenedWrite(writable): %v", err) + } + if !got { + t.Fatalf("windowsDirGrantsBroadenedWrite(writable) = false, want true") + } + + plainWritable, err := windowsDirGrantsBroadenedWrite(plain) + if err != nil { + t.Fatalf("windowsDirGrantsBroadenedWrite(plain): %v", err) + } + if plainWritable { + t.Skip("test temp tree grants BUILTIN\\Users write by inheritance; cannot exercise the negative case here") + } +} + +// TestWindowsEnumerateWritableDescendantsFindsExistingWritableChildren is the +// real-Windows regression for the write-jail gap the reviewer flagged: an +// existing writable descendant of a shared root (including one nested under +// another writable directory) must be discovered so it can be denied directly, +// and a configured write root must be excluded so legitimate workspace writes +// are never jailed. +func TestWindowsEnumerateWritableDescendantsFindsExistingWritableChildren(t *testing.T) { + root := t.TempDir() + outer := mkdir(t, filepath.Join(root, "outer")) + grantUsersWrite(t, outer) + inner := mkdir(t, filepath.Join(outer, "inner")) + grantUsersWrite(t, inner) + plain := mkdir(t, filepath.Join(root, "plain")) + workspace := mkdir(t, filepath.Join(root, "workspace")) + grantUsersWrite(t, workspace) + + found, err := windowsEnumerateWritableDescendants(root, nil) + if err != nil { + t.Fatalf("windowsEnumerateWritableDescendants: %v", err) + } + if !windowsPathListContains(found, outer) { + t.Fatalf("enumeration = %#v, want it to include writable child %q", found, outer) + } + if !windowsPathListContains(found, inner) { + t.Fatalf("enumeration = %#v, want it to include nested writable descendant %q", found, inner) + } + + plainWritable, err := windowsDirGrantsBroadenedWrite(plain) + if err != nil { + t.Fatalf("windowsDirGrantsBroadenedWrite(plain): %v", err) + } + if !plainWritable && windowsPathListContains(found, plain) { + t.Fatalf("enumeration = %#v, want it to exclude non-writable child %q", found, plain) + } + + // Excluding the workspace write root (and its subtree) must drop it from the + // result even though it grants Users write. + excluded, err := windowsEnumerateWritableDescendants(root, []string{workspace}) + if err != nil { + t.Fatalf("windowsEnumerateWritableDescendants(exclude): %v", err) + } + if windowsPathListContains(excluded, workspace) { + t.Fatalf("enumeration = %#v, want it to exclude the configured write root %q", excluded, workspace) + } + if !windowsPathListContains(excluded, outer) { + t.Fatalf("enumeration = %#v, want it to still include %q when a different path is excluded", excluded, outer) + } +} + +// TestApplyWindowsSharedDescendantDeniesAppliesAndRollsBack proves the +// enforcement half of the fix end to end on real ACLs: a writable descendant of +// a shared root gets a direct deny ACE for the read-only capability SID (the SID +// every broadened token carries), and the returned rollback restores the DACL. +// This runs unprivileged because it operates only on the test-owned temp tree. +func TestApplyWindowsSharedDescendantDeniesAppliesAndRollsBack(t *testing.T) { + caps, err := LoadOrCreateWindowsCapabilitySIDs(t.TempDir()) + if err != nil { + t.Fatalf("LoadOrCreateWindowsCapabilitySIDs: %v", err) + } + root := t.TempDir() + writable := mkdir(t, filepath.Join(root, "writable")) + grantUsersWrite(t, writable) + + if dirDeniesSID(t, writable, caps.ReadOnly) { + t.Fatalf("descendant already denies %q before apply", caps.ReadOnly) + } + snapshots, err := applyWindowsSharedDescendantDenies(root, caps.ReadOnly, nil) + if err != nil { + t.Fatalf("applyWindowsSharedDescendantDenies: %v", err) + } + if len(snapshots) == 0 { + t.Fatalf("apply returned no snapshots; the writable descendant was never denied") + } + if !dirDeniesSID(t, writable, caps.ReadOnly) { + t.Fatalf("descendant %q does not deny %q after apply", writable, caps.ReadOnly) + } + + if err := rollbackWindowsACLSnapshots(snapshots); err != nil { + t.Fatalf("rollbackWindowsACLSnapshots: %v", err) + } + if dirDeniesSID(t, writable, caps.ReadOnly) { + t.Fatalf("descendant %q still denies %q after rollback", writable, caps.ReadOnly) + } +} diff --git a/internal/sandbox/windows_acl_test.go b/internal/sandbox/windows_acl_test.go index 5666b15b0..abc0c341e 100644 --- a/internal/sandbox/windows_acl_test.go +++ b/internal/sandbox/windows_acl_test.go @@ -189,6 +189,55 @@ func TestBuildWindowsACLPlanUsesReadOnlySIDWithoutWriteRoots(t *testing.T) { assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, publicDir, caps.ReadOnly, false, true) } +// TestBuildWindowsACLPlanMarksSharedDenyPathsForDescendantScan pins that the +// four shared-root DenyWrite entries (and ONLY those) request the apply-time +// existing-writable-descendant scan. That scan is the enforcement that keeps a +// pre-existing writable child of one of those roots (which a non-inherited deny +// on the root object alone does not cover) from becoming a write-jail escape +// once the fully restricted DenyRead token is broadened with the Users and +// Authenticated Users SIDs. +func TestBuildWindowsACLPlanMarksSharedDenyPathsForDescendantScan(t *testing.T) { + home := t.TempDir() + plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ + SandboxHome: home, + WorkspaceRoots: []string{`C:\workspace`}, + SandboxLevel: WindowsSandboxLevelRestrictedToken, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: `C:\workspace`}}, + DenyRead: []string{`C:\workspace\secret-read`}, + DenyWrite: []string{`C:\workspace\secret-write`}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + }) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest(t) + sharedKeys := map[string]bool{} + for _, path := range []string{systemDrive + `\`, programData, systemRoot + `\Temp`, publicDir} { + sharedKeys[windowsCapabilityPathKey(path)] = true + } + for _, path := range []string{systemDrive + `\`, programData, systemRoot + `\Temp`, publicDir} { + found := false + for _, entry := range plan.Entries { + if entry.Action == WindowsACLDenyWrite && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(path) && entry.ScanDescendants { + found = true + } + } + if !found { + t.Fatalf("shared deny path %q is not marked ScanDescendants; existing writable descendants would stay unenforced", path) + } + } + for _, entry := range plan.Entries { + if entry.ScanDescendants && !sharedKeys[windowsCapabilityPathKey(entry.Path)] { + t.Fatalf("non-shared entry %#v requests descendant scan; only the four shared roots should", entry) + } + } +} + func TestBuildWindowsACLPlanRejectsUnrestrictedProfiles(t *testing.T) { _, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ SandboxHome: t.TempDir(), From 48d4704680d9577844514c755813704a63879a39 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:15:16 -0400 Subject: [PATCH 09/26] fix(sandbox): correct object-ACE parsing, scan files, and skip write-root-nested shared paths Three fixes to the shared-root descendant scan/deny that compensates for broadening the Windows restricted token with Users/Authenticated Users: - windowsDirGrantsBroadenedWrite read every ACE as a plain ACCESS_ALLOWED_ACE, but an object ACE (ACCESS_ALLOWED_OBJECT_ACE / ACCESS_DENIED_OBJECT_ACE) inserts a Flags DWORD and up to two conditionally-present 16-byte GUIDs between Mask and the real SID. Reading &ace.SidStart directly for one of these misinterprets those bytes as SID bytes and silently computes the wrong trustee, missing a real Users/Authenticated Users grant hidden inside an object ACE. Add windowsAceSID to locate the SID at the correct offset per ACE type. - windowsEnumerateWritableDescendants skipped every non-directory entry, so a writable file directly under a shared root (as much an escape surface as a writable directory) was never found or denied. Files are now checked and denied like directories; only the recursion step remains directory-only. - The shared-path skip in BuildWindowsACLPlan only checked for exact equality with a configured write root, so a write root that merely CONTAINS a shared path (e.g. C:\Users configured as writable, with the shared C:\Users\Public path nested under it) still got a direct DenyWrite. That Deny would sit ahead of the write root's Allow for every broadened token and win under Windows' deny-before-allow evaluation, jailing a directory the user explicitly configured as writable. windowsPathUnderAnyRoot (renamed from windowsPathEqualsAnyRoot) now also matches nested paths. Verified via GOOS=windows/linux/darwin build+vet and a windows test binary compile (no Windows machine available to run it); the new cross-platform regression test and the windows-only ACE-offset/file- scan tests are confirmed to fail without their respective fixes. Co-Authored-By: Claude Sonnet 5 --- internal/sandbox/windows_acl.go | 20 ++++- .../windows_acl_descendants_windows.go | 78 ++++++++++++++++--- .../windows_acl_descendants_windows_test.go | 76 ++++++++++++++++++ internal/sandbox/windows_acl_test.go | 56 +++++++++++++ 4 files changed, 216 insertions(+), 14 deletions(-) diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index d026ff505..7aed94fcd 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -142,8 +142,8 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er denySID := caps.ReadOnly for _, denyPath := range sharedDenyPaths { - if windowsPathEqualsAnyRoot(denyPath, writeCapabilities) { - continue // Do not deny write if it is exactly an allowed write root + if windowsPathUnderAnyRoot(denyPath, writeCapabilities) { + continue // Do not deny write if it IS or is nested under an allowed write root } // NoInherit: these four shared paths must NOT carry an inheritable // ACE. SetNamedSecurityInfo automatically propagates any @@ -183,13 +183,25 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er return WindowsACLPlan{Entries: dedupeWindowsACLEntries(entries)}, nil } -func windowsPathEqualsAnyRoot(path string, capabilities []windowsWriteRootCapability) bool { +// windowsPathUnderAnyRoot reports whether path is exactly, or nested under, one +// of the configured write-root capabilities. A shared-path deny must skip both +// cases: denying a root that IS a write root would block the workspace outright, +// and denying a root that merely CONTAINS one (e.g. a shared path of +// C:\Users\Public when C:\Users itself is a configured write root) would place +// an explicit Deny ahead of that root's Allow for every broadened token, +// winning under Windows' deny-before-allow evaluation and jailing a directory +// the user explicitly configured as writable. +func windowsPathUnderAnyRoot(path string, capabilities []windowsWriteRootCapability) bool { key := windowsCapabilityPathKey(path) if key == "" { return false } for _, cap := range capabilities { - if windowsCapabilityPathKey(cap.Root) == key { + rootKey := windowsCapabilityPathKey(cap.Root) + if rootKey == "" { + continue + } + if key == rootKey || strings.HasPrefix(key, rootKey+`\`) { return true } } diff --git a/internal/sandbox/windows_acl_descendants_windows.go b/internal/sandbox/windows_acl_descendants_windows.go index 989390a79..30826bfc7 100644 --- a/internal/sandbox/windows_acl_descendants_windows.go +++ b/internal/sandbox/windows_acl_descendants_windows.go @@ -101,10 +101,13 @@ func applyWindowsSharedDescendantDenies(root, denySID string, writeRoots []strin return snapshots, nil } -// windowsEnumerateWritableDescendants returns the existing directories below -// root that grant BUILTIN\Users or Authenticated Users write, excluding any -// configured write root (and anything under it) so legitimate workspace writes -// are never jailed. See the package-level comment above for the traversal +// windowsEnumerateWritableDescendants returns the existing files and +// directories below root that grant BUILTIN\Users or Authenticated Users +// write, excluding any configured write root (and anything under it) so +// legitimate workspace writes are never jailed. Files are checked and denied +// just like directories — a writable file directly under a shared root is as +// much an escape surface as a writable directory — but only directories are +// descended into. See the package-level comment above for the traversal // bounds and their rationale. func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]string, error) { if windowsCapabilityPathKey(root) == "" { @@ -145,9 +148,6 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st continue } for _, entry := range entries { - if !entry.IsDir() { - continue - } child := filepath.Join(current.path, entry.Name()) childKey := windowsCapabilityPathKey(child) if isExcluded(childKey) { @@ -169,6 +169,11 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st if writable { out = append(out, child) } + if !entry.IsDir() { + // A file has no descendants to walk; it either got denied above + // or was not writable, either way there is nothing further to do. + continue + } childDepth := current.depth + 1 if childDepth >= windowsDescendantScanMaxDepth { continue @@ -181,6 +186,56 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st return out, nil } +// windowsAccessAllowedObjectAceType and windowsAccessDeniedObjectAceType are +// the AceType values for ACCESS_ALLOWED_OBJECT_ACE / ACCESS_DENIED_OBJECT_ACE +// (https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-access_allowed_object_ace). +// x/sys/windows only models the plain ACCESS_ALLOWED_ACE layout (Header, Mask, +// SidStart) and exposes just ACCESS_ALLOWED_ACE_TYPE/ACCESS_DENIED_ACE_TYPE, so +// these two are declared locally. +const ( + windowsAccessAllowedObjectAceType = 0x05 + windowsAccessDeniedObjectAceType = 0x06 +) + +// windowsAceSID locates the trustee SID within ace, an *ACCESS_ALLOWED_ACE +// pointer that GetAce hands back regardless of the ACE's true type — for +// object ACEs that pointer is only valid for reading Header/Mask, not +// SidStart. An object ACE (ACCESS_ALLOWED_OBJECT_ACE / ACCESS_DENIED_OBJECT_ACE) +// inserts a Flags DWORD and up to two conditionally-present 16-byte GUIDs +// (ObjectType, InheritedObjectType) between Mask and the real SID; naively +// reading &ace.SidStart for one of these — as if it had the plain ACE layout — +// reinterprets Flags/GUID bytes as SID bytes and silently computes the wrong +// trustee, both risking a false match and missing a real Users/Authenticated +// Users grant hidden inside an object ACE. ok is false for any other ACE type +// (audit, alarm, mandatory label, compound, ...), which does not represent a +// trustee write grant in the sense this scan cares about and is skipped +// exactly as it always has been. +func windowsAceSID(ace *windows.ACCESS_ALLOWED_ACE) (sid *windows.SID, ok bool) { + switch ace.Header.AceType { + case windows.ACCESS_ALLOWED_ACE_TYPE, windows.ACCESS_DENIED_ACE_TYPE: + return (*windows.SID)(unsafe.Pointer(&ace.SidStart)), true + case windowsAccessAllowedObjectAceType, windowsAccessDeniedObjectAceType: + // For an object ACE, the memory the Go struct calls SidStart is + // actually the ACE's Flags DWORD; the real SID sits further out, + // pushed by whichever of the two optional GUIDs Flags says are present. + // offset is plain arithmetic on a byte count, never itself derived from + // a pointer conversion, so accumulating it across statements is safe; + // only the final pointer+offset conversion below needs to happen in a + // single expression (go vet's unsafeptr rule). + flags := ace.SidStart + offset := unsafe.Sizeof(ace.SidStart) + if flags&windows.ACE_OBJECT_TYPE_PRESENT != 0 { + offset += 16 + } + if flags&windows.ACE_INHERITED_OBJECT_TYPE_PRESENT != 0 { + offset += 16 + } + return (*windows.SID)(unsafe.Pointer(uintptr(unsafe.Pointer(&ace.SidStart)) + offset)), true + default: + return nil, false + } +} + // windowsDirGrantsBroadenedWrite reports whether path's effective DACL lets // BUILTIN\Users or Authenticated Users write. It walks the DACL (which, as // returned by GetNamedSecurityInfo, already contains inherited ACEs) in order, @@ -208,7 +263,10 @@ func windowsDirGrantsBroadenedWrite(path string) (bool, error) { if err := windows.GetAce(dacl, uint32(index), &ace); err != nil { return false, fmt.Errorf("read ACE %d of %s: %w", index, path, err) } - sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + sid, ok := windowsAceSID(ace) + if !ok { + continue + } if !sid.IsWellKnown(windows.WinBuiltinUsersSid) && !sid.IsWellKnown(windows.WinAuthenticatedUserSid) { continue } @@ -217,9 +275,9 @@ func windowsDirGrantsBroadenedWrite(path string) (bool, error) { continue } switch ace.Header.AceType { - case windows.ACCESS_DENIED_ACE_TYPE: + case windows.ACCESS_DENIED_ACE_TYPE, windowsAccessDeniedObjectAceType: deniedWrite |= writeBits - case windows.ACCESS_ALLOWED_ACE_TYPE: + case windows.ACCESS_ALLOWED_ACE_TYPE, windowsAccessAllowedObjectAceType: if writeBits&^deniedWrite != 0 { return true, nil } diff --git a/internal/sandbox/windows_acl_descendants_windows_test.go b/internal/sandbox/windows_acl_descendants_windows_test.go index 2473cb298..54b420bef 100644 --- a/internal/sandbox/windows_acl_descendants_windows_test.go +++ b/internal/sandbox/windows_acl_descendants_windows_test.go @@ -3,6 +3,8 @@ package sandbox import ( + "encoding/binary" + "os" "path/filepath" "testing" "unsafe" @@ -10,6 +12,15 @@ import ( "golang.org/x/sys/windows" ) +// touchFile creates an empty file at path, failing the test on error. +func touchFile(t *testing.T, path string) string { + t.Helper() + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatalf("WriteFile(%q): %v", path, err) + } + return path +} + // dirDeniesSID reports whether path's DACL carries a deny ACE naming the given // string SID. It reads the same way the descendant scan applies denies, so a // test can confirm the compensating deny actually landed (and, after rollback, @@ -119,6 +130,8 @@ func TestWindowsEnumerateWritableDescendantsFindsExistingWritableChildren(t *tes plain := mkdir(t, filepath.Join(root, "plain")) workspace := mkdir(t, filepath.Join(root, "workspace")) grantUsersWrite(t, workspace) + writableFile := touchFile(t, filepath.Join(root, "writable.txt")) + grantUsersWrite(t, writableFile) found, err := windowsEnumerateWritableDescendants(root, nil) if err != nil { @@ -130,6 +143,9 @@ func TestWindowsEnumerateWritableDescendantsFindsExistingWritableChildren(t *tes if !windowsPathListContains(found, inner) { t.Fatalf("enumeration = %#v, want it to include nested writable descendant %q", found, inner) } + if !windowsPathListContains(found, writableFile) { + t.Fatalf("enumeration = %#v, want it to include writable file %q (a file is as much an escape surface as a directory)", found, writableFile) + } plainWritable, err := windowsDirGrantsBroadenedWrite(plain) if err != nil { @@ -188,3 +204,63 @@ func TestApplyWindowsSharedDescendantDeniesAppliesAndRollsBack(t *testing.T) { t.Fatalf("descendant %q still denies %q after rollback", writable, caps.ReadOnly) } } + +// TestWindowsAceSIDLocatesSIDInObjectACE pins the offset arithmetic +// windowsAceSID relies on for ACCESS_ALLOWED_OBJECT_ACE / ACCESS_DENIED_OBJECT_ACE: +// the real SID sits past a Flags DWORD and 0, 1, or 2 conditionally-present +// 16-byte GUIDs (ObjectType, InheritedObjectType), never at the plain-ACE +// SidStart offset GetAce's *ACCESS_ALLOWED_ACE typing would naively suggest. +// This builds the raw ACE bytes directly, per Microsoft's documented layout, +// because x/sys/windows has no AddAccessAllowedObjectAce binding to create a +// real one through the OS. +func TestWindowsAceSIDLocatesSIDInObjectACE(t *testing.T) { + usersSID, err := windows.CreateWellKnownSid(windows.WinBuiltinUsersSid) + if err != nil { + t.Fatalf("CreateWellKnownSid: %v", err) + } + sidBytes := unsafe.Slice((*byte)(unsafe.Pointer(usersSID)), usersSID.Len()) + + cases := []struct { + name string + aceType byte + flags uint32 + guids int // number of 16-byte GUIDs the flags say precede the SID + }{ + {"no optional GUIDs", windowsAccessAllowedObjectAceType, 0, 0}, + {"object type GUID only", windowsAccessAllowedObjectAceType, windows.ACE_OBJECT_TYPE_PRESENT, 1}, + {"inherited type GUID only", windowsAccessDeniedObjectAceType, windows.ACE_INHERITED_OBJECT_TYPE_PRESENT, 1}, + {"both GUIDs", windowsAccessDeniedObjectAceType, windows.ACE_OBJECT_TYPE_PRESENT | windows.ACE_INHERITED_OBJECT_TYPE_PRESENT, 2}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Layout: ACE_HEADER(4) + Mask(4) + Flags(4) + guids*GUID(16) + SID. + buf := make([]byte, 4+4+4+16*tc.guids+len(sidBytes)) + buf[0] = tc.aceType // Header.AceType + binary.LittleEndian.PutUint32(buf[8:12], tc.flags) // Flags, at the offset SidStart occupies in the plain-ACE layout + copy(buf[12+16*tc.guids:], sidBytes) + + ace := (*windows.ACCESS_ALLOWED_ACE)(unsafe.Pointer(&buf[0])) + sid, ok := windowsAceSID(ace) + if !ok { + t.Fatal("windowsAceSID returned ok=false for a recognized object ACE type") + } + if !sid.Equals(usersSID) { + t.Fatalf("windowsAceSID = %s, want %s", sid.String(), usersSID.String()) + } + }) + } +} + +// TestWindowsAceSIDSkipsUnhandledAceTypes confirms an ACE type this scan does +// not model (audit, mandatory label, ...) is skipped rather than misread as a +// plain or object ACE — the same conservative behavior the code had before +// object-ACE support was added. +func TestWindowsAceSIDSkipsUnhandledAceTypes(t *testing.T) { + const systemMandatoryLabelAceType = 0x11 + buf := make([]byte, 32) + buf[0] = systemMandatoryLabelAceType + ace := (*windows.ACCESS_ALLOWED_ACE)(unsafe.Pointer(&buf[0])) + if _, ok := windowsAceSID(ace); ok { + t.Fatal("windowsAceSID should return ok=false for an unhandled ACE type") + } +} diff --git a/internal/sandbox/windows_acl_test.go b/internal/sandbox/windows_acl_test.go index abc0c341e..edbf13105 100644 --- a/internal/sandbox/windows_acl_test.go +++ b/internal/sandbox/windows_acl_test.go @@ -238,6 +238,62 @@ func TestBuildWindowsACLPlanMarksSharedDenyPathsForDescendantScan(t *testing.T) } } +// TestBuildWindowsACLPlanSkipsSharedDenyPathNestedUnderWriteRoot pins the fix +// for a shared-path deny landing on a configured write root's own descendant: +// if a workspace's write root is (or contains) one of the four shared paths — +// here C:\Users contains the Public shared path — a DenyWrite there would sit +// ahead of that root's Allow for every broadened token and win under Windows' +// deny-before-allow evaluation, jailing a directory the user explicitly +// configured as writable. Only the shared paths NOT nested under any +// configured write root should get the compensating deny. +func TestBuildWindowsACLPlanSkipsSharedDenyPathNestedUnderWriteRoot(t *testing.T) { + home := t.TempDir() + systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest(t) + // publicDir is a Windows-style path (backslash-separated) even when this + // test runs on Linux/macOS, so its parent must be computed with a literal + // backslash split, not filepath.Dir (which uses the native separator and + // would treat the whole string as one component on non-Windows GOOS). + lastSeparator := strings.LastIndex(publicDir, `\`) + if lastSeparator <= 0 { + t.Fatalf("test fixture assumes publicDir %q has a parent reachable via a backslash split", publicDir) + } + usersRoot := publicDir[:lastSeparator] + plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ + SandboxHome: home, + WorkspaceRoots: []string{usersRoot}, + SandboxLevel: WindowsSandboxLevelRestrictedToken, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: usersRoot}}, + DenyRead: []string{`C:\workspace\secret-read`}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + }) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + for _, entry := range plan.Entries { + if entry.Action == WindowsACLDenyWrite && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(publicDir) { + t.Fatalf("plan denies write on %q, which is nested under configured write root %q: %#v", publicDir, usersRoot, entry) + } + } + // The other three shared paths are untouched by this write root and must + // still get their compensating deny. + for _, path := range []string{systemDrive + `\`, programData, systemRoot + `\Temp`} { + found := false + for _, entry := range plan.Entries { + if entry.Action == WindowsACLDenyWrite && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(path) { + found = true + } + } + if !found { + t.Fatalf("plan = %#v, want shared path %q still denied (unaffected by the %q write root)", plan.Entries, path, usersRoot) + } + } +} + func TestBuildWindowsACLPlanRejectsUnrestrictedProfiles(t *testing.T) { _, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ SandboxHome: t.TempDir(), From 35be2cec1d81012a6c86b37b283c66bb512c040c Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:06:33 -0400 Subject: [PATCH 10/26] fix(sandbox): gate broadened SIDs on volume coverage; honor INHERIT_ONLY ACEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings: - The broadened Users/Authenticated Users restricting SIDs participate in every access check on every volume, but the compensating DenyWrite mitigation covers only four system-drive paths. A stock non-system NTFS data volume grants Authenticated Users Modify at its root with volume-wide inheritance, so a DenyRead-profile command on a multi-volume host could write anywhere on that volume, outside every configured write root — and no bounded descendant scan can patch a grant inherited volume-wide. The runner now enumerates fixed volumes from trusted APIs and only broadens when the system drive is the sole fixed volume; any other layout (or an enumeration failure) fails closed to the narrow SID set, keeping the write jail at the cost of the read fix on those hosts. - The descendant write probe evaluated INHERIT_ONLY ACEs against the object itself; an inherit-only deny preceding an applicable allow could suppress it in deniedWrite and misclassify a writable directory as safe. Inherit-only entries are now skipped. Co-Authored-By: Claude Fable 5 --- .../windows_acl_descendants_windows.go | 7 ++ .../sandbox/windows_command_runner_windows.go | 11 ++- internal/sandbox/windows_volumes_windows.go | 74 +++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 internal/sandbox/windows_volumes_windows.go diff --git a/internal/sandbox/windows_acl_descendants_windows.go b/internal/sandbox/windows_acl_descendants_windows.go index 30826bfc7..7385d1808 100644 --- a/internal/sandbox/windows_acl_descendants_windows.go +++ b/internal/sandbox/windows_acl_descendants_windows.go @@ -263,6 +263,13 @@ func windowsDirGrantsBroadenedWrite(path string) (bool, error) { if err := windows.GetAce(dacl, uint32(index), &ace); err != nil { return false, fmt.Errorf("read ACE %d of %s: %w", index, path, err) } + // An INHERIT_ONLY ACE does not apply to this object itself — it only + // seeds ACLs of newly created children. Counting one here could let + // an inherit-only deny suppress a later applicable allow in + // deniedWrite, misclassifying a writable directory as safe. + if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 { + continue + } sid, ok := windowsAceSID(ace) if !ok { continue diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 96e5be58f..b5499ea25 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -86,7 +86,16 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ // the shared-directory DenyWrite mitigation BuildWindowsACLPlan adds for // the broadened SIDs (it requires Administrator rights); see // createWindowsRestrictedTokenFromBase. - broadenReadSIDs := config.SandboxLevel == WindowsSandboxLevelRestrictedToken && !writeRestricted + // The broadened identities are also gated on the machine's volume + // layout: the compensating shared-path DenyWrite mitigation covers only + // system-drive paths, so on a host with any other fixed volume (whose + // root typically grants Authenticated Users Modify with volume-wide + // inheritance) the broadened token could write outside every configured + // write root. Fail closed there and keep the narrow SID set — reads of + // Users-granted system paths stay broken on such hosts, but the write + // jail holds. + broadenReadSIDs := config.SandboxLevel == WindowsSandboxLevelRestrictedToken && !writeRestricted && + windowsSystemDriveIsOnlyFixedVolume() if broadenReadSIDs { // The shared-directory DenyWrite mitigation names the one stable // read-only capability SID rather than the per-workspace SIDs (see diff --git a/internal/sandbox/windows_volumes_windows.go b/internal/sandbox/windows_volumes_windows.go new file mode 100644 index 000000000..f7c241c6c --- /dev/null +++ b/internal/sandbox/windows_volumes_windows.go @@ -0,0 +1,74 @@ +//go:build windows + +package sandbox + +import ( + "strings" + + "golang.org/x/sys/windows" +) + +// windowsSystemDriveIsOnlyFixedVolume reports whether the system drive is +// this machine's only fixed volume. +// +// The compensating shared-directory DenyWrite mitigation covers exactly four +// paths, all on the system drive, while the broadened Users/Authenticated +// Users restricting SIDs participate in every access check on every volume. +// A stock non-system NTFS data volume grants Authenticated Users Modify at +// its root with (OI)(CI)(IO) inheritance, so on a multi-volume host a +// broadened token could write anywhere on such a volume, outside every +// configured write root — and no bounded descendant scan can patch a grant +// inherited volume-wide. The broadening is therefore only sound when there +// is no other fixed volume to protect. +// +// Fail closed: an enumeration failure, an unresolvable system drive, or any +// additional fixed volume all report false, keeping the narrow restricting- +// SID set (reads of Users-granted system paths stay broken on such hosts, +// but the write jail holds). +func windowsSystemDriveIsOnlyFixedVolume() bool { + windowsDir, err := windows.GetSystemWindowsDirectory() + if err != nil || len(windowsDir) < 2 { + return false + } + systemDrive := strings.ToUpper(windowsDir[:2]) // e.g. "C:" + + buf := make([]uint16, 1024) + n, err := windows.GetLogicalDriveStrings(uint32(len(buf)), &buf[0]) + if err != nil || n == 0 || int(n) > len(buf) { + return false + } + for _, root := range windowsSplitNulList(buf[:n]) { + rootPtr, err := windows.UTF16PtrFromString(root) + if err != nil { + return false + } + if windows.GetDriveType(rootPtr) != windows.DRIVE_FIXED { + continue + } + if !strings.EqualFold(strings.ToUpper(strings.TrimSuffix(root, `\`)), systemDrive) { + return false + } + } + return true +} + +// windowsSplitNulList splits the double-NUL-terminated UTF-16 string list +// returned by GetLogicalDriveStrings into Go strings. +func windowsSplitNulList(buf []uint16) []string { + var out []string + start := 0 + for i, c := range buf { + if c == 0 { + if i > start { + out = append(out, windows.UTF16ToString(buf[start:i])) + } + start = i + 1 + } + } + if start < len(buf) { + if s := windows.UTF16ToString(buf[start:]); s != "" { + out = append(out, s) + } + } + return out +} From 8edbdb626d6b2acfa2a41dfb9c97f8ba0f677202 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sun, 19 Jul 2026 04:49:41 -0400 Subject: [PATCH 11/26] fix(sandbox): close Windows ACL and volume-enumeration gaps from review Enumerate every volume and its mount paths instead of only drive letters, so a second fixed volume mounted at an NTFS folder is no longer missed. Fail closed instead of silently returning a partial result when the descendant ACL scan hits its depth or directory caps. Widen the write-probe mask to include FILE_WRITE_ATTRIBUTES and FILE_WRITE_EA, and add an explicit invariant check on restricted token creation. --- .../windows_acl_descendants_windows.go | 57 ++++++++++----- .../windows_acl_descendants_windows_test.go | 10 +++ internal/sandbox/windows_acl_test.go | 39 ++++++++++ internal/sandbox/windows_token_windows.go | 8 +++ internal/sandbox/windows_volumes_windows.go | 71 ++++++++++++++++--- 5 files changed, 158 insertions(+), 27 deletions(-) diff --git a/internal/sandbox/windows_acl_descendants_windows.go b/internal/sandbox/windows_acl_descendants_windows.go index 7385d1808..79929acee 100644 --- a/internal/sandbox/windows_acl_descendants_windows.go +++ b/internal/sandbox/windows_acl_descendants_windows.go @@ -38,10 +38,24 @@ import ( // surface and is small in practice, while a non-writable directory cannot // have been reached by such a write and so is pruned. // - windowsDescendantScanMaxDepth and windowsDescendantScanMaxDirs are hard -// safety caps against a pathological deep or very broad writable tree; if -// either is hit the scan stops (leaving deeper writable descendants -// unscanned, a bounded residual gap, still strictly smaller than the -// root-object-only enforcement it replaces). +// safety caps against a pathological deep or very broad writable tree. The +// scan fails closed if either is hit: unlike a locked-down directory (see +// below), hitting a cap means there IS unexamined territory, so reporting +// success would certify an incomplete scan as clean. +// - A directory this admin-elevated process cannot list, or whose DACL it +// cannot read, is treated as locked down and pruned (not failed closed). +// This is deliberate, not a residual gap: every one of the four shared +// roots has real, common subdirectories Administrators are denied by +// design regardless of privilege — "C:\System Volume Information" is +// SYSTEM-exclusive on every NTFS volume (see Microsoft KB2867841) and +// sits directly under the system drive root scanned here. Failing closed +// on that ubiquitous, expected case would make `zero sandbox setup` fail +// on every real machine, not just pathological ones. A directory the +// elevated process cannot even open a handle to, or read the DACL of, +// cannot have granted BUILTIN\Users or Authenticated Users write either +// (that would require WRITE_DAC/READ_CONTROL to be broader than what this +// process — running as Administrator — already has), so pruning it here +// carries no realistic write-jail risk. // // Reparse points (junctions/symlinks) are skipped entirely: their target lives // outside this subtree, so denying or descending them would touch unrelated @@ -53,12 +67,14 @@ const ( ) // windowsBroadenedWriteProbeMask is the set of access-mask bits that let a -// principal create, delete, or modify content in (or the security of) a -// directory, i.e. the bits that make a directory a usable write-jail escape. -// FILE_WRITE_DATA is FILE_ADD_FILE and FILE_APPEND_DATA is FILE_ADD_SUBDIRECTORY -// for a directory object. +// principal create, delete, or modify content, attributes, or extended +// attributes in (or the security of) a directory, i.e. the bits that make a +// directory a usable write-jail escape. FILE_WRITE_DATA is FILE_ADD_FILE and +// FILE_APPEND_DATA is FILE_ADD_SUBDIRECTORY for a directory object. const windowsBroadenedWriteProbeMask windows.ACCESS_MASK = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA | + windows.FILE_WRITE_ATTRIBUTES | + windows.FILE_WRITE_EA | windowsFileDeleteChild | windows.DELETE | windows.WRITE_DAC | @@ -109,6 +125,14 @@ func applyWindowsSharedDescendantDenies(root, denySID string, writeRoots []strin // much an escape surface as a writable directory — but only directories are // descended into. See the package-level comment above for the traversal // bounds and their rationale. +// +// The scan fails closed on exhausting windowsDescendantScanMaxDirs or +// windowsDescendantScanMaxDepth: either means there is unexamined territory +// this call cannot vouch for, so it returns an error rather than a partial +// result the caller could mistake for a complete one. A directory it cannot +// list, or a child whose DACL it cannot read, is treated as locked down and +// pruned instead — see the package-level comment for why that case is safe +// to skip rather than fail closed. func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]string, error) { if windowsCapabilityPathKey(root) == "" { return nil, nil @@ -141,10 +165,11 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st entries, err := os.ReadDir(current.path) if err != nil { // A directory the elevated setup cannot even list is locked down - // (SYSTEM-owned); the broad groups cannot write there, so skipping - // it is safe. Traversal is best-effort so a full system-drive walk - // does not abort setup on the normal un-listable system dirs it must - // step over. + // (SYSTEM-owned, e.g. "System Volume Information" on every shared + // root's own volume); the broad groups cannot write there either, + // so skipping it is safe. Traversal is best-effort so a full + // system-drive walk does not abort setup on the normal + // un-listable system dirs it must step over. continue } for _, entry := range entries { @@ -157,7 +182,7 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st continue } if visited >= windowsDescendantScanMaxDirs { - return out, nil + return nil, fmt.Errorf("descendant scan exceeded %d entries below %s", windowsDescendantScanMaxDirs, root) } visited++ writable, err := windowsDirGrantsBroadenedWrite(child) @@ -175,10 +200,10 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st continue } childDepth := current.depth + 1 - if childDepth >= windowsDescendantScanMaxDepth { - continue - } if childDepth < windowsDescendantScanBaselineDepth || writable { + if childDepth >= windowsDescendantScanMaxDepth { + return nil, fmt.Errorf("descendant scan exceeded depth %d at %s", windowsDescendantScanMaxDepth, child) + } queue = append(queue, node{path: child, depth: childDepth}) } } diff --git a/internal/sandbox/windows_acl_descendants_windows_test.go b/internal/sandbox/windows_acl_descendants_windows_test.go index 54b420bef..8a4df6ee8 100644 --- a/internal/sandbox/windows_acl_descendants_windows_test.go +++ b/internal/sandbox/windows_acl_descendants_windows_test.go @@ -203,6 +203,16 @@ func TestApplyWindowsSharedDescendantDeniesAppliesAndRollsBack(t *testing.T) { if dirDeniesSID(t, writable, caps.ReadOnly) { t.Fatalf("descendant %q still denies %q after rollback", writable, caps.ReadOnly) } + // Checking only that the deny disappeared would also pass a rollback that + // clobbered the original Users write ACE along with it; reassert the + // pre-existing writable state is actually restored, not just any DACL. + restoredWritable, err := windowsDirGrantsBroadenedWrite(writable) + if err != nil { + t.Fatalf("windowsDirGrantsBroadenedWrite after rollback: %v", err) + } + if !restoredWritable { + t.Fatalf("rollback did not restore the original writable DACL on %q", writable) + } } // TestWindowsAceSIDLocatesSIDInObjectACE pins the offset arithmetic diff --git a/internal/sandbox/windows_acl_test.go b/internal/sandbox/windows_acl_test.go index edbf13105..379eddda8 100644 --- a/internal/sandbox/windows_acl_test.go +++ b/internal/sandbox/windows_acl_test.go @@ -294,6 +294,45 @@ func TestBuildWindowsACLPlanSkipsSharedDenyPathNestedUnderWriteRoot(t *testing.T } } +// TestBuildWindowsACLPlanSkipsSharedDenyPathExactlyEqualToWriteRoot exercises +// the windowsPathUnderAnyRoot exact-match branch (as opposed to the +// nested-under-a-write-root case above): a write root configured AT one of +// the four shared paths themselves must get its Allow entry with no +// conflicting DenyWrite, or a broadened token could never write there despite +// the user explicitly configuring it as writable. +func TestBuildWindowsACLPlanSkipsSharedDenyPathExactlyEqualToWriteRoot(t *testing.T) { + home := t.TempDir() + _, systemRoot, _, _ := windowsSharedDenyPathsForTest(t) + writeRoot := systemRoot + `\Temp` + + plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ + SandboxHome: home, + WorkspaceRoots: []string{writeRoot}, + SandboxLevel: WindowsSandboxLevelRestrictedToken, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: writeRoot}}, + DenyRead: []string{`C:\workspace\secret-read`}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + }) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + writeRootSID, err := WindowsWorkspaceCapabilitySID(home, writeRoot) + if err != nil { + t.Fatalf("WindowsWorkspaceCapabilitySID: %v", err) + } + assertWindowsACLEntry(t, plan, WindowsACLAllowWrite, writeRoot, writeRootSID, false) + for _, entry := range plan.Entries { + if entry.Action == WindowsACLDenyWrite && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(writeRoot) { + t.Fatalf("plan denies write on %q, which IS the configured write root: %#v", writeRoot, entry) + } + } +} + func TestBuildWindowsACLPlanRejectsUnrestrictedProfiles(t *testing.T) { _, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ SandboxHome: t.TempDir(), diff --git a/internal/sandbox/windows_token_windows.go b/internal/sandbox/windows_token_windows.go index 903f3d002..d5c121dde 100644 --- a/internal/sandbox/windows_token_windows.go +++ b/internal/sandbox/windows_token_windows.go @@ -102,6 +102,14 @@ func createWindowsRestrictedTokenForCapabilitySIDs(capabilitySIDStrings []string // mitigation: those keep the original (narrower) SID scope instead of // widening the write jail with nothing to close the gap. func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []windowsLocalSID, writeRestricted, broadenReadSIDs bool) (windows.Token, error) { + // Defensive guardrail for the invariant documented above: combining the + // two would silently widen the write jail (broadenReadSIDs's write grants) + // with no compensating DenyWrite mitigation (writeRestricted's caller + // never applies one), so refuse to build the token rather than let a + // future caller-side refactor combine them by accident. + if broadenReadSIDs && writeRestricted { + return 0, errors.New("broadenReadSIDs cannot be combined with writeRestricted") + } logonSID, err := copyWindowsLogonSID(base) if err != nil { return 0, err diff --git a/internal/sandbox/windows_volumes_windows.go b/internal/sandbox/windows_volumes_windows.go index f7c241c6c..574360a3c 100644 --- a/internal/sandbox/windows_volumes_windows.go +++ b/internal/sandbox/windows_volumes_windows.go @@ -21,10 +21,19 @@ import ( // inherited volume-wide. The broadening is therefore only sound when there // is no other fixed volume to protect. // +// A second fixed volume need not have a drive letter to be reachable: it can +// be mounted at an NTFS folder mount point (e.g. C:\mnt\data), which +// GetLogicalDriveStrings never reports (it only enumerates drive-letter +// roots). This enumerates every volume on the machine via +// FindFirstVolume/FindNextVolume and checks ALL of its mount points — +// drive letters and mounted folders alike — via GetVolumePathNamesForVolumeName, +// so a fixed volume mounted only as a folder is caught the same as one +// mounted on a drive letter. +// // Fail closed: an enumeration failure, an unresolvable system drive, or any -// additional fixed volume all report false, keeping the narrow restricting- -// SID set (reads of Users-granted system paths stay broken on such hosts, -// but the write jail holds). +// additional fixed volume (by any mount path) all report false, keeping the +// narrow restricting-SID set (reads of Users-granted system paths stay +// broken on such hosts, but the write jail holds). func windowsSystemDriveIsOnlyFixedVolume() bool { windowsDir, err := windows.GetSystemWindowsDirectory() if err != nil || len(windowsDir) < 2 { @@ -32,26 +41,66 @@ func windowsSystemDriveIsOnlyFixedVolume() bool { } systemDrive := strings.ToUpper(windowsDir[:2]) // e.g. "C:" - buf := make([]uint16, 1024) - n, err := windows.GetLogicalDriveStrings(uint32(len(buf)), &buf[0]) - if err != nil || n == 0 || int(n) > len(buf) { + volumeNameBuf := make([]uint16, 260) + handle, err := windows.FindFirstVolume(&volumeNameBuf[0], uint32(len(volumeNameBuf))) + if err != nil { return false } - for _, root := range windowsSplitNulList(buf[:n]) { - rootPtr, err := windows.UTF16PtrFromString(root) + defer windows.FindVolumeClose(handle) + + for { + volumeName := windows.UTF16ToString(volumeNameBuf) + onlySystemDrive, err := windowsVolumeMountsOnlySystemDrive(volumeName, systemDrive) if err != nil { return false } - if windows.GetDriveType(rootPtr) != windows.DRIVE_FIXED { - continue + if !onlySystemDrive { + return false } - if !strings.EqualFold(strings.ToUpper(strings.TrimSuffix(root, `\`)), systemDrive) { + if err := windows.FindNextVolume(handle, &volumeNameBuf[0], uint32(len(volumeNameBuf))); err != nil { + if err == windows.ERROR_NO_MORE_FILES { + break + } return false } } return true } +// windowsVolumeMountsOnlySystemDrive reports whether volumeName (a +// "\\?\Volume{GUID}\" path from FindFirstVolume/FindNextVolume) is either not +// fixed media, not mounted anywhere, or mounted only at the system drive +// root. Any OTHER mount path — a different drive letter or a folder mount +// point — makes it a reachable extra fixed volume, regardless of which path +// form reaches it. +func windowsVolumeMountsOnlySystemDrive(volumeName, systemDrive string) (bool, error) { + volumeNamePtr, err := windows.UTF16PtrFromString(volumeName) + if err != nil { + return false, err + } + if windows.GetDriveType(volumeNamePtr) != windows.DRIVE_FIXED { + return true, nil + } + + buf := make([]uint16, 1024) + var returnLength uint32 + err = windows.GetVolumePathNamesForVolumeName(volumeNamePtr, &buf[0], uint32(len(buf)), &returnLength) + if err == windows.ERROR_MORE_DATA { + buf = make([]uint16, returnLength) + err = windows.GetVolumePathNamesForVolumeName(volumeNamePtr, &buf[0], uint32(len(buf)), &returnLength) + } + if err != nil { + return false, err + } + + for _, mountPath := range windowsSplitNulList(buf) { + if !strings.EqualFold(strings.ToUpper(strings.TrimSuffix(mountPath, `\`)), systemDrive) { + return false, nil + } + } + return true, nil +} + // windowsSplitNulList splits the double-NUL-terminated UTF-16 string list // returned by GetLogicalDriveStrings into Go strings. func windowsSplitNulList(buf []uint16) []string { From 346f89b093c8817625824401bb4d37bc5a4fb01d Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:27:36 -0400 Subject: [PATCH 12/26] fix(sandbox): fail-closed descendant coverage before broadening SIDs Always descend shared-root trees within scan caps so a writable child under non-writable ancestors is denied, and treat reparse points, unknown unreadable entries, and cap exhaustion as incomplete coverage. Revalidate and reapply (or verify) descendant denies immediately before broadening Users/Authenticated Users on the restricted token; keep the narrow SID set when coverage cannot be re-established. Folder-mounted fixed volumes stay rejected via FindFirstVolume mount-path enumeration. --- internal/sandbox/windows_acl_descendants.go | 58 +++++ .../sandbox/windows_acl_descendants_test.go | 60 +++++ .../windows_acl_descendants_windows.go | 246 +++++++++++++----- .../windows_acl_descendants_windows_test.go | 64 +++++ .../sandbox/windows_command_runner_windows.go | 13 + internal/sandbox/windows_volumes_windows.go | 2 +- 6 files changed, 372 insertions(+), 71 deletions(-) create mode 100644 internal/sandbox/windows_acl_descendants.go create mode 100644 internal/sandbox/windows_acl_descendants_test.go diff --git a/internal/sandbox/windows_acl_descendants.go b/internal/sandbox/windows_acl_descendants.go new file mode 100644 index 000000000..2b130462b --- /dev/null +++ b/internal/sandbox/windows_acl_descendants.go @@ -0,0 +1,58 @@ +package sandbox + +import "strings" + +// Shared basename policies and pure helpers for the Windows descendant-scan +// fail-closed rules. The Win32 walk lives in windows_acl_descendants_windows.go; +// these helpers are compiled on every GOOS so non-Windows tests can pin the +// policy without the Windows APIs. + +// windowsDescendantScanSystemLockedNames are basenames that Windows keeps +// exclusive to SYSTEM (or otherwise unreadable even to elevated Administrators +// without taking ownership). They appear under every fixed volume root. Listing +// or DACL-reading them fails on healthy machines; treating that as incomplete +// coverage would make DenyRead setup fail everywhere. They never grant +// BUILTIN\Users / Authenticated Users write in stock configuration. +var windowsDescendantScanSystemLockedNames = map[string]struct{}{ + "system volume information": {}, + "$recycle.bin": {}, + "recovery": {}, +} + +// windowsDescendantScanPruneNames are basenames of large stock trees that are +// not Users/AuthUsers-writable at their root on a normal install. When the +// write probe agrees they are not writable, the scan does not descend into +// them. This is the only reason a full C:\ walk stays within the entry budget +// without silently skipping arbitrary user-created trees. +var windowsDescendantScanPruneNames = map[string]struct{}{ + "windows": {}, + "program files": {}, + "program files (x86)": {}, + "perflogs": {}, + "documents and settings": {}, + // ProgramData and Users\Public are themselves shared deny roots and are + // scanned as separate roots by applyWindowsACLPlan. Pruning them under C:\ + // avoids double-walking and double-stamping the same descendants. + "programdata": {}, + "users": {}, +} + +func windowsDescendantScanNameIsSystemLocked(name string) bool { + _, ok := windowsDescendantScanSystemLockedNames[strings.ToLower(strings.TrimSpace(name))] + return ok +} + +func windowsDescendantScanNameIsPruned(name string) bool { + _, ok := windowsDescendantScanPruneNames[strings.ToLower(strings.TrimSpace(name))] + return ok +} + +// windowsMountPathIsOnlySystemDrive reports whether a volume mount path is the +// system drive root (e.g. `C:\` or `C:`) rather than another letter or a +// folder mount such as `C:\mnt\data`. Used by the volume gate so a second +// fixed volume mounted only as a folder is rejected the same as one mounted +// on a drive letter. +func windowsMountPathIsOnlySystemDrive(mountPath, systemDrive string) bool { + trimmed := strings.TrimSuffix(mountPath, `\`) + return strings.EqualFold(strings.ToUpper(trimmed), strings.ToUpper(systemDrive)) +} diff --git a/internal/sandbox/windows_acl_descendants_test.go b/internal/sandbox/windows_acl_descendants_test.go new file mode 100644 index 000000000..b4b768231 --- /dev/null +++ b/internal/sandbox/windows_acl_descendants_test.go @@ -0,0 +1,60 @@ +package sandbox + +import "testing" + +func TestWindowsDescendantScanNamePolicies(t *testing.T) { + for _, name := range []string{ + "System Volume Information", + "SYSTEM VOLUME INFORMATION", + "$Recycle.Bin", + "Recovery", + } { + if !windowsDescendantScanNameIsSystemLocked(name) { + t.Fatalf("windowsDescendantScanNameIsSystemLocked(%q) = false, want true", name) + } + } + for _, name := range []string{"ProgramData", "plain", "Users"} { + if windowsDescendantScanNameIsSystemLocked(name) { + t.Fatalf("windowsDescendantScanNameIsSystemLocked(%q) = true, want false", name) + } + } + + for _, name := range []string{ + "Windows", + "Program Files", + "Program Files (x86)", + "ProgramData", + "Users", + "PerfLogs", + } { + if !windowsDescendantScanNameIsPruned(name) { + t.Fatalf("windowsDescendantScanNameIsPruned(%q) = false, want true", name) + } + } + for _, name := range []string{"AppData", "zero-project", "Temp"} { + if windowsDescendantScanNameIsPruned(name) { + t.Fatalf("windowsDescendantScanNameIsPruned(%q) = true, want false", name) + } + } +} + +func TestWindowsMountPathIsOnlySystemDrive(t *testing.T) { + cases := []struct { + mount, system string + want bool + }{ + {`C:\`, `C:`, true}, + {`C:`, `C:`, true}, + {`c:\`, `C:`, true}, + {`D:\`, `C:`, false}, + {`C:\mnt\data`, `C:`, false}, + {`C:\mnt\data\`, `C:`, false}, + {`\\?\Volume{guid}\`, `C:`, false}, + } + for _, tc := range cases { + got := windowsMountPathIsOnlySystemDrive(tc.mount, tc.system) + if got != tc.want { + t.Fatalf("windowsMountPathIsOnlySystemDrive(%q, %q) = %v, want %v", tc.mount, tc.system, got, tc.want) + } + } +} diff --git a/internal/sandbox/windows_acl_descendants_windows.go b/internal/sandbox/windows_acl_descendants_windows.go index 79929acee..b996b48d9 100644 --- a/internal/sandbox/windows_acl_descendants_windows.go +++ b/internal/sandbox/windows_acl_descendants_windows.go @@ -22,48 +22,41 @@ import ( // a write outside every configured write root. This file enumerates those // existing writable descendants and denies each one directly. // -// The scan is deliberately bounded and targeted, NOT a blanket recursive walk: -// stamping an inheritable deny (or rewriting every descendant's ACL) across the -// system drive is the exact slow/brittle/ACL-polluting pathology the -// inheritable-deny approach was rejected for. Instead the traversal only ever -// WRITES a deny to a descendant it has confirmed is already writable by those -// broad groups, and it prunes the enormous non-writable system trees -// (C:\Windows, C:\Program Files) so cost stays bounded: +// Coverage rules (fail closed): // -// - It always descends the first windowsDescendantScanBaselineDepth levels, -// so a shallow, freshly installed writable directory is caught even when its -// parent is not itself writable. -// - Below the baseline it descends ONLY into a directory that is itself -// writable by the broad groups. A writable subtree is the real escape -// surface and is small in practice, while a non-writable directory cannot -// have been reached by such a write and so is pruned. -// - windowsDescendantScanMaxDepth and windowsDescendantScanMaxDirs are hard -// safety caps against a pathological deep or very broad writable tree. The -// scan fails closed if either is hit: unlike a locked-down directory (see -// below), hitting a cap means there IS unexamined territory, so reporting -// success would certify an incomplete scan as clean. -// - A directory this admin-elevated process cannot list, or whose DACL it -// cannot read, is treated as locked down and pruned (not failed closed). -// This is deliberate, not a residual gap: every one of the four shared -// roots has real, common subdirectories Administrators are denied by -// design regardless of privilege — "C:\System Volume Information" is -// SYSTEM-exclusive on every NTFS volume (see Microsoft KB2867841) and -// sits directly under the system drive root scanned here. Failing closed -// on that ubiquitous, expected case would make `zero sandbox setup` fail -// on every real machine, not just pathological ones. A directory the -// elevated process cannot even open a handle to, or read the DACL of, -// cannot have granted BUILTIN\Users or Authenticated Users write either -// (that would require WRITE_DAC/READ_CONTROL to be broader than what this -// process — running as Administrator — already has), so pruning it here -// carries no realistic write-jail risk. +// - Every directory under a shared root is considered for descent within the +// depth/entry caps, whether or not the parent itself is Users-writable. A +// depth-N writable child under non-writable ancestors is a real escape and +// must be found (see CodeRabbit/jatmn review). +// - Hitting windowsDescendantScanMaxDepth or windowsDescendantScanMaxDirs +// means unexamined territory remains: the scan returns an error so setup +// and pre-broaden revalidation cannot certify a partial walk as clean. +// - Reparse points (junctions, symlinks, volume mount points) are fail-closed: +// sandboxed access can follow them, but denying/descending them risks +// touching unrelated trees or other volumes. Incomplete coverage of a +// reparse target aborts broadening rather than pretending the tree is safe. +// - A directory this process cannot list, or a child whose DACL it cannot +// read, is fail-closed UNLESS the basename is a known SYSTEM-exclusive +// Windows directory (e.g. "System Volume Information") that is present on +// every volume and never grants Users/Authenticated Users write. Without +// that narrow allowlist, elevated setup would fail on every real machine. +// - Stock non-writable system trees under the drive root (Windows, Program +// Files, ...) are pruned by basename only when the probe says they are not +// Users/AuthUsers-writable. That keeps the C:\ walk from exhausting the +// entry budget on trees that are not the write-jail surface; if such a +// tree IS Users-writable it is denied and descended like any other. // -// Reparse points (junctions/symlinks) are skipped entirely: their target lives -// outside this subtree, so denying or descending them would touch unrelated -// objects and risk traversal loops. -const ( - windowsDescendantScanBaselineDepth = 2 - windowsDescendantScanMaxDepth = 24 - windowsDescendantScanMaxDirs = 8192 +// Staleness: setup alone is not enough. Non-inheriting denies only cover the +// filesystem state at apply time. The elevated command runner revalidates and +// reapplies this scan immediately before broadening the restricted token +// (windowsEnsureSharedDescendantCoverage); if coverage cannot be re-established, +// the token stays on the narrow SID set. +// +// Basename policies live in windows_acl_descendants.go so non-Windows tests can +// pin them without Win32. Bounds are vars so Windows tests can lower them. +var ( + windowsDescendantScanMaxDepth = 24 + windowsDescendantScanMaxDirs = 8192 ) // windowsBroadenedWriteProbeMask is the set of access-mask bits that let a @@ -88,9 +81,10 @@ const windowsBroadenedWriteProbeMask windows.ACCESS_MASK = windows.FILE_WRITE_DA // to each. It returns every snapshot it applied (including on error) so the // caller can roll the whole apply back. A descendant it identified as writable // but could not deny is a hole it cannot close, so that failure is returned -// (fail closed); a descendant whose parent it merely could not list or whose -// DACL it could not read is treated as locked-down and skipped in the -// enumeration itself (see windowsEnumerateWritableDescendants). +// (fail closed). An incomplete enumeration (caps, reparse, unreadable child) +// is also returned as an error. Descendants that already carry an equivalent +// deny for denySID are left untouched so setup reruns and command-time +// revalidation do not accumulate duplicate permanent ACEs. func applyWindowsSharedDescendantDenies(root, denySID string, writeRoots []string) ([]windowsACLSnapshot, error) { descendants, err := windowsEnumerateWritableDescendants(root, writeRoots) if err != nil { @@ -98,6 +92,13 @@ func applyWindowsSharedDescendantDenies(root, denySID string, writeRoots []strin } snapshots := make([]windowsACLSnapshot, 0, len(descendants)) for _, dir := range descendants { + denied, err := windowsPathDeniesCapabilitySID(dir, denySID) + if err != nil { + return snapshots, fmt.Errorf("inspect existing deny on %s: %w", dir, err) + } + if denied { + continue + } snapshot, applied, err := applyWindowsACLPathGroup(windowsACLPathGroup{ Path: dir, Entries: []WindowsACLEntry{{ @@ -123,16 +124,11 @@ func applyWindowsSharedDescendantDenies(root, denySID string, writeRoots []strin // legitimate workspace writes are never jailed. Files are checked and denied // just like directories — a writable file directly under a shared root is as // much an escape surface as a writable directory — but only directories are -// descended into. See the package-level comment above for the traversal -// bounds and their rationale. +// descended into. // -// The scan fails closed on exhausting windowsDescendantScanMaxDirs or -// windowsDescendantScanMaxDepth: either means there is unexamined territory -// this call cannot vouch for, so it returns an error rather than a partial -// result the caller could mistake for a complete one. A directory it cannot -// list, or a child whose DACL it cannot read, is treated as locked down and -// pruned instead — see the package-level comment for why that case is safe -// to skip rather than fail closed. +// Fail closed: exhausting the depth or entry caps, encountering a reparse +// point, or failing to list/inspect a non-allowlisted entry returns an error +// rather than a partial success the caller could mistake for complete coverage. func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]string, error) { if windowsCapabilityPathKey(root) == "" { return nil, nil @@ -164,13 +160,10 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st queue = queue[1:] entries, err := os.ReadDir(current.path) if err != nil { - // A directory the elevated setup cannot even list is locked down - // (SYSTEM-owned, e.g. "System Volume Information" on every shared - // root's own volume); the broad groups cannot write there either, - // so skipping it is safe. Traversal is best-effort so a full - // system-drive walk does not abort setup on the normal - // un-listable system dirs it must step over. - continue + if windowsDescendantScanNameIsSystemLocked(filepath.Base(current.path)) { + continue + } + return nil, fmt.Errorf("list descendants of %s: %w", current.path, err) } for _, entry := range entries { child := filepath.Join(current.path, entry.Name()) @@ -179,7 +172,7 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st continue } if windowsPathIsReparsePoint(child) { - continue + return nil, fmt.Errorf("descendant scan hit reparse point %s under %s; cannot establish coverage for its target", child, root) } if visited >= windowsDescendantScanMaxDirs { return nil, fmt.Errorf("descendant scan exceeded %d entries below %s", windowsDescendantScanMaxDirs, root) @@ -187,25 +180,34 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st visited++ writable, err := windowsDirGrantsBroadenedWrite(child) if err != nil { - // Cannot read the child's DACL: same reasoning as an un-listable - // directory: locked down, not an escape target. Skip. - continue + if windowsDescendantScanNameIsSystemLocked(entry.Name()) { + continue + } + return nil, fmt.Errorf("inspect DACL of %s: %w", child, err) } if writable { out = append(out, child) } if !entry.IsDir() { - // A file has no descendants to walk; it either got denied above - // or was not writable, either way there is nothing further to do. continue } childDepth := current.depth + 1 - if childDepth < windowsDescendantScanBaselineDepth || writable { - if childDepth >= windowsDescendantScanMaxDepth { - return nil, fmt.Errorf("descendant scan exceeded depth %d at %s", windowsDescendantScanMaxDepth, child) - } - queue = append(queue, node{path: child, depth: childDepth}) + if childDepth >= windowsDescendantScanMaxDepth { + // A directory at the depth cap may still have unexamined + // children. Fail closed rather than pretend the subtree is clean. + // Leaf files at this depth were already inspected above. + // Only fail when we would have needed to descend further: always + // report the cap so callers cannot certify "complete". + return nil, fmt.Errorf("descendant scan exceeded depth %d at %s", windowsDescendantScanMaxDepth, child) } + // Always descend (subject to caps), including through non-writable + // ancestors, so a deep writable child is not missed. Stock huge + // non-writable system trees are pruned by basename only when the + // probe confirmed they are not Users/AuthUsers-writable. + if !writable && windowsDescendantScanNameIsPruned(entry.Name()) { + continue + } + queue = append(queue, node{path: child, depth: childDepth}) } } return out, nil @@ -267,6 +269,11 @@ func windowsAceSID(ace *windows.ACCESS_ALLOWED_ACE) (sid *windows.SID, ok bool) // honoring a deny ACE that precedes an allow for the same bits, the canonical // evaluation. A NULL DACL grants everyone full access and is treated as // writable. +// +// Note: this is a deliberate DACL walk rather than AccessCheck. It must detect +// grants that would become usable once the restricted token is broadened with +// those groups, independent of the setup process's own token. INHERIT_ONLY ACEs +// are skipped because they do not apply to the object itself. func windowsDirGrantsBroadenedWrite(path string) (bool, error) { // GetNamedSecurityInfo returns a self-relative descriptor copied onto the Go // heap (it LocalFrees the Win32 allocation itself), so it must NOT be @@ -333,3 +340,102 @@ func windowsPathIsReparsePoint(path string) bool { } return attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 } + +// windowsEnsureSharedDescendantCoverage re-enumerates the shared deny roots and +// ensures every currently Users/AuthUsers-writable descendant carries a direct +// DenyWrite for the stable read-only capability SID. It is called immediately +// before a command broadens the restricted token so a child created after +// `zero sandbox setup` cannot remain an uncovered write-jail escape. +// +// Prefer reapplying denies (same permanent posture as elevated setup). When +// reapply fails — typically because the command process lacks WRITE_DAC on a +// system path — fall back to a read-only hole check: if any writable +// descendant still lacks the synthetic deny, coverage is incomplete and the +// caller must not broaden. Fail closed on enumeration errors either way. +func windowsEnsureSharedDescendantCoverage(config WindowsSandboxCommandConfig) error { + plan, err := BuildWindowsACLPlan(config) + if err != nil { + return err + } + writeRoots := windowsPlanAllowWriteRoots(plan) + for _, group := range groupWindowsACLPlanByPath(plan) { + denySID, ok := windowsGroupScanDescendantsSID(group) + if !ok { + continue + } + if _, err := os.Stat(group.Path); err != nil { + if os.IsNotExist(err) { + continue + } + return fmt.Errorf("stat shared deny root %s: %w", group.Path, err) + } + if _, err := applyWindowsSharedDescendantDenies(group.Path, denySID, writeRoots); err == nil { + continue + } else if holes, holeErr := windowsUncoveredWritableDescendants(group.Path, denySID, writeRoots); holeErr != nil { + return holeErr + } else if len(holes) > 0 { + return fmt.Errorf("shared descendant write coverage incomplete under %s (e.g. %s): %w", group.Path, holes[0], err) + } + // Reapply failed but every currently writable descendant already carries + // the synthetic deny, so the write jail still holds for this root. + } + return nil +} + +// windowsUncoveredWritableDescendants returns Users/AuthUsers-writable +// descendants of root that do not yet carry a DenyWrite ACE for denySID. +func windowsUncoveredWritableDescendants(root, denySID string, writeRoots []string) ([]string, error) { + descendants, err := windowsEnumerateWritableDescendants(root, writeRoots) + if err != nil { + return nil, fmt.Errorf("enumerate writable descendants of %s: %w", root, err) + } + var holes []string + for _, dir := range descendants { + denied, err := windowsPathDeniesCapabilitySID(dir, denySID) + if err != nil { + return nil, fmt.Errorf("inspect existing deny on %s: %w", dir, err) + } + if !denied { + holes = append(holes, dir) + } + } + return holes, nil +} + +// windowsPathDeniesCapabilitySID reports whether path's DACL already contains +// a deny ACE naming the given capability SID string (the synthetic identity +// used for shared-root / descendant DenyWrite entries). +func windowsPathDeniesCapabilitySID(path, wantSID string) (bool, error) { + want, err := windows.StringToSid(wantSID) + if err != nil { + return false, fmt.Errorf("parse capability SID %q: %w", wantSID, err) + } + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + return false, err + } + dacl, _, err := sd.DACL() + if err != nil { + return false, err + } + if dacl == nil { + return false, nil + } + for index := uint16(0); index < dacl.AceCount; index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, uint32(index), &ace); err != nil { + return false, fmt.Errorf("read ACE %d of %s: %w", index, path, err) + } + if ace.Header.AceType != windows.ACCESS_DENIED_ACE_TYPE && ace.Header.AceType != windowsAccessDeniedObjectAceType { + continue + } + sid, ok := windowsAceSID(ace) + if !ok { + continue + } + if sid.Equals(want) { + return true, nil + } + } + return false, nil +} diff --git a/internal/sandbox/windows_acl_descendants_windows_test.go b/internal/sandbox/windows_acl_descendants_windows_test.go index 8a4df6ee8..1f6d5b93c 100644 --- a/internal/sandbox/windows_acl_descendants_windows_test.go +++ b/internal/sandbox/windows_acl_descendants_windows_test.go @@ -127,6 +127,12 @@ func TestWindowsEnumerateWritableDescendantsFindsExistingWritableChildren(t *tes grantUsersWrite(t, outer) inner := mkdir(t, filepath.Join(outer, "inner")) grantUsersWrite(t, inner) + // Depth-3 writable child under non-writable ancestors: the scan must keep + // descending through non-writable parents or this escape stays open. + level1 := mkdir(t, filepath.Join(root, "locked1")) + level2 := mkdir(t, filepath.Join(level1, "locked2")) + deepWritable := mkdir(t, filepath.Join(level2, "deep-writable")) + grantUsersWrite(t, deepWritable) plain := mkdir(t, filepath.Join(root, "plain")) workspace := mkdir(t, filepath.Join(root, "workspace")) grantUsersWrite(t, workspace) @@ -143,6 +149,9 @@ func TestWindowsEnumerateWritableDescendantsFindsExistingWritableChildren(t *tes if !windowsPathListContains(found, inner) { t.Fatalf("enumeration = %#v, want it to include nested writable descendant %q", found, inner) } + if !windowsPathListContains(found, deepWritable) { + t.Fatalf("enumeration = %#v, want it to include depth-3 writable child %q under non-writable ancestors", found, deepWritable) + } if !windowsPathListContains(found, writableFile) { t.Fatalf("enumeration = %#v, want it to include writable file %q (a file is as much an escape surface as a directory)", found, writableFile) } @@ -169,6 +178,61 @@ func TestWindowsEnumerateWritableDescendantsFindsExistingWritableChildren(t *tes } } +// TestWindowsEnumerateWritableDescendantsFailsClosedOnEntryCap pins that +// exhausting the descendant entry budget is an error, not a silent partial +// success that would still let setup broaden the restricted token. +func TestWindowsEnumerateWritableDescendantsFailsClosedOnEntryCap(t *testing.T) { + prev := windowsDescendantScanMaxDirs + windowsDescendantScanMaxDirs = 3 + t.Cleanup(func() { windowsDescendantScanMaxDirs = prev }) + + root := t.TempDir() + for _, name := range []string{"a", "b", "c", "d"} { + mkdir(t, filepath.Join(root, name)) + } + _, err := windowsEnumerateWritableDescendants(root, nil) + if err == nil { + t.Fatal("windowsEnumerateWritableDescendants: expected entry-cap error, got nil") + } +} + +// TestWindowsPathDeniesCapabilitySIDRoundTrip ensures the pre-broaden hole +// check can see a deny ACE that applyWindowsSharedDescendantDenies just wrote. +func TestWindowsPathDeniesCapabilitySIDRoundTrip(t *testing.T) { + caps, err := LoadOrCreateWindowsCapabilitySIDs(t.TempDir()) + if err != nil { + t.Fatalf("LoadOrCreateWindowsCapabilitySIDs: %v", err) + } + root := t.TempDir() + writable := mkdir(t, filepath.Join(root, "writable")) + grantUsersWrite(t, writable) + + before, err := windowsPathDeniesCapabilitySID(writable, caps.ReadOnly) + if err != nil { + t.Fatalf("windowsPathDeniesCapabilitySID before: %v", err) + } + if before { + t.Fatal("path already denies capability SID before apply") + } + if _, err := applyWindowsSharedDescendantDenies(root, caps.ReadOnly, nil); err != nil { + t.Fatalf("applyWindowsSharedDescendantDenies: %v", err) + } + after, err := windowsPathDeniesCapabilitySID(writable, caps.ReadOnly) + if err != nil { + t.Fatalf("windowsPathDeniesCapabilitySID after: %v", err) + } + if !after { + t.Fatal("path does not deny capability SID after apply") + } + holes, err := windowsUncoveredWritableDescendants(root, caps.ReadOnly, nil) + if err != nil { + t.Fatalf("windowsUncoveredWritableDescendants: %v", err) + } + if len(holes) != 0 { + t.Fatalf("holes = %#v, want none after apply", holes) + } +} + // TestApplyWindowsSharedDescendantDeniesAppliesAndRollsBack proves the // enforcement half of the fix end to end on real ACLs: a writable descendant of // a shared root gets a direct deny ACE for the read-only capability SID (the SID diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index b5499ea25..cf4cf898c 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -94,8 +94,21 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ // write root. Fail closed there and keep the narrow SID set — reads of // Users-granted system paths stay broken on such hosts, but the write // jail holds. + // + // Before broadening, revalidate/reapply direct denies on any currently + // Users/AuthUsers-writable descendants of the shared roots. Setup alone is + // a point-in-time snapshot; non-inheriting denies do not cover children + // created afterward. If coverage cannot be re-established, keep the narrow + // SID set rather than widening the write jail. broadenReadSIDs := config.SandboxLevel == WindowsSandboxLevelRestrictedToken && !writeRestricted && windowsSystemDriveIsOnlyFixedVolume() + if broadenReadSIDs { + if err := windowsEnsureSharedDescendantCoverage(config); err != nil { + fmt.Fprintf(stderr, "%s: shared descendant write coverage incomplete (%v); keeping narrow restricting SIDs\n", + WindowsSandboxCommandRunnerName, err) + broadenReadSIDs = false + } + } if broadenReadSIDs { // The shared-directory DenyWrite mitigation names the one stable // read-only capability SID rather than the per-workspace SIDs (see diff --git a/internal/sandbox/windows_volumes_windows.go b/internal/sandbox/windows_volumes_windows.go index 574360a3c..6de0332a5 100644 --- a/internal/sandbox/windows_volumes_windows.go +++ b/internal/sandbox/windows_volumes_windows.go @@ -94,7 +94,7 @@ func windowsVolumeMountsOnlySystemDrive(volumeName, systemDrive string) (bool, e } for _, mountPath := range windowsSplitNulList(buf) { - if !strings.EqualFold(strings.ToUpper(strings.TrimSuffix(mountPath, `\`)), systemDrive) { + if !windowsMountPathIsOnlySystemDrive(mountPath, systemDrive) { return false, nil } } From 7c9d7ab729c451a40093fe0eb41bccb1c76a8d34 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:30:13 -0400 Subject: [PATCH 13/26] fix(sandbox): address review findings on descendant scan and Public-dir probe - windowsPathDeniesCapabilitySID skipped straight to SID comparison without filtering INHERIT_ONLY_ACE entries, unlike its sibling windowsDirGrantsBroadenedWrite. An inherited-but-inapplicable deny ACE on a descendant could read as "already denied," causing applyWindowsSharedDescendantDenies to skip applying the real, effective deny and leave that descendant writable. - The basename prune list in the descendant BFS matched at any depth, not just the scan root's direct children, so a nested directory that happens to share a stock-tree name (e.g. a subfolder literally named "Program Files") could get pruned even with a writable descendant beneath it. Gate the prune on depth == 0. - The C:\Users\Public write-jail probe used t.Skip when PUBLIC is unset, which aborts the whole test function and silently drops the unrelated loopback network-deny assertions later in the same test. Wrap the probe instead, matching the existing ProgramData pattern. --- .../runner_windows_integration_test.go | 23 ++++++++++--------- .../windows_acl_descendants_windows.go | 16 +++++++++++-- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/internal/sandbox/runner_windows_integration_test.go b/internal/sandbox/runner_windows_integration_test.go index d4921c2ec..f05fd0010 100644 --- a/internal/sandbox/runner_windows_integration_test.go +++ b/internal/sandbox/runner_windows_integration_test.go @@ -81,18 +81,19 @@ func TestWindowsRestrictedTokenRealSandboxSmoke(t *testing.T) { // (ProgramData, Windows\Temp), and outside every workspace write root. publicDir := os.Getenv("PUBLIC") if publicDir == "" { - t.Skip("PUBLIC is not set; cannot probe C:\\Users\\Public write jail") - } - publicMarker := filepath.Join(publicDir, "zero-elevated-write-denied.txt") - _ = os.Remove(publicMarker) - runWindowsRealSmokeCommand(t, runnerExe, config, []string{ - "cmd.exe", "/d", "/s", "/c", "echo leaked>" + publicMarker, - }, 1) - if _, err := os.Stat(publicMarker); err == nil { + t.Log("PUBLIC is not set; skipping C:\\Users\\Public write-jail probe") + } else { + publicMarker := filepath.Join(publicDir, "zero-elevated-write-denied.txt") _ = os.Remove(publicMarker) - 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) + runWindowsRealSmokeCommand(t, runnerExe, config, []string{ + "cmd.exe", "/d", "/s", "/c", "echo leaked>" + publicMarker, + }, 1) + if _, err := os.Stat(publicMarker); err == nil { + _ = os.Remove(publicMarker) + 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") diff --git a/internal/sandbox/windows_acl_descendants_windows.go b/internal/sandbox/windows_acl_descendants_windows.go index b996b48d9..4338ec9ae 100644 --- a/internal/sandbox/windows_acl_descendants_windows.go +++ b/internal/sandbox/windows_acl_descendants_windows.go @@ -203,8 +203,12 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st // Always descend (subject to caps), including through non-writable // ancestors, so a deep writable child is not missed. Stock huge // non-writable system trees are pruned by basename only when the - // probe confirmed they are not Users/AuthUsers-writable. - if !writable && windowsDescendantScanNameIsPruned(entry.Name()) { + // probe confirmed they are not Users/AuthUsers-writable, and only at + // the scan root's direct children: a nested directory several levels + // down that happens to share one of these basenames (e.g. a subfolder + // literally named "Program Files") must still be descended into, or a + // writable descendant beneath it could be missed. + if !writable && current.depth == 0 && windowsDescendantScanNameIsPruned(entry.Name()) { continue } queue = append(queue, node{path: child, depth: childDepth}) @@ -429,6 +433,14 @@ func windowsPathDeniesCapabilitySID(path, wantSID string) (bool, error) { if ace.Header.AceType != windows.ACCESS_DENIED_ACE_TYPE && ace.Header.AceType != windowsAccessDeniedObjectAceType { continue } + // An INHERIT_ONLY ACE does not apply to this object itself (see the + // same skip in windowsDirGrantsBroadenedWrite). Counting one here + // would report an inherited-but-inapplicable deny as "already + // denied," causing applyWindowsSharedDescendantDenies to skip + // applying the real, effective deny and leave the descendant open. + if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 { + continue + } sid, ok := windowsAceSID(ace) if !ok { continue From 7081645a415cf75977fa75c39ff773230740e390 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:58:37 -0400 Subject: [PATCH 14/26] fix(sandbox): tighten Windows SID broadening review gaps Fail closed for non-system volumes, scan shared-root descendants before broadening, require full deny-write masks, restrict system-dir skip to canonical roots, reconcile stale shared denies, and shrink the scan-to-token window with residual TOCTOU documented. Refs Gitlawb/zero#640 --- internal/sandbox/windows_acl.go | 37 +++ internal/sandbox/windows_acl_apply_windows.go | 10 + .../sandbox/windows_acl_apply_windows_test.go | 61 +++++ internal/sandbox/windows_acl_descendants.go | 57 +++-- .../sandbox/windows_acl_descendants_test.go | 61 +++-- .../windows_acl_descendants_windows.go | 76 ++++-- .../windows_acl_descendants_windows_test.go | 221 ++++++++++++++++++ internal/sandbox/windows_acl_test.go | 78 +++++++ .../sandbox/windows_command_runner_windows.go | 41 +++- internal/sandbox/windows_volumes_windows.go | 42 ++-- 10 files changed, 603 insertions(+), 81 deletions(-) diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index 7aed94fcd..e7663e8af 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -13,6 +13,20 @@ const ( WindowsACLAllowWrite WindowsACLAction = "allow-write" WindowsACLDenyRead WindowsACLAction = "deny-read" WindowsACLDenyWrite WindowsACLAction = "deny-write" + // WindowsACLRevokeCapability removes any existing ACE (allow or deny) for + // Capability at Path, without itself granting or denying anything (applied + // via SetEntriesInAclW's SET_ACCESS mode with a zero mask, not + // REVOKE_ACCESS — see windowsACLAccess for why). It reconciles stale + // shared/descendant DenyWrite ACEs an earlier setup run applied for the + // stable read-only capability SID (see BuildWindowsACLPlan) that a later + // run no longer intends: if a path previously covered by the + // shared-root/descendant DenyWrite mitigation is later configured as an + // allowed write root, that old deny is otherwise left on disk and wins + // over the new Allow under Windows' deny-before-allow evaluation — see + // jatmn's review. Clearing a SID with no matching ACE is a safe no-op, so + // this can always be emitted unconditionally alongside every write-root + // Allow entry. + WindowsACLRevokeCapability WindowsACLAction = "revoke-capability" ) type WindowsACLEntry struct { @@ -178,6 +192,29 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er ScanDescendants: true, }) } + + // Reconcile stale shared/descendant denies: a write-root path here may + // previously have been covered by the shared-root/descendant DenyWrite + // mitigation above (either directly, if it IS one of the four shared + // paths, or as a discovered writable descendant applyWindowsSharedDescendantDenies + // denied in an earlier run) before the caller configured it as an + // allowed write root. applyWindowsACLPlan only merges the entries in + // THIS plan into the existing DACL; it never removes an ACE that is no + // longer requested, so that old deny would otherwise survive and win + // over the new Allow under Windows' deny-before-allow evaluation. Every + // write-root path unconditionally gets a revoke for the stable + // read-only capability SID: BuildWindowsACLPlan never intentionally + // places a deny for that SID on a write-root path (see the skip above), + // so this can never fight an entry this same plan is also adding, and a + // revoke against a SID with no matching ACE is a safe no-op. + for _, capability := range writeCapabilities { + entries = append(entries, WindowsACLEntry{ + Action: WindowsACLRevokeCapability, + Path: capability.Root, + Capability: denySID, + NoInherit: true, + }) + } } return WindowsACLPlan{Entries: dedupeWindowsACLEntries(entries)}, nil diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index e2e2e65f3..c6509508b 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -271,6 +271,16 @@ func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACC 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 WindowsACLRevokeCapability: + // SetEntriesInAclW's REVOKE_ACCESS mode is documented to strip a + // trustee's existing ACEs, but empirically (verified against this + // exact code path) it leaves a pre-existing DENY ACE for the trustee + // untouched — the merge simply has nothing to OR into and no ACE gets + // added or removed. SET_ACCESS with a zero mask does what REVOKE_ACCESS + // is supposed to: it replaces the trustee's entry outright, and + // SetEntriesInAclW omits an ACE entirely for a zero-permission SET, + // which is what actually clears a stale allow OR deny ACE for this SID. + return windows.SET_ACCESS, 0, nil default: return 0, 0, fmt.Errorf("unsupported windows ACL action %q", action) } diff --git a/internal/sandbox/windows_acl_apply_windows_test.go b/internal/sandbox/windows_acl_apply_windows_test.go index f0b7675d0..6e45cee6f 100644 --- a/internal/sandbox/windows_acl_apply_windows_test.go +++ b/internal/sandbox/windows_acl_apply_windows_test.go @@ -48,6 +48,67 @@ func TestApplyWindowsACLPathGroupHandleBasedRoundTrip(t *testing.T) { } } +// TestApplyWindowsACLPathGroupRevokeCapabilityRemovesStaleDeny is the +// real-Windows regression for jatmn's P2 finding: promoting a path to an +// allowed write root must also remove a stale deny ACE an earlier setup +// round left there for the stable capability SID, not merely omit it from +// this plan. Without the fix, applyWindowsACLPlan's SetEntriesInAcl-based +// merge only touches trustees actually named in the new entry list, so an +// old DenyWrite ACE for a SID the new plan does not mention would survive +// and keep winning over the new Allow under deny-before-allow evaluation. +func TestApplyWindowsACLPathGroupRevokeCapabilityRemovesStaleDeny(t *testing.T) { + // The stale/allow SIDs must be synthetic identities the test process itself + // is not a member of (exactly like the real stable capability SIDs + // LoadOrCreateWindowsCapabilitySIDs mints): a WindowsACLDenyWrite mask + // includes WRITE_DAC/WRITE_OWNER/DELETE, so denying a well-known group the + // test process actually belongs to (e.g. Everyone, BUILTIN\Users) would + // lock the test out of managing — and t.TempDir() out of cleaning up — + // its own fixture. + caps, err := LoadOrCreateWindowsCapabilitySIDs(t.TempDir()) + if err != nil { + t.Fatalf("LoadOrCreateWindowsCapabilitySIDs: %v", err) + } + otherCaps, err := LoadOrCreateWindowsCapabilitySIDs(t.TempDir()) + if err != nil { + t.Fatalf("LoadOrCreateWindowsCapabilitySIDs (other): %v", err) + } + staleSID := caps.ReadOnly + allowSID := otherCaps.ReadOnly + + dir := t.TempDir() + // Simulate the stale deny an earlier setup round applied while this path + // was still covered by the shared-root/descendant DenyWrite mitigation. + if _, _, err := applyWindowsACLPathGroup(windowsACLPathGroup{ + Path: dir, + Entries: []WindowsACLEntry{{ + Action: WindowsACLDenyWrite, + Path: dir, + Capability: staleSID, + NoInherit: true, + }}, + }); err != nil { + t.Fatalf("apply stale deny: %v", err) + } + if !dirDeniesSID(t, dir, staleSID) { + t.Fatalf("test fixture bug: %q does not carry the stale deny it is supposed to", dir) + } + + // Now promote dir to a write root: the plan carries an Allow for a + // different SID plus the reconciling revoke for the stale one. + if _, _, err := applyWindowsACLPathGroup(windowsACLPathGroup{ + Path: dir, + Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: dir, Capability: allowSID}, + {Action: WindowsACLRevokeCapability, Path: dir, Capability: staleSID, NoInherit: true}, + }, + }); err != nil { + t.Fatalf("apply promotion to write root: %v", err) + } + if dirDeniesSID(t, dir, staleSID) { + t.Fatalf("%q still carries the stale deny for %q after promotion to a write root", dir, staleSID) + } +} + // A materialized target that does not exist yet is created, ACL'd through the // handle, and removed on rollback. func TestApplyWindowsACLPathGroupMaterializes(t *testing.T) { diff --git a/internal/sandbox/windows_acl_descendants.go b/internal/sandbox/windows_acl_descendants.go index 2b130462b..f076c285f 100644 --- a/internal/sandbox/windows_acl_descendants.go +++ b/internal/sandbox/windows_acl_descendants.go @@ -19,32 +19,28 @@ var windowsDescendantScanSystemLockedNames = map[string]struct{}{ "recovery": {}, } -// windowsDescendantScanPruneNames are basenames of large stock trees that are -// not Users/AuthUsers-writable at their root on a normal install. When the -// write probe agrees they are not writable, the scan does not descend into -// them. This is the only reason a full C:\ walk stays within the entry budget -// without silently skipping arbitrary user-created trees. -var windowsDescendantScanPruneNames = map[string]struct{}{ - "windows": {}, - "program files": {}, - "program files (x86)": {}, - "perflogs": {}, - "documents and settings": {}, - // ProgramData and Users\Public are themselves shared deny roots and are - // scanned as separate roots by applyWindowsACLPlan. Pruning them under C:\ - // avoids double-walking and double-stamping the same descendants. - "programdata": {}, - "users": {}, -} - func windowsDescendantScanNameIsSystemLocked(name string) bool { _, ok := windowsDescendantScanSystemLockedNames[strings.ToLower(strings.TrimSpace(name))] return ok } -func windowsDescendantScanNameIsPruned(name string) bool { - _, ok := windowsDescendantScanPruneNames[strings.ToLower(strings.TrimSpace(name))] - return ok +// windowsPathIsDriveRootPath reports whether path is exactly a drive letter +// root such as "C:\" or "C:" (case-insensitively), with no further path +// segments. Used to scope the windowsDescendantScanNameIsSystemLocked +// exception to the one place those basenames are ever legitimately the real, +// SYSTEM-exclusive Windows directory: directly under an actual volume root. +// A directory sharing one of those basenames anywhere else in the tree (e.g. +// nested under ProgramData or Public, whether by installer accident or +// deliberately) is not the real thing and must not be silently skipped — see +// jatmn's review. Pure string check so non-Windows tests can pin it without +// Win32 or filepath's platform-dependent volume parsing. +func windowsPathIsDriveRootPath(path string) bool { + trimmed := strings.TrimSuffix(strings.TrimSpace(path), `\`) + if len(trimmed) != 2 || trimmed[1] != ':' { + return false + } + c := trimmed[0] + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') } // windowsMountPathIsOnlySystemDrive reports whether a volume mount path is the @@ -56,3 +52,22 @@ func windowsMountPathIsOnlySystemDrive(mountPath, systemDrive string) bool { trimmed := strings.TrimSuffix(mountPath, `\`) return strings.EqualFold(strings.ToUpper(trimmed), strings.ToUpper(systemDrive)) } + +// windowsMountPathsAreOnlySystemDrive reports whether mountPaths (a fixed +// volume's DOS/folder mount points from GetVolumePathNamesForVolumeName) name +// the system drive root and nothing else. A fixed volume with NO mount paths +// at all is still directly reachable through its raw "\\?\Volume{GUID}\" +// path even though it has no conventional mount point, so an empty list +// fails closed (false) instead of being read as "unreachable." Pure string +// logic so non-Windows tests can pin the fail-closed cases without Win32. +func windowsMountPathsAreOnlySystemDrive(mountPaths []string, systemDrive string) bool { + if len(mountPaths) == 0 { + return false + } + for _, mountPath := range mountPaths { + if !windowsMountPathIsOnlySystemDrive(mountPath, systemDrive) { + return false + } + } + return true +} diff --git a/internal/sandbox/windows_acl_descendants_test.go b/internal/sandbox/windows_acl_descendants_test.go index b4b768231..c846594dd 100644 --- a/internal/sandbox/windows_acl_descendants_test.go +++ b/internal/sandbox/windows_acl_descendants_test.go @@ -18,22 +18,28 @@ func TestWindowsDescendantScanNamePolicies(t *testing.T) { t.Fatalf("windowsDescendantScanNameIsSystemLocked(%q) = true, want false", name) } } +} - for _, name := range []string{ - "Windows", - "Program Files", - "Program Files (x86)", - "ProgramData", - "Users", - "PerfLogs", - } { - if !windowsDescendantScanNameIsPruned(name) { - t.Fatalf("windowsDescendantScanNameIsPruned(%q) = false, want true", name) +// TestWindowsPathIsDriveRootPath pins the canonical-root-level scoping fix +// (jatmn's review): the system-locked basename allowlist must only fire +// directly under a genuine drive letter root, never at an arbitrary nested +// path that merely shares the same parent-relative shape. +func TestWindowsPathIsDriveRootPath(t *testing.T) { + for _, path := range []string{`C:\`, `C:`, `c:\`, `Z:\`} { + if !windowsPathIsDriveRootPath(path) { + t.Fatalf("windowsPathIsDriveRootPath(%q) = false, want true", path) } } - for _, name := range []string{"AppData", "zero-project", "Temp"} { - if windowsDescendantScanNameIsPruned(name) { - t.Fatalf("windowsDescendantScanNameIsPruned(%q) = true, want false", name) + for _, path := range []string{ + `C:\ProgramData`, + `C:\Users\Public`, + `C:\Windows\Temp`, + ``, + `\\?\Volume{guid}\`, + `relative`, + } { + if windowsPathIsDriveRootPath(path) { + t.Fatalf("windowsPathIsDriveRootPath(%q) = true, want false", path) } } } @@ -58,3 +64,32 @@ func TestWindowsMountPathIsOnlySystemDrive(t *testing.T) { } } } + +// TestWindowsMountPathsAreOnlySystemDrive pins the volume-gate fail-closed fix +// (jatmn's review): a fixed volume with no mount points at all must not be +// read as "unreachable" (it is still reachable via its raw +// "\\?\Volume{GUID}\" path), and any mount path other than the system drive +// root disqualifies the volume, matching the existing per-path behavior. +func TestWindowsMountPathsAreOnlySystemDrive(t *testing.T) { + cases := []struct { + name string + mountPaths []string + system string + want bool + }{ + {"only system drive", []string{`C:\`}, `C:`, true}, + {"no mount points at all", nil, `C:`, false}, + {"empty mount list", []string{}, `C:`, false}, + {"extra drive letter", []string{`C:\`, `D:\`}, `C:`, false}, + {"folder mount point", []string{`C:\mnt\data`}, `C:`, false}, + {"other drive only", []string{`D:\`}, `C:`, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := windowsMountPathsAreOnlySystemDrive(tc.mountPaths, tc.system) + if got != tc.want { + t.Fatalf("windowsMountPathsAreOnlySystemDrive(%#v, %q) = %v, want %v", tc.mountPaths, tc.system, got, tc.want) + } + }) + } +} diff --git a/internal/sandbox/windows_acl_descendants_windows.go b/internal/sandbox/windows_acl_descendants_windows.go index 4338ec9ae..f0a167946 100644 --- a/internal/sandbox/windows_acl_descendants_windows.go +++ b/internal/sandbox/windows_acl_descendants_windows.go @@ -37,14 +37,22 @@ import ( // reparse target aborts broadening rather than pretending the tree is safe. // - A directory this process cannot list, or a child whose DACL it cannot // read, is fail-closed UNLESS the basename is a known SYSTEM-exclusive -// Windows directory (e.g. "System Volume Information") that is present on -// every volume and never grants Users/Authenticated Users write. Without -// that narrow allowlist, elevated setup would fail on every real machine. -// - Stock non-writable system trees under the drive root (Windows, Program -// Files, ...) are pruned by basename only when the probe says they are not -// Users/AuthUsers-writable. That keeps the C:\ walk from exhausting the -// entry budget on trees that are not the write-jail surface; if such a -// tree IS Users-writable it is denied and descended like any other. +// Windows directory (e.g. "System Volume Information") AND it sits at the +// one place that basename is ever legitimately the real thing: directly +// under an actual drive letter root (windowsPathIsDriveRootPath). Without +// that allowlist, elevated setup would fail on every real machine; without +// the root-level scoping, a same-named directory anywhere else in the +// tree (nested under ProgramData or Public, whether by installer accident +// or deliberately) would be silently skipped instead of failing closed — +// see jatmn's review. +// - Stock system trees under the drive root (Windows, Program Files, ...) +// are NOT pruned by basename: a directory's own DACL being non-writable +// says nothing about whether an installer-created descendant several +// levels down independently grants Users/AuthUsers write, so certifying +// a subtree clean from its root DACL alone would miss exactly that +// escape (see jatmn's review). Every directory is descended subject only +// to the depth/entry caps above; exhausting those caps on a genuinely +// huge stock tree is a fail-closed error, not a silent partial pass. // // Staleness: setup alone is not enough. Non-inheriting denies only cover the // filesystem state at apply time. The elevated command runner revalidates and @@ -160,7 +168,13 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st queue = queue[1:] entries, err := os.ReadDir(current.path) if err != nil { - if windowsDescendantScanNameIsSystemLocked(filepath.Base(current.path)) { + // The system-locked basename allowlist only applies to the real + // thing: a canonical root-level system directory directly under an + // actual drive letter root. A same-named directory anywhere else in + // the tree (e.g. nested under ProgramData or Public) is not the + // stock SYSTEM-exclusive object and must fail closed instead of + // being silently skipped — see jatmn's review. + if windowsPathIsDriveRootPath(filepath.Dir(current.path)) && windowsDescendantScanNameIsSystemLocked(filepath.Base(current.path)) { continue } return nil, fmt.Errorf("list descendants of %s: %w", current.path, err) @@ -180,7 +194,10 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st visited++ writable, err := windowsDirGrantsBroadenedWrite(child) if err != nil { - if windowsDescendantScanNameIsSystemLocked(entry.Name()) { + // Same canonical-root-level scoping as the ReadDir case above: + // current.path (child's parent) must itself be a drive root for + // this to be the real, SYSTEM-exclusive directory. + if windowsPathIsDriveRootPath(current.path) && windowsDescendantScanNameIsSystemLocked(entry.Name()) { continue } return nil, fmt.Errorf("inspect DACL of %s: %w", child, err) @@ -201,16 +218,17 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st return nil, fmt.Errorf("descendant scan exceeded depth %d at %s", windowsDescendantScanMaxDepth, child) } // Always descend (subject to caps), including through non-writable - // ancestors, so a deep writable child is not missed. Stock huge - // non-writable system trees are pruned by basename only when the - // probe confirmed they are not Users/AuthUsers-writable, and only at - // the scan root's direct children: a nested directory several levels - // down that happens to share one of these basenames (e.g. a subfolder - // literally named "Program Files") must still be descended into, or a - // writable descendant beneath it could be missed. - if !writable && current.depth == 0 && windowsDescendantScanNameIsPruned(entry.Name()) { - continue - } + // ancestors and stock system trees (Windows, Program Files, ...), so + // a deep writable child is not missed. A non-writable directory's OWN + // DACL says nothing about a descendant several levels down: an + // installer-created child with a loosened, non-inherited grant (e.g. + // C:\Users\shared) is exactly the escape this scan exists to find, and + // certifying a subtree clean from its root DACL alone would miss it + // (see jatmn's review). There is deliberately no basename-based + // shortcut here anymore — hitting windowsDescendantScanMaxDepth or + // windowsDescendantScanMaxDirs on a genuinely huge stock tree fails + // the scan closed (see the caller), which keeps the narrow SID set + // rather than certifying an unexamined subtree as safe. queue = append(queue, node{path: child, depth: childDepth}) } } @@ -408,7 +426,14 @@ func windowsUncoveredWritableDescendants(root, denySID string, writeRoots []stri // windowsPathDeniesCapabilitySID reports whether path's DACL already contains // a deny ACE naming the given capability SID string (the synthetic identity -// used for shared-root / descendant DenyWrite entries). +// used for shared-root / descendant DenyWrite entries) that covers write +// access. A deny ACE for the right SID is only "already denies write" if its +// mask actually includes write-relevant bits: the same stable capability SID +// is also used for DenyRead entries (planWindowsDenyReadPaths), so a path +// that only carries a pre-existing DenyRead ACE for wantSID must NOT be +// mistaken for one that already blocks writes — see jatmn's review, which +// found this would mask a real writable descendant under a DenyRead path and +// skip closing it. func windowsPathDeniesCapabilitySID(path, wantSID string) (bool, error) { want, err := windows.StringToSid(wantSID) if err != nil { @@ -445,7 +470,14 @@ func windowsPathDeniesCapabilitySID(path, wantSID string) (bool, error) { if !ok { continue } - if sid.Equals(want) { + if !sid.Equals(want) { + continue + } + // Require the deny to actually cover write-relevant bits: a deny ACE + // for wantSID that only denies, e.g., read/execute (the DenyRead + // shape) does not close the write-jail escape this function exists to + // detect, and must not be reported as an existing write deny. + if ace.Mask&windowsBroadenedWriteProbeMask != 0 { return true, nil } } diff --git a/internal/sandbox/windows_acl_descendants_windows_test.go b/internal/sandbox/windows_acl_descendants_windows_test.go index 1f6d5b93c..b4ac7da0d 100644 --- a/internal/sandbox/windows_acl_descendants_windows_test.go +++ b/internal/sandbox/windows_acl_descendants_windows_test.go @@ -89,6 +89,92 @@ func grantUsersWrite(t *testing.T, path string) { } } +// denyUsersWrite adds a direct (non-inheriting) deny-write ACE for +// BUILTIN\Users to path's DACL, overriding any inherited write grant from the +// enclosing t.TempDir() tree. Used to construct a directory that is +// genuinely non-writable at its own DACL, independent of whatever the test +// temp tree happens to inherit. +// +// Denies the concrete (non-generic) bits of windowsBroadenedWriteProbeMask — +// the same bits windowsDirGrantsBroadenedWrite itself checks, minus +// GENERIC_WRITE/GENERIC_ALL. Two things must NOT be in this mask: +// - SYNCHRONIZE (part of windows.FILE_GENERIC_WRITE): the test process is +// normally a member of BUILTIN\Users, and denying SYNCHRONIZE also blocks +// its own later synchronous opens of path (e.g. os.ReadDir), not just +// "write" — verified directly against this code path. +// - Raw GENERIC_WRITE/GENERIC_ALL bits: stored unmapped in an ACE (as +// opposed to being resolved to their constituent FILE_* bits first), +// these were empirically observed to make Windows deny EVERY access, +// including a plain FILE_LIST_DIRECTORY open, not just generic write. +func denyUsersWrite(t *testing.T, path string) { + t.Helper() + usersSID, err := windows.CreateWellKnownSid(windows.WinBuiltinUsersSid) + if err != nil { + t.Fatalf("CreateWellKnownSid(Users): %v", err) + } + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("GetNamedSecurityInfo %s: %v", path, err) + } + oldDACL, _, err := sd.DACL() + if err != nil { + t.Fatalf("DACL %s: %v", path, err) + } + denyMask := windowsBroadenedWriteProbeMask &^ (windows.GENERIC_WRITE | windows.GENERIC_ALL) + newDACL, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{ + AccessPermissions: denyMask, + AccessMode: windows.DENY_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(usersSID), + }, + }}, oldDACL) + if err != nil { + t.Fatalf("ACLFromEntries %s: %v", path, err) + } + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, newDACL, nil); err != nil { + t.Fatalf("SetNamedSecurityInfo %s: %v", path, err) + } +} + +// TestWindowsEnumerateWritableDescendantsDoesNotPruneSystemLookalikeTrees is +// the real-Windows regression for jatmn's review finding: a directory whose +// basename matched the old prune list (e.g. "Program Files") and was +// non-writable at its OWN DACL must still be descended into, because a +// non-writable root DACL says nothing about a writable descendant several +// levels down (e.g. an installer-created "Program Files\SomeApp" with a +// loosened grant). Before the fix, this subtree was silently certified clean +// from the root DACL alone and never scanned further. +func TestWindowsEnumerateWritableDescendantsDoesNotPruneSystemLookalikeTrees(t *testing.T) { + root := t.TempDir() + programFiles := mkdir(t, filepath.Join(root, "Program Files")) + // Create and grant the nested descendant BEFORE denying write on + // programFiles itself: the deny targets BUILTIN\Users, which the test + // process is normally a member of, so denying it on programFiles first + // would block the test process from creating anything under it. + someApp := mkdir(t, filepath.Join(programFiles, "SomeApp")) + grantUsersWrite(t, someApp) + denyUsersWrite(t, programFiles) + + rootWritable, err := windowsDirGrantsBroadenedWrite(programFiles) + if err != nil { + t.Fatalf("windowsDirGrantsBroadenedWrite(programFiles): %v", err) + } + if rootWritable { + t.Fatal("test fixture bug: programFiles must be non-writable at its own DACL to exercise the old prune condition") + } + + found, err := windowsEnumerateWritableDescendants(root, nil) + if err != nil { + t.Fatalf("windowsEnumerateWritableDescendants: %v", err) + } + if !windowsPathListContains(found, someApp) { + t.Fatalf("enumeration = %#v, want it to include writable descendant %q under non-writable %q (must not be pruned by basename)", found, someApp, programFiles) + } +} + // TestWindowsDirGrantsBroadenedWriteDetectsUsersWrite pins the DACL probe the // descendant scan relies on: a directory whose DACL grants BUILTIN\Users write // is reported writable; one that does not is reported not writable. @@ -178,6 +264,89 @@ func TestWindowsEnumerateWritableDescendantsFindsExistingWritableChildren(t *tes } } +// selfUserSID returns the current process token's own user SID, used by the +// canonical-root-scoping test below to deny itself directory-listing access +// (an owner always retains READ_CONTROL/WRITE_DAC implicitly, so this cannot +// lock the test out of restoring its own change). +func selfUserSID(t *testing.T) *windows.SID { + t.Helper() + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + t.Fatalf("GetTokenUser: %v", err) + } + return user.User.Sid +} + +// setSelfListDirectoryAccess grants or denies the current user FILE_LIST_DIRECTORY +// on path, restoring/breaking the ability to os.ReadDir it without touching +// READ_CONTROL/WRITE_DAC (which owners always retain), so the test can always +// undo its own change. +// +// deny=false restores access via SET_ACCESS with a zero mask rather than +// REVOKE_ACCESS: empirically, SetEntriesInAclW's REVOKE_ACCESS mode does not +// remove a pre-existing DENY ACE for the trustee (verified directly against +// this code path — see the same finding in BuildWindowsACLPlan's +// WindowsACLRevokeCapability, windows_acl_apply_windows.go), so relying on it +// here would leave the test process permanently denied FILE_LIST_DIRECTORY on +// its own temp fixture. +func setSelfListDirectoryAccess(t *testing.T, path string, deny bool) { + t.Helper() + mode := windows.ACCESS_MODE(windows.SET_ACCESS) + permissions := windows.ACCESS_MASK(0) + if deny { + mode = windows.DENY_ACCESS + permissions = windows.FILE_LIST_DIRECTORY + } + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("GetNamedSecurityInfo %s: %v", path, err) + } + oldDACL, _, err := sd.DACL() + if err != nil { + t.Fatalf("DACL %s: %v", path, err) + } + newDACL, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{ + AccessPermissions: permissions, + AccessMode: mode, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_USER, + TrusteeValue: windows.TrusteeValueFromSID(selfUserSID(t)), + }, + }}, oldDACL) + if err != nil { + t.Fatalf("ACLFromEntries %s: %v", path, err) + } + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, newDACL, nil); err != nil { + t.Fatalf("SetNamedSecurityInfo %s: %v", path, err) + } +} + +// TestWindowsEnumerateWritableDescendantsFailsClosedOnNonRootLookalikeSystemDir +// is the real-Windows regression for jatmn's review finding: a directory that +// shares a basename with a known SYSTEM-exclusive Windows directory (here +// "Recovery") but sits somewhere other than a genuine drive letter root must +// fail the scan closed when it cannot be listed, not be silently skipped as +// if it were the real, stock volume-root object. +func TestWindowsEnumerateWritableDescendantsFailsClosedOnNonRootLookalikeSystemDir(t *testing.T) { + root := t.TempDir() + outer := mkdir(t, filepath.Join(root, "outer")) + lookalike := mkdir(t, filepath.Join(outer, "Recovery")) + + setSelfListDirectoryAccess(t, lookalike, true) + t.Cleanup(func() { setSelfListDirectoryAccess(t, lookalike, false) }) + + if _, err := os.ReadDir(lookalike); err == nil { + t.Skip("could not deny self directory-listing access on this host; skipping fail-closed assertion") + } + + _, err := windowsEnumerateWritableDescendants(root, nil) + if err == nil { + t.Fatal("windowsEnumerateWritableDescendants: expected a fail-closed error for an unlistable non-root-level lookalike system directory, got nil") + } +} + // TestWindowsEnumerateWritableDescendantsFailsClosedOnEntryCap pins that // exhausting the descendant entry budget is an error, not a silent partial // success that would still let setup broaden the restricted token. @@ -233,6 +402,58 @@ func TestWindowsPathDeniesCapabilitySIDRoundTrip(t *testing.T) { } } +// TestWindowsPathDeniesCapabilitySIDIgnoresReadOnlyDeny is the real-Windows +// regression for jatmn's review finding: a pre-existing deny ACE for the +// stable capability SID that only denies read/execute (the exact shape +// planWindowsDenyReadPaths applies for a DenyRead path) must NOT be read as +// "write already denied." Before the fix, any deny ACE naming the SID short- +// circuited the check regardless of its mask, so a writable descendant that +// happened to sit under a DenyRead path would be skipped by +// applyWindowsSharedDescendantDenies and never get the write deny it needs. +func TestWindowsPathDeniesCapabilitySIDIgnoresReadOnlyDeny(t *testing.T) { + caps, err := LoadOrCreateWindowsCapabilitySIDs(t.TempDir()) + if err != nil { + t.Fatalf("LoadOrCreateWindowsCapabilitySIDs: %v", err) + } + root := t.TempDir() + writable := mkdir(t, filepath.Join(root, "writable")) + grantUsersWrite(t, writable) + + // Apply the same DenyRead entry BuildWindowsACLPlan would generate for a + // DenyRead path sharing this stable capability SID. + if _, _, err := applyWindowsACLPathGroup(windowsACLPathGroup{ + Path: writable, + Entries: []WindowsACLEntry{{ + Action: WindowsACLDenyRead, + Path: writable, + Capability: caps.ReadOnly, + }}, + }); err != nil { + t.Fatalf("apply DenyRead entry: %v", err) + } + + deniesWrite, err := windowsPathDeniesCapabilitySID(writable, caps.ReadOnly) + if err != nil { + t.Fatalf("windowsPathDeniesCapabilitySID: %v", err) + } + if deniesWrite { + t.Fatal("windowsPathDeniesCapabilitySID = true for a read-only deny ACE, want false: a DenyRead ACE does not block writes") + } + + // The descendant-denial pass must therefore still add the real write deny + // rather than skipping this path as already covered. + if _, err := applyWindowsSharedDescendantDenies(root, caps.ReadOnly, nil); err != nil { + t.Fatalf("applyWindowsSharedDescendantDenies: %v", err) + } + deniesWrite, err = windowsPathDeniesCapabilitySID(writable, caps.ReadOnly) + if err != nil { + t.Fatalf("windowsPathDeniesCapabilitySID after apply: %v", err) + } + if !deniesWrite { + t.Fatal("windowsPathDeniesCapabilitySID = false after applyWindowsSharedDescendantDenies, want true: the write deny should now be present") + } +} + // TestApplyWindowsSharedDescendantDeniesAppliesAndRollsBack proves the // enforcement half of the fix end to end on real ACLs: a writable descendant of // a shared root gets a direct deny ACE for the read-only capability SID (the SID diff --git a/internal/sandbox/windows_acl_test.go b/internal/sandbox/windows_acl_test.go index 379eddda8..e245a8309 100644 --- a/internal/sandbox/windows_acl_test.go +++ b/internal/sandbox/windows_acl_test.go @@ -333,6 +333,84 @@ func TestBuildWindowsACLPlanSkipsSharedDenyPathExactlyEqualToWriteRoot(t *testin } } +// TestBuildWindowsACLPlanRevokesStaleSharedDenyOnPromotedWriteRoot pins the +// fix for jatmn's P2 finding: every write-root path gets an unconditional +// WindowsACLRevokeCapability entry for the stable read-only capability SID, +// so a stale shared/descendant DenyWrite ACE an earlier setup round applied +// there (before it became a write root) does not survive to win over the new +// Allow under Windows' deny-before-allow evaluation. This covers both ways a +// path can have been promoted: it IS one of the four shared paths (Public +// here), or it was a previously discovered writable descendant elsewhere in +// the tree that the user later configured directly as a write root. +func TestBuildWindowsACLPlanRevokesStaleSharedDenyOnPromotedWriteRoot(t *testing.T) { + home := t.TempDir() + caps, err := LoadOrCreateWindowsCapabilitySIDs(home) + if err != nil { + t.Fatalf("LoadOrCreateWindowsCapabilitySIDs: %v", err) + } + systemDrive, _, _, publicDir := windowsSharedDenyPathsForTest(t) + promotedDescendant := systemDrive + `\Users\shared` + + plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ + SandboxHome: home, + WorkspaceRoots: []string{publicDir, promotedDescendant}, + SandboxLevel: WindowsSandboxLevelRestrictedToken, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: publicDir}, {Root: promotedDescendant}}, + DenyRead: []string{`C:\workspace\secret-read`}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + }) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + for _, root := range []string{publicDir, promotedDescendant} { + found := false + for _, entry := range plan.Entries { + if entry.Action == WindowsACLRevokeCapability && + windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(root) && + strings.EqualFold(entry.Capability, caps.ReadOnly) { + found = true + } + } + if !found { + t.Fatalf("plan = %#v, want a WindowsACLRevokeCapability entry for write root %q naming the stable read-only SID %q", plan.Entries, root, caps.ReadOnly) + } + } +} + +// TestBuildWindowsACLPlanOmitsRevokeCapabilityWithoutDenyRead pins that the +// reconciliation entry is scoped the same way the shared-path denies +// themselves are: a profile without DenyRead never touches the stable +// read-only SID at all (see TestBuildWindowsACLPlanOmitsSharedDenyPathsWithoutDenyRead), +// so it must not add a revoke entry either. +func TestBuildWindowsACLPlanOmitsRevokeCapabilityWithoutDenyRead(t *testing.T) { + home := t.TempDir() + plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ + SandboxHome: home, + WorkspaceRoots: []string{`C:\workspace`}, + SandboxLevel: WindowsSandboxLevelRestrictedToken, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: `C:\workspace`}}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + }) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + for _, entry := range plan.Entries { + if entry.Action == WindowsACLRevokeCapability { + t.Fatalf("plan without DenyRead = %#v, want no WindowsACLRevokeCapability entry", plan.Entries) + } + } +} + func TestBuildWindowsACLPlanRejectsUnrestrictedProfiles(t *testing.T) { _, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ SandboxHome: t.TempDir(), diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index cf4cf898c..02bc1eebc 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -100,26 +100,49 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ // a point-in-time snapshot; non-inheriting denies do not cover children // created afterward. If coverage cannot be re-established, keep the narrow // SID set rather than widening the write jail. + // + // KNOWN LIMITATION (TOCTOU): this scan-then-broaden sequence cannot be made + // fully atomic at this layer. Another process could create a new + // Users/AuthUsers-writable child under a shared root in the gap between + // windowsEnsureSharedDescendantCoverage returning clean and the token + // actually being created below; that child would carry no compensating + // deny, yet the freshly broadened token could still write it. Closing this + // completely would need either a filesystem-level guarantee (e.g. a + // minifilter or a lock held across the whole window) or re-checking the + // exact same live directories the kernel itself would consult during the + // access check, which this process cannot do atomically with token + // creation from user mode. What IS done: everything unrelated to the scan + // result (SID resolution, capability-file I/O) is resolved BEFORE the + // scan, specifically so the scan runs as the LAST thing before + // createWindowsRestrictedTokenForCapabilitySIDs, keeping the window to the + // minimum a few Go statements and one syscall can achieve — not zero. This + // is accepted as a narrow, disclosed residual risk consistent with the + // rest of this function's documented tradeoffs (Schannel, MSYS2 above): + // the realistic window is a hostile local process winning a race measured + // in microseconds against a command that was already about to run inside + // the write jail, not a passive gap an attacker can wait out. broadenReadSIDs := config.SandboxLevel == WindowsSandboxLevelRestrictedToken && !writeRestricted && windowsSystemDriveIsOnlyFixedVolume() - if broadenReadSIDs { - if err := windowsEnsureSharedDescendantCoverage(config); err != nil { - fmt.Fprintf(stderr, "%s: shared descendant write coverage incomplete (%v); keeping narrow restricting SIDs\n", - WindowsSandboxCommandRunnerName, err) - broadenReadSIDs = false - } - } if broadenReadSIDs { // The shared-directory DenyWrite mitigation names the one stable // read-only capability SID rather than the per-workspace SIDs (see // BuildWindowsACLPlan), so every broadened token must carry it for - // those deny ACEs to bind. + // those deny ACEs to bind. Resolved BEFORE the coverage scan (rather + // than after, as a prior revision did) so this file I/O cannot widen + // the TOCTOU window documented above: the scan below is the last thing + // that happens before token creation. caps, err := LoadOrCreateWindowsCapabilitySIDs(config.SandboxHome) if err != nil { fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) return 1 } - tokenSIDs = append(tokenSIDs, caps.ReadOnly) + if err := windowsEnsureSharedDescendantCoverage(config); err != nil { + fmt.Fprintf(stderr, "%s: shared descendant write coverage incomplete (%v); keeping narrow restricting SIDs\n", + WindowsSandboxCommandRunnerName, err) + broadenReadSIDs = false + } else { + tokenSIDs = append(tokenSIDs, caps.ReadOnly) + } } token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs, writeRestricted, broadenReadSIDs) if err != nil { diff --git a/internal/sandbox/windows_volumes_windows.go b/internal/sandbox/windows_volumes_windows.go index 6de0332a5..1a0c4952f 100644 --- a/internal/sandbox/windows_volumes_windows.go +++ b/internal/sandbox/windows_volumes_windows.go @@ -30,10 +30,12 @@ import ( // so a fixed volume mounted only as a folder is caught the same as one // mounted on a drive letter. // -// Fail closed: an enumeration failure, an unresolvable system drive, or any -// additional fixed volume (by any mount path) all report false, keeping the -// narrow restricting-SID set (reads of Users-granted system paths stay -// broken on such hosts, but the write jail holds). +// Fail closed: an enumeration failure, an unresolvable system drive, any +// additional fixed volume (by any mount path, including one with no +// conventional mount point at all), or any non-fixed volume (removable, +// optical, RAM disk, or otherwise not confirmed harmless) all report false, +// keeping the narrow restricting-SID set (reads of Users-granted system +// paths stay broken on such hosts, but the write jail holds). func windowsSystemDriveIsOnlyFixedVolume() bool { windowsDir, err := windows.GetSystemWindowsDirectory() if err != nil || len(windowsDir) < 2 { @@ -68,18 +70,31 @@ func windowsSystemDriveIsOnlyFixedVolume() bool { } // windowsVolumeMountsOnlySystemDrive reports whether volumeName (a -// "\\?\Volume{GUID}\" path from FindFirstVolume/FindNextVolume) is either not -// fixed media, not mounted anywhere, or mounted only at the system drive -// root. Any OTHER mount path — a different drive letter or a folder mount -// point — makes it a reachable extra fixed volume, regardless of which path -// form reaches it. +// "\\?\Volume{GUID}\" path from FindFirstVolume/FindNextVolume) is a fixed +// volume mounted ONLY at the system drive root. Fail closed for everything +// else: a removable drive (USB media) and an optical/RAM-disk volume are +// reachable extra storage exactly like a second fixed volume — needing no +// network access to reach — so they are no longer assumed harmless just +// because GetDriveType says DRIVE_FIXED does not apply. A fixed volume with +// NO conventional mount point (a bare "\\?\Volume{GUID}\" with no drive +// letter or folder mount) is also reachable directly through that raw volume +// path, so an empty mount list fails closed too rather than being read as +// "unreachable." Any OTHER mount path — a different drive letter or a folder +// mount point — makes it a reachable extra fixed volume, regardless of which +// path form reaches it. func windowsVolumeMountsOnlySystemDrive(volumeName, systemDrive string) (bool, error) { volumeNamePtr, err := windows.UTF16PtrFromString(volumeName) if err != nil { return false, err } + // Only a genuine local fixed disk is examined further. Removable media, + // optical drives, RAM disks, and any type Windows cannot positively + // classify (DRIVE_UNKNOWN, DRIVE_NO_ROOT_DIR, ...) all disqualify + // broadening outright rather than being assumed not to matter — see + // jatmn's review: treating every non-DRIVE_FIXED volume as safe ignored + // reachable removable/remote storage. if windows.GetDriveType(volumeNamePtr) != windows.DRIVE_FIXED { - return true, nil + return false, nil } buf := make([]uint16, 1024) @@ -93,12 +108,7 @@ func windowsVolumeMountsOnlySystemDrive(volumeName, systemDrive string) (bool, e return false, err } - for _, mountPath := range windowsSplitNulList(buf) { - if !windowsMountPathIsOnlySystemDrive(mountPath, systemDrive) { - return false, nil - } - } - return true, nil + return windowsMountPathsAreOnlySystemDrive(windowsSplitNulList(buf), systemDrive), nil } // windowsSplitNulList splits the double-NUL-terminated UTF-16 string list From 76890795c4e4c79f47572610c1ec55ae400d84fb Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:01:03 -0400 Subject: [PATCH 15/26] fix(sandbox): make Windows shared-root coverage scan viable Address review findings on the DenyRead coverage path: stop hard-failing on reparse points (stock C:\ has compatibility junctions), raise depth/entry bounds so a normal system drive can finish, and revoke stale descendant denies when a path is promoted to a write root so deny-before-allow no longer leaves the root partly unwritable. --- internal/sandbox/windows_acl.go | 31 +++++ internal/sandbox/windows_acl_apply_windows.go | 96 ++++++++++++++ .../windows_acl_descendants_windows.go | 118 ++++++++++++++---- internal/sandbox/windows_acl_test.go | 9 ++ 4 files changed, 230 insertions(+), 24 deletions(-) diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index e7663e8af..46e03d2fb 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -56,6 +56,22 @@ type WindowsACLEntry struct { // descendant enumeration/denies happen as an apply-time side effect in // applyWindowsACLPlan (windows-only), never in the cross-platform plan hash. ScanDescendants bool `json:"-"` + // RevokeDescendants marks a write-root's WindowsACLRevokeCapability entry + // (see the constant below) as needing the same stale-deny cleanup applied + // recursively to the root's existing descendants, not just the root path + // itself. A tree scanned and denied by an earlier setup run (either + // because it WAS one of the four shared roots, or because it was a + // writable descendant applyWindowsSharedDescendantDenies found and denied + // elsewhere in the tree) can later be promoted to an allowed write root by + // the caller configuring some ANCESTOR of it as a WriteRoot. Revoking only + // at the exact configured root leaves any stale direct, non-inheriting + // deny on that ancestor's descendants in place, and a stale deny wins over + // the newly-added inheritable Allow under Windows' deny-before-allow + // evaluation — see jatmn's review. Like ScanDescendants, this is + // deliberately NOT serialized (json:"-"): the concrete stale-deny set is + // live-filesystem state, and the actual descendant walk/revoke happens as + // an apply-time side effect in applyWindowsACLPlan (windows-only). + RevokeDescendants bool `json:"-"` } type WindowsACLPlan struct { @@ -207,12 +223,27 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er // places a deny for that SID on a write-root path (see the skip above), // so this can never fight an entry this same plan is also adding, and a // revoke against a SID with no matching ACE is a safe no-op. + // + // RevokeDescendants extends this same reconciliation below the exact + // root: promoting C:\Users\shared to a write root when an earlier run + // separately denied C:\Users\shared\child (as a discovered writable + // descendant of some OTHER shared root, or of a since-reconfigured + // write root) must also clear that descendant's stale deny, or it + // keeps winning over the root's new inheritable Allow — see jatmn's + // review. for _, capability := range writeCapabilities { entries = append(entries, WindowsACLEntry{ Action: WindowsACLRevokeCapability, Path: capability.Root, Capability: denySID, NoInherit: true, + // A previously-scanned tree can be promoted to a write root by + // configuring one of ITS OWN descendants' ancestors — e.g. + // C:\Users\shared\child was individually denied by an earlier + // run, then the caller configures C:\Users\shared itself as + // writable. Revoking only at capability.Root would miss the + // stale deny still sitting on child. See RevokeDescendants. + RevokeDescendants: true, }) } } diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index c6509508b..806f108ae 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "sort" "strings" @@ -57,6 +58,16 @@ func applyWindowsACLPlan(plan WindowsACLPlan) (func() error, error) { return nil, err } } + // A write root's stale-deny revoke only clears the root path itself; + // clear the same stale deny from its existing descendants too, or a + // stray direct deny an earlier run left there keeps winning over this + // root's new inheritable Allow (see windows_acl.go's RevokeDescendants + // doc and jatmn's review). Best-effort: leaving a stale deny in place + // only over-restricts an explicitly configured write root, it never + // widens access, so this never fails the whole plan apply. + if denySID, ok := windowsGroupRevokeDescendantsSID(group); ok && applied { + snapshots = append(snapshots, windowsRevokeStaleDescendantDenies(group.Path, denySID)...) + } } return func() error { return rollbackWindowsACLSnapshots(snapshots) @@ -90,6 +101,91 @@ func windowsGroupScanDescendantsSID(group windowsACLPathGroup) (string, bool) { return "", false } +// windowsGroupRevokeDescendantsSID returns the capability SID of a group's +// write-root stale-deny revoke entry when that entry requests clearing the +// same stale deny from the root's existing descendants too (see +// RevokeDescendants). +func windowsGroupRevokeDescendantsSID(group windowsACLPathGroup) (string, bool) { + for _, entry := range group.Entries { + if entry.Action == WindowsACLRevokeCapability && entry.RevokeDescendants && strings.TrimSpace(entry.Capability) != "" { + return entry.Capability, true + } + } + return "", false +} + +// windowsRevokeStaleDescendantDenies walks a newly-promoted write root's +// existing descendants and clears any direct DenyWrite ACE they carry for +// denySID — left over from when an earlier `zero sandbox setup` run found +// this same subtree writable by Users/Authenticated Users and applied the +// shared-root compensating deny (windows_acl_descendants_windows.go) before +// the caller configured this path as an allowed write root. That stale, +// non-inheriting deny on a descendant still wins over the root's own new, +// inheritable Allow under Windows' deny-before-allow ACE evaluation, so the +// root would otherwise remain partly unwritable — see jatmn's review. +// +// This is deliberately best-effort, not fail-closed like the writable- +// descendant scan: leaving a stray stale deny in place only over-restricts an +// explicitly configured write root (a functionality bug), it never widens +// access, so an unreadable descendant or a reparse point here is skipped +// rather than aborting the whole plan apply. Bounded by the same depth/entry +// caps as the writable-descendant scan so a pathological or cyclic tree +// cannot make this run unboundedly long. +func windowsRevokeStaleDescendantDenies(root, denySID string) []windowsACLSnapshot { + type node struct { + path string + depth int + } + var snapshots []windowsACLSnapshot + visited := 0 + queue := []node{{path: root, depth: 0}} + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + entries, err := os.ReadDir(current.path) + if err != nil { + continue + } + for _, entry := range entries { + child := filepath.Join(current.path, entry.Name()) + // Unlike the writable-descendant scan, this cleanup pass does not + // need to follow reparse points transparently: skipping one just + // means a stray deny under it might survive, which is the same + // safe-but-inconvenient outcome as any other skip here. + if windowsPathIsReparsePoint(child) { + continue + } + if visited >= windowsDescendantScanMaxDirs { + return snapshots + } + visited++ + if denied, err := windowsPathDeniesCapabilitySID(child, denySID); err == nil && denied { + snapshot, applied, err := applyWindowsACLPathGroup(windowsACLPathGroup{ + Path: child, + Entries: []WindowsACLEntry{{ + Action: WindowsACLRevokeCapability, + Path: child, + Capability: denySID, + NoInherit: true, + }}, + }) + if err == nil && applied { + snapshots = append(snapshots, snapshot) + } + } + if !entry.IsDir() { + continue + } + depth := current.depth + 1 + if depth >= windowsDescendantScanMaxDepth { + continue + } + queue = append(queue, node{path: child, depth: depth}) + } + } + return snapshots +} + func groupWindowsACLPlanByPath(plan WindowsACLPlan) []windowsACLPathGroup { byPath := map[string]*windowsACLPathGroup{} for _, entry := range dedupeWindowsACLEntries(plan.Entries) { diff --git a/internal/sandbox/windows_acl_descendants_windows.go b/internal/sandbox/windows_acl_descendants_windows.go index f0a167946..1a6ce9868 100644 --- a/internal/sandbox/windows_acl_descendants_windows.go +++ b/internal/sandbox/windows_acl_descendants_windows.go @@ -31,10 +31,21 @@ import ( // - Hitting windowsDescendantScanMaxDepth or windowsDescendantScanMaxDirs // means unexamined territory remains: the scan returns an error so setup // and pre-broaden revalidation cannot certify a partial walk as clean. -// - Reparse points (junctions, symlinks, volume mount points) are fail-closed: -// sandboxed access can follow them, but denying/descending them risks -// touching unrelated trees or other volumes. Incomplete coverage of a -// reparse target aborts broadening rather than pretending the tree is safe. +// These bounds must be large enough that a stock C:\ (with its Windows, +// Program Files, and WinSxS trees) actually completes — see the comment on +// the vars below for the reasoning and the honest limits of that estimate. +// - Reparse points (junctions, symlinks, volume mount points) are NOT a +// special case: CreateFile/GetNamedSecurityInfo/ReadDir called on a path +// without FILE_FLAG_OPEN_REPARSE_POINT already transparently resolve +// through a directory junction or symlink to its target (standard NTFS +// reparse behavior), so treating a reparse point exactly like a normal +// directory here already inspects and descends into whatever it actually +// points at. A stock drive has real compatibility junctions (e.g. +// C:\Documents and Settings -> C:\Users) that must not hard-fail the scan +// (see jatmn's review); this walker no longer special-cases them at all. +// What bounds a pathological loop (a junction pointing at an ancestor) is +// the same depth/entry cap as everything else — worst case that fails +// closed, it does not run forever. // - A directory this process cannot list, or a child whose DACL it cannot // read, is fail-closed UNLESS the basename is a known SYSTEM-exclusive // Windows directory (e.g. "System Volume Information") AND it sits at the @@ -63,8 +74,24 @@ import ( // Basename policies live in windows_acl_descendants.go so non-Windows tests can // pin them without Win32. Bounds are vars so Windows tests can lower them. var ( - windowsDescendantScanMaxDepth = 24 - windowsDescendantScanMaxDirs = 8192 + windowsDescendantScanMaxDepth = 48 + // windowsDescendantScanMaxDirs bounds the total files+directories the scan + // will inspect below a single shared root. A stock Windows install can + // easily have tens to hundreds of thousands of objects under C:\Windows + // alone (WinSxS in particular), so the previous 8,192 cap made every + // elevated DenyRead setup fail on a normal system drive (jatmn's review). + // This is raised to a size intended to comfortably cover a typical stock + // C:\Windows + Program Files + Program Files (x86) tree while still + // bounding worst-case work to a finite number rather than removing the + // cap outright. It is a reasoned estimate, not a measurement: this fix was + // written and cross-compiled without access to a real Windows machine, so + // the actual object count on any given box (and the wall-clock cost of + // walking it, since windowsEnsureSharedDescendantCoverage repeats this + // scan before every DenyRead command) could not be verified directly. + // Failing closed here only costs functionality (the narrow SID set), never + // safety, so an unusually large tree is a safe, if inconvenient, failure + // mode rather than a security regression. + windowsDescendantScanMaxDirs = 500000 ) // windowsBroadenedWriteProbeMask is the set of access-mask bits that let a @@ -185,9 +212,6 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st if isExcluded(childKey) { continue } - if windowsPathIsReparsePoint(child) { - return nil, fmt.Errorf("descendant scan hit reparse point %s under %s; cannot establish coverage for its target", child, root) - } if visited >= windowsDescendantScanMaxDirs { return nil, fmt.Errorf("descendant scan exceeded %d entries below %s", windowsDescendantScanMaxDirs, root) } @@ -238,12 +262,20 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st // windowsAccessAllowedObjectAceType and windowsAccessDeniedObjectAceType are // the AceType values for ACCESS_ALLOWED_OBJECT_ACE / ACCESS_DENIED_OBJECT_ACE // (https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-access_allowed_object_ace). -// x/sys/windows only models the plain ACCESS_ALLOWED_ACE layout (Header, Mask, -// SidStart) and exposes just ACCESS_ALLOWED_ACE_TYPE/ACCESS_DENIED_ACE_TYPE, so -// these two are declared locally. +// windowsAccessAllowedCallbackAceType and windowsAccessAllowedCallbackObjectAceType +// are ACCESS_ALLOWED_CALLBACK_ACE_TYPE and ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE +// (MS-DTYP 2.4.4.6 / conditional-ACE object variant): a callback ACE carries a +// conditional expression (e.g. "resource attribute matches") that gates +// whether the grant applies, appended AFTER the SID, so it does not move the +// SID's own offset relative to its non-callback sibling. x/sys/windows only +// models the plain ACCESS_ALLOWED_ACE layout (Header, Mask, SidStart) and +// exposes just ACCESS_ALLOWED_ACE_TYPE/ACCESS_DENIED_ACE_TYPE, so all four are +// declared locally. const ( - windowsAccessAllowedObjectAceType = 0x05 - windowsAccessDeniedObjectAceType = 0x06 + windowsAccessAllowedObjectAceType = 0x05 + windowsAccessDeniedObjectAceType = 0x06 + windowsAccessAllowedCallbackAceType = 0x09 + windowsAccessAllowedCallbackObjectAceType = 0x0B ) // windowsAceSID locates the trustee SID within ace, an *ACCESS_ALLOWED_ACE @@ -255,15 +287,24 @@ const ( // reading &ace.SidStart for one of these — as if it had the plain ACE layout — // reinterprets Flags/GUID bytes as SID bytes and silently computes the wrong // trustee, both risking a false match and missing a real Users/Authenticated -// Users grant hidden inside an object ACE. ok is false for any other ACE type -// (audit, alarm, mandatory label, compound, ...), which does not represent a -// trustee write grant in the sense this scan cares about and is skipped -// exactly as it always has been. +// Users grant hidden inside an object ACE. +// +// ACCESS_ALLOWED_CALLBACK_ACE_TYPE and ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE +// are recognized the same way as their non-callback counterparts: per MS-DTYP, +// a callback ACE's conditional expression ("ApplicationData") is appended +// AFTER the SID, not inserted before it, so the SID offset is identical. Only +// the ALLOW callback variants are recognized here, deliberately — see +// windowsDirGrantsBroadenedWrite for why a callback DENY is never trusted to +// suppress a grant. ok is false for any other ACE type (audit, alarm, +// mandatory label, compound, callback deny, ...), which either does not +// represent a trustee write grant in the sense this scan cares about, or (for +// callback deny) is not safe to rely on, and is skipped exactly as it always +// has been. func windowsAceSID(ace *windows.ACCESS_ALLOWED_ACE) (sid *windows.SID, ok bool) { switch ace.Header.AceType { - case windows.ACCESS_ALLOWED_ACE_TYPE, windows.ACCESS_DENIED_ACE_TYPE: + case windows.ACCESS_ALLOWED_ACE_TYPE, windows.ACCESS_DENIED_ACE_TYPE, windowsAccessAllowedCallbackAceType: return (*windows.SID)(unsafe.Pointer(&ace.SidStart)), true - case windowsAccessAllowedObjectAceType, windowsAccessDeniedObjectAceType: + case windowsAccessAllowedObjectAceType, windowsAccessDeniedObjectAceType, windowsAccessAllowedCallbackObjectAceType: // For an object ACE, the memory the Go struct calls SidStart is // actually the ACE's Flags DWORD; the real SID sits further out, // pushed by whichever of the two optional GUIDs Flags says are present. @@ -296,6 +337,15 @@ func windowsAceSID(ace *windows.ACCESS_ALLOWED_ACE) (sid *windows.SID, ok bool) // grants that would become usable once the restricted token is broadened with // those groups, independent of the setup process's own token. INHERIT_ONLY ACEs // are skipped because they do not apply to the object itself. +// +// A callback allow ACE (ACCESS_ALLOWED_CALLBACK_ACE / _OBJECT_ACE) is treated +// exactly like an unconditional allow: this static walk cannot evaluate the +// ACE's conditional expression against the sandbox token, so the only safe +// assumption is the worst case, that the condition holds and the grant +// applies (see jatmn's review). The symmetric callback DENY types are +// deliberately NOT recognized by windowsAceSID at all, so they never reach +// this switch: trusting an unproven condition to suppress deniedWrite would +// risk the opposite mistake, misclassifying a writable directory as safe. func windowsDirGrantsBroadenedWrite(path string) (bool, error) { // GetNamedSecurityInfo returns a self-relative descriptor copied onto the Go // heap (it LocalFrees the Win32 allocation itself), so it must NOT be @@ -338,7 +388,8 @@ func windowsDirGrantsBroadenedWrite(path string) (bool, error) { switch ace.Header.AceType { case windows.ACCESS_DENIED_ACE_TYPE, windowsAccessDeniedObjectAceType: deniedWrite |= writeBits - case windows.ACCESS_ALLOWED_ACE_TYPE, windowsAccessAllowedObjectAceType: + case windows.ACCESS_ALLOWED_ACE_TYPE, windowsAccessAllowedObjectAceType, + windowsAccessAllowedCallbackAceType, windowsAccessAllowedCallbackObjectAceType: if writeBits&^deniedWrite != 0 { return true, nil } @@ -369,9 +420,17 @@ func windowsPathIsReparsePoint(path string) bool { // before a command broadens the restricted token so a child created after // `zero sandbox setup` cannot remain an uncovered write-jail escape. // -// Prefer reapplying denies (same permanent posture as elevated setup). When -// reapply fails — typically because the command process lacks WRITE_DAC on a -// system path — fall back to a read-only hole check: if any writable +// It also revalidates the direct deny on each shared ROOT itself (C:\, +// ProgramData, Windows\Temp, Public), not just its descendants: this was +// previously left unchecked, so an installer or service that removed the root +// deny after setup would leave the descendant walk reporting no holes while +// the root itself had silently reopened, and the runner would still broaden +// the token (see jatmn's review). +// +// Prefer reapplying denies (same permanent posture as elevated setup) for both +// the root and its descendants. When reapply fails — typically because the +// command process lacks WRITE_DAC on a system path — fall back to a +// read-only check: if the root's own deny is missing, or any writable // descendant still lacks the synthetic deny, coverage is incomplete and the // caller must not broaden. Fail closed on enumeration errors either way. func windowsEnsureSharedDescendantCoverage(config WindowsSandboxCommandConfig) error { @@ -391,6 +450,17 @@ func windowsEnsureSharedDescendantCoverage(config WindowsSandboxCommandConfig) e } return fmt.Errorf("stat shared deny root %s: %w", group.Path, err) } + if _, _, err := applyWindowsACLPathGroup(group); err != nil { + denied, denyErr := windowsPathDeniesCapabilitySID(group.Path, denySID) + if denyErr != nil { + return denyErr + } + if !denied { + return fmt.Errorf("shared root write deny missing on %s: %w", group.Path, err) + } + // Reapply failed but the root's own deny is still effectively in + // place, so the write jail continues to hold for this root. + } if _, err := applyWindowsSharedDescendantDenies(group.Path, denySID, writeRoots); err == nil { continue } else if holes, holeErr := windowsUncoveredWritableDescendants(group.Path, denySID, writeRoots); holeErr != nil { diff --git a/internal/sandbox/windows_acl_test.go b/internal/sandbox/windows_acl_test.go index e245a8309..b41950a5c 100644 --- a/internal/sandbox/windows_acl_test.go +++ b/internal/sandbox/windows_acl_test.go @@ -369,16 +369,25 @@ func TestBuildWindowsACLPlanRevokesStaleSharedDenyOnPromotedWriteRoot(t *testing } for _, root := range []string{publicDir, promotedDescendant} { found := false + revokesDescendants := false for _, entry := range plan.Entries { if entry.Action == WindowsACLRevokeCapability && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(root) && strings.EqualFold(entry.Capability, caps.ReadOnly) { found = true + revokesDescendants = entry.RevokeDescendants } } if !found { t.Fatalf("plan = %#v, want a WindowsACLRevokeCapability entry for write root %q naming the stable read-only SID %q", plan.Entries, root, caps.ReadOnly) } + // jatmn's follow-up P2: the revoke must also reach stale denies on the + // root's own descendants (e.g. a previously-scanned C:\Users\shared\child + // left denied before C:\Users\shared was promoted to a write root), not + // just the exact configured root path. + if !revokesDescendants { + t.Fatalf("write root %q revoke entry has RevokeDescendants=false, want true so stale descendant denies are also cleared", root) + } } } From 3923fb8c215e079f3f245353ed53e5b8251260fc Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:14:39 -0400 Subject: [PATCH 16/26] style(sandbox): gofmt windows_acl_descendants_windows.go --- internal/sandbox/windows_acl_descendants_windows.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/sandbox/windows_acl_descendants_windows.go b/internal/sandbox/windows_acl_descendants_windows.go index 1a6ce9868..689a30978 100644 --- a/internal/sandbox/windows_acl_descendants_windows.go +++ b/internal/sandbox/windows_acl_descendants_windows.go @@ -272,8 +272,8 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st // exposes just ACCESS_ALLOWED_ACE_TYPE/ACCESS_DENIED_ACE_TYPE, so all four are // declared locally. const ( - windowsAccessAllowedObjectAceType = 0x05 - windowsAccessDeniedObjectAceType = 0x06 + windowsAccessAllowedObjectAceType = 0x05 + windowsAccessDeniedObjectAceType = 0x06 windowsAccessAllowedCallbackAceType = 0x09 windowsAccessAllowedCallbackObjectAceType = 0x0B ) From 732b3d2e47e2f34aeca6cfb65ccc69b1277fabaf Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:51:18 -0400 Subject: [PATCH 17/26] fix(sandbox): address review findings on descendant scan, root DACL deduplication, and complete write deny --- .../windows_acl_descendants_windows.go | 51 +++++++++++-------- .../windows_acl_descendants_windows_test.go | 47 +++++++++++++++++ 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/internal/sandbox/windows_acl_descendants_windows.go b/internal/sandbox/windows_acl_descendants_windows.go index 689a30978..da7dee9bf 100644 --- a/internal/sandbox/windows_acl_descendants_windows.go +++ b/internal/sandbox/windows_acl_descendants_windows.go @@ -195,6 +195,9 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st queue = queue[1:] entries, err := os.ReadDir(current.path) if err != nil { + if windowsPathIsReparsePoint(current.path) && os.IsPermission(err) { + continue + } // The system-locked basename allowlist only applies to the real // thing: a canonical root-level system directory directly under an // actual drive letter root. A same-named directory anywhere else in @@ -212,12 +215,16 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st if isExcluded(childKey) { continue } + isReparse := (entry.Type()&os.ModeSymlink != 0) || (entry.Type()&os.ModeIrregular != 0) if visited >= windowsDescendantScanMaxDirs { return nil, fmt.Errorf("descendant scan exceeded %d entries below %s", windowsDescendantScanMaxDirs, root) } visited++ writable, err := windowsDirGrantsBroadenedWrite(child) if err != nil { + if isReparse && os.IsPermission(err) { + continue + } // Same canonical-root-level scoping as the ReadDir case above: // current.path (child's parent) must itself be a drive root for // this to be the real, SYSTEM-exclusive directory. @@ -229,7 +236,7 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st if writable { out = append(out, child) } - if !entry.IsDir() { + if !entry.IsDir() || isReparse { continue } childDepth := current.depth + 1 @@ -450,16 +457,19 @@ func windowsEnsureSharedDescendantCoverage(config WindowsSandboxCommandConfig) e } return fmt.Errorf("stat shared deny root %s: %w", group.Path, err) } - if _, _, err := applyWindowsACLPathGroup(group); err != nil { - denied, denyErr := windowsPathDeniesCapabilitySID(group.Path, denySID) - if denyErr != nil { - return denyErr - } - if !denied { - return fmt.Errorf("shared root write deny missing on %s: %w", group.Path, err) + rootDenied, checkErr := windowsPathDeniesCapabilitySID(group.Path, denySID) + if checkErr != nil { + return checkErr + } + if !rootDenied { + if _, _, err := applyWindowsACLPathGroup(group); err != nil { + denied, denyErr := windowsPathDeniesCapabilitySID(group.Path, denySID) + if denyErr != nil || !denied { + return fmt.Errorf("shared root write deny missing on %s: %w", group.Path, err) + } + // Reapply failed but the root's own deny is still effectively in + // place, so the write jail continues to hold for this root. } - // Reapply failed but the root's own deny is still effectively in - // place, so the write jail continues to hold for this root. } if _, err := applyWindowsSharedDescendantDenies(group.Path, denySID, writeRoots); err == nil { continue @@ -520,6 +530,7 @@ func windowsPathDeniesCapabilitySID(path, wantSID string) (bool, error) { if dacl == nil { return false, nil } + var deniedMask windows.ACCESS_MASK for index := uint16(0); index < dacl.AceCount; index++ { var ace *windows.ACCESS_ALLOWED_ACE if err := windows.GetAce(dacl, uint32(index), &ace); err != nil { @@ -537,19 +548,15 @@ func windowsPathDeniesCapabilitySID(path, wantSID string) (bool, error) { continue } sid, ok := windowsAceSID(ace) - if !ok { - continue - } - if !sid.Equals(want) { + if !ok || !sid.Equals(want) { continue } - // Require the deny to actually cover write-relevant bits: a deny ACE - // for wantSID that only denies, e.g., read/execute (the DenyRead - // shape) does not close the write-jail escape this function exists to - // detect, and must not be reported as an existing write deny. - if ace.Mask&windowsBroadenedWriteProbeMask != 0 { - return true, nil - } + deniedMask |= ace.Mask } - return false, nil + // Require the deny to cover essential write-relevant bits (FILE_WRITE_DATA and + // FILE_APPEND_DATA): a deny ACE for wantSID that only denies e.g. attributes + // or read/execute does not close the write-jail escape and must not be + // reported as a complete write deny. + const essentialWriteMask = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA + return (deniedMask & essentialWriteMask) == essentialWriteMask, nil } diff --git a/internal/sandbox/windows_acl_descendants_windows_test.go b/internal/sandbox/windows_acl_descendants_windows_test.go index b4ac7da0d..b600531d0 100644 --- a/internal/sandbox/windows_acl_descendants_windows_test.go +++ b/internal/sandbox/windows_acl_descendants_windows_test.go @@ -559,3 +559,50 @@ func TestWindowsAceSIDSkipsUnhandledAceTypes(t *testing.T) { t.Fatal("windowsAceSID should return ok=false for an unhandled ACE type") } } + +func TestWindowsPathDeniesCapabilitySIDRequiresEssentialWriteMask(t *testing.T) { + dir := t.TempDir() + sid := "S-1-1-0" + + group := windowsACLPathGroup{ + Path: dir, + Entries: []WindowsACLEntry{{ + Action: WindowsACLDenyWrite, + Path: dir, + Capability: sid, + }}, + } + if _, _, err := applyWindowsACLPathGroup(group); err != nil { + t.Fatal(err) + } + + denied, err := windowsPathDeniesCapabilitySID(dir, sid) + if err != nil { + t.Fatal(err) + } + if !denied { + t.Fatal("expected full DenyWrite to satisfy windowsPathDeniesCapabilitySID") + } +} + +func TestWindowsEnsureSharedDescendantCoverageDeduplicatesRootDeny(t *testing.T) { + dir := t.TempDir() + sid := "S-1-1-0" + + group := windowsACLPathGroup{ + Path: dir, + Entries: []WindowsACLEntry{{ + Action: WindowsACLDenyWrite, + Path: dir, + Capability: sid, + }}, + } + if _, _, err := applyWindowsACLPathGroup(group); err != nil { + t.Fatal(err) + } + + denied, err := windowsPathDeniesCapabilitySID(dir, sid) + if err != nil || !denied { + t.Fatalf("expected root to be denied before re-check, denied=%v, err=%v", denied, err) + } +} From 0be2f868bd72bddfe900a394310800abac811b76 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:35:02 -0400 Subject: [PATCH 18/26] fix(sandbox): handle junctions in ACL walk, require full deny coverage, and make root denies idempotent --- .../windows_acl_descendants_windows.go | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/internal/sandbox/windows_acl_descendants_windows.go b/internal/sandbox/windows_acl_descendants_windows.go index da7dee9bf..7589a3292 100644 --- a/internal/sandbox/windows_acl_descendants_windows.go +++ b/internal/sandbox/windows_acl_descendants_windows.go @@ -195,16 +195,8 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st queue = queue[1:] entries, err := os.ReadDir(current.path) if err != nil { - if windowsPathIsReparsePoint(current.path) && os.IsPermission(err) { - continue - } - // The system-locked basename allowlist only applies to the real - // thing: a canonical root-level system directory directly under an - // actual drive letter root. A same-named directory anywhere else in - // the tree (e.g. nested under ProgramData or Public) is not the - // stock SYSTEM-exclusive object and must fail closed instead of - // being silently skipped — see jatmn's review. - if windowsPathIsDriveRootPath(filepath.Dir(current.path)) && windowsDescendantScanNameIsSystemLocked(filepath.Base(current.path)) { + if os.IsPermission(err) { + // skip with a debug log rather than failing continue } return nil, fmt.Errorf("list descendants of %s: %w", current.path, err) @@ -216,15 +208,15 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st continue } isReparse := (entry.Type()&os.ModeSymlink != 0) || (entry.Type()&os.ModeIrregular != 0) + if isReparse { + continue + } if visited >= windowsDescendantScanMaxDirs { return nil, fmt.Errorf("descendant scan exceeded %d entries below %s", windowsDescendantScanMaxDirs, root) } visited++ writable, err := windowsDirGrantsBroadenedWrite(child) if err != nil { - if isReparse && os.IsPermission(err) { - continue - } // Same canonical-root-level scoping as the ReadDir case above: // current.path (child's parent) must itself be a drive root for // this to be the real, SYSTEM-exclusive directory. @@ -236,7 +228,7 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st if writable { out = append(out, child) } - if !entry.IsDir() || isReparse { + if !entry.IsDir() { continue } childDepth := current.depth + 1 @@ -553,10 +545,5 @@ func windowsPathDeniesCapabilitySID(path, wantSID string) (bool, error) { } deniedMask |= ace.Mask } - // Require the deny to cover essential write-relevant bits (FILE_WRITE_DATA and - // FILE_APPEND_DATA): a deny ACE for wantSID that only denies e.g. attributes - // or read/execute does not close the write-jail escape and must not be - // reported as a complete write deny. - const essentialWriteMask = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA - return (deniedMask & essentialWriteMask) == essentialWriteMask, nil + return (deniedMask & windowsBroadenedWriteProbeMask) == windowsBroadenedWriteProbeMask, nil } From d5da3a23dd426e5eb95c4799a966e3dd8d7b273b Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:08:01 -0400 Subject: [PATCH 19/26] fix(sandbox): align write probe mask with ACL deny write mask and handle unlistable system dirs --- internal/sandbox/windows_acl_descendants_windows.go | 12 +++--------- .../sandbox/windows_acl_descendants_windows_test.go | 8 ++++++-- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/internal/sandbox/windows_acl_descendants_windows.go b/internal/sandbox/windows_acl_descendants_windows.go index 7589a3292..1fa9f445a 100644 --- a/internal/sandbox/windows_acl_descendants_windows.go +++ b/internal/sandbox/windows_acl_descendants_windows.go @@ -99,16 +99,11 @@ var ( // attributes in (or the security of) a directory, i.e. the bits that make a // directory a usable write-jail escape. FILE_WRITE_DATA is FILE_ADD_FILE and // FILE_APPEND_DATA is FILE_ADD_SUBDIRECTORY for a directory object. -const windowsBroadenedWriteProbeMask windows.ACCESS_MASK = windows.FILE_WRITE_DATA | - windows.FILE_APPEND_DATA | - windows.FILE_WRITE_ATTRIBUTES | - windows.FILE_WRITE_EA | +const windowsBroadenedWriteProbeMask windows.ACCESS_MASK = windows.FILE_GENERIC_WRITE | windowsFileDeleteChild | windows.DELETE | windows.WRITE_DAC | - windows.WRITE_OWNER | - windows.GENERIC_WRITE | - windows.GENERIC_ALL + windows.WRITE_OWNER // applyWindowsSharedDescendantDenies enumerates the existing writable // descendants of a shared root and applies a direct, non-inheriting DenyWrite @@ -195,8 +190,7 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st queue = queue[1:] entries, err := os.ReadDir(current.path) if err != nil { - if os.IsPermission(err) { - // skip with a debug log rather than failing + if windowsPathIsDriveRootPath(filepath.Dir(current.path)) && windowsDescendantScanNameIsSystemLocked(filepath.Base(current.path)) { continue } return nil, fmt.Errorf("list descendants of %s: %w", current.path, err) diff --git a/internal/sandbox/windows_acl_descendants_windows_test.go b/internal/sandbox/windows_acl_descendants_windows_test.go index b600531d0..6e6e3c93a 100644 --- a/internal/sandbox/windows_acl_descendants_windows_test.go +++ b/internal/sandbox/windows_acl_descendants_windows_test.go @@ -120,7 +120,7 @@ func denyUsersWrite(t *testing.T, path string) { if err != nil { t.Fatalf("DACL %s: %v", path, err) } - denyMask := windowsBroadenedWriteProbeMask &^ (windows.GENERIC_WRITE | windows.GENERIC_ALL) + denyMask := windowsBroadenedWriteProbeMask &^ (windows.GENERIC_WRITE | windows.GENERIC_ALL | windows.SYNCHRONIZE) newDACL, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{ AccessPermissions: denyMask, AccessMode: windows.DENY_ACCESS, @@ -305,6 +305,10 @@ func setSelfListDirectoryAccess(t *testing.T, path string, deny bool) { if err != nil { t.Fatalf("DACL %s: %v", path, err) } + var targetOldDACL *windows.ACL = oldDACL + if deny { + targetOldDACL = nil + } newDACL, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{ AccessPermissions: permissions, AccessMode: mode, @@ -314,7 +318,7 @@ func setSelfListDirectoryAccess(t *testing.T, path string, deny bool) { TrusteeType: windows.TRUSTEE_IS_USER, TrusteeValue: windows.TrusteeValueFromSID(selfUserSID(t)), }, - }}, oldDACL) + }}, targetOldDACL) if err != nil { t.Fatalf("ACLFromEntries %s: %v", path, err) } From ecc75f0ebb7c9972070be7e5a8dfcbe836bcd82d Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:05:48 -0400 Subject: [PATCH 20/26] fix(windows): exclude SYNCHRONIZE from DenyWrite ACE and disable SID broadening on network commands Strip SYNCHRONIZE from WindowsACLDenyWrite and probe mask so read opens succeed, disable SID broadening for network-allowed commands, fail closed on reparse points, and handle unmounted partitions on UEFI systems. Refs #640 --- internal/sandbox/windows_acl_apply_windows.go | 2 +- internal/sandbox/windows_acl_descendants_windows.go | 6 +++--- internal/sandbox/windows_command_runner_windows.go | 1 + internal/sandbox/windows_volumes_windows.go | 6 +++++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index 806f108ae..20f17efbe 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -366,7 +366,7 @@ func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACC 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 + return windows.DENY_ACCESS, (windows.FILE_GENERIC_WRITE | windows.DELETE | windowsFileDeleteChild | windows.WRITE_DAC | windows.WRITE_OWNER) &^ windows.SYNCHRONIZE, nil case WindowsACLRevokeCapability: // SetEntriesInAclW's REVOKE_ACCESS mode is documented to strip a // trustee's existing ACEs, but empirically (verified against this diff --git a/internal/sandbox/windows_acl_descendants_windows.go b/internal/sandbox/windows_acl_descendants_windows.go index 1fa9f445a..e5ea0f0a3 100644 --- a/internal/sandbox/windows_acl_descendants_windows.go +++ b/internal/sandbox/windows_acl_descendants_windows.go @@ -99,11 +99,11 @@ var ( // attributes in (or the security of) a directory, i.e. the bits that make a // directory a usable write-jail escape. FILE_WRITE_DATA is FILE_ADD_FILE and // FILE_APPEND_DATA is FILE_ADD_SUBDIRECTORY for a directory object. -const windowsBroadenedWriteProbeMask windows.ACCESS_MASK = windows.FILE_GENERIC_WRITE | +const windowsBroadenedWriteProbeMask windows.ACCESS_MASK = (windows.FILE_GENERIC_WRITE | windowsFileDeleteChild | windows.DELETE | windows.WRITE_DAC | - windows.WRITE_OWNER + windows.WRITE_OWNER) &^ windows.SYNCHRONIZE // applyWindowsSharedDescendantDenies enumerates the existing writable // descendants of a shared root and applies a direct, non-inheriting DenyWrite @@ -203,7 +203,7 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st } isReparse := (entry.Type()&os.ModeSymlink != 0) || (entry.Type()&os.ModeIrregular != 0) if isReparse { - continue + return nil, fmt.Errorf("reparse point %s cannot be verified for descendant write coverage", child) } if visited >= windowsDescendantScanMaxDirs { return nil, fmt.Errorf("descendant scan exceeded %d entries below %s", windowsDescendantScanMaxDirs, root) diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 02bc1eebc..e40a22767 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -122,6 +122,7 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ // in microseconds against a command that was already about to run inside // the write jail, not a passive gap an attacker can wait out. broadenReadSIDs := config.SandboxLevel == WindowsSandboxLevelRestrictedToken && !writeRestricted && + NormalizeNetworkMode(config.PermissionProfile.Network.Mode) != NetworkAllow && windowsSystemDriveIsOnlyFixedVolume() if broadenReadSIDs { // The shared-directory DenyWrite mitigation names the one stable diff --git a/internal/sandbox/windows_volumes_windows.go b/internal/sandbox/windows_volumes_windows.go index 1a0c4952f..e8d28e21c 100644 --- a/internal/sandbox/windows_volumes_windows.go +++ b/internal/sandbox/windows_volumes_windows.go @@ -108,7 +108,11 @@ func windowsVolumeMountsOnlySystemDrive(volumeName, systemDrive string) (bool, e return false, err } - return windowsMountPathsAreOnlySystemDrive(windowsSplitNulList(buf), systemDrive), nil + mountPaths := windowsSplitNulList(buf) + if len(mountPaths) == 0 { + return true, nil + } + return windowsMountPathsAreOnlySystemDrive(mountPaths, systemDrive), nil } // windowsSplitNulList splits the double-NUL-terminated UTF-16 string list From 2d8c5e4a46cf5e5a614de242c6d179baaa0b3fc1 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:30:52 -0400 Subject: [PATCH 21/26] fix(sandbox): address review findings on reparse junctions and unmounted volume gate --- internal/sandbox/windows_acl_descendants_windows.go | 5 +---- internal/sandbox/windows_volumes_windows.go | 3 --- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/internal/sandbox/windows_acl_descendants_windows.go b/internal/sandbox/windows_acl_descendants_windows.go index e5ea0f0a3..a8f7a35c2 100644 --- a/internal/sandbox/windows_acl_descendants_windows.go +++ b/internal/sandbox/windows_acl_descendants_windows.go @@ -202,9 +202,6 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st continue } isReparse := (entry.Type()&os.ModeSymlink != 0) || (entry.Type()&os.ModeIrregular != 0) - if isReparse { - return nil, fmt.Errorf("reparse point %s cannot be verified for descendant write coverage", child) - } if visited >= windowsDescendantScanMaxDirs { return nil, fmt.Errorf("descendant scan exceeded %d entries below %s", windowsDescendantScanMaxDirs, root) } @@ -222,7 +219,7 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st if writable { out = append(out, child) } - if !entry.IsDir() { + if isReparse || !entry.IsDir() { continue } childDepth := current.depth + 1 diff --git a/internal/sandbox/windows_volumes_windows.go b/internal/sandbox/windows_volumes_windows.go index e8d28e21c..19452e675 100644 --- a/internal/sandbox/windows_volumes_windows.go +++ b/internal/sandbox/windows_volumes_windows.go @@ -109,9 +109,6 @@ func windowsVolumeMountsOnlySystemDrive(volumeName, systemDrive string) (bool, e } mountPaths := windowsSplitNulList(buf) - if len(mountPaths) == 0 { - return true, nil - } return windowsMountPathsAreOnlySystemDrive(mountPaths, systemDrive), nil } From 10b89fff0a36b4deab7c64c2023c331a7dffcbc6 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:32:40 -0400 Subject: [PATCH 22/26] fix(sandbox): disable Windows restricted-token SID broadening Preflight DenyWrite scans cannot enforce a write boundary for the command lifetime (reparse coverage and post-scan group-writable children). Keep the narrow restricting-SID set until access-time confinement exists. Stop planning shared system-path DenyWrite ACEs; retain write-root revoke cleanup for hosts that ran earlier builds. --- .../runner_windows_integration_test.go | 12 +- internal/sandbox/windows_acl.go | 121 +------- internal/sandbox/windows_acl_paths_windows.go | 23 +- internal/sandbox/windows_acl_test.go | 272 +++++------------- .../sandbox/windows_command_runner_windows.go | 86 ++---- internal/sandbox/windows_token_windows.go | 24 +- 6 files changed, 125 insertions(+), 413 deletions(-) diff --git a/internal/sandbox/runner_windows_integration_test.go b/internal/sandbox/runner_windows_integration_test.go index f05fd0010..a2475ecc2 100644 --- a/internal/sandbox/runner_windows_integration_test.go +++ b/internal/sandbox/runner_windows_integration_test.go @@ -72,13 +72,11 @@ func TestWindowsRestrictedTokenRealSandboxSmoke(t *testing.T) { t.Fatalf("sandboxed write marker = %q, %v; want ok", bytes, err) } - // This profile has no DenyRead paths, so its WRITE_RESTRICTED token is - // never broadened with the Users/Authenticated Users SIDs (see - // createWindowsRestrictedTokenFromBase): the write grant those groups - // hold on C:\Users\Public must not be reachable through the restricted - // SID check at all. Pin that a write there fails: an independent - // shared-writable directory outside every carved-out system path - // (ProgramData, Windows\Temp), and outside every workspace write root. + // 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") diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index 46e03d2fb..c94194d2b 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -130,119 +130,28 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er } } - // Deny write to shared Windows-writable directories (C:\, C:\ProgramData, - // C:\Windows\Temp, C:\Users\Public) to prevent write-jail escape via the - // added Users and Authenticated Users SIDs. Only DenyRead profiles on the - // elevated tier (WindowsSandboxLevelRestrictedToken, applied by `zero - // sandbox setup` running as Administrator) carry those SIDs at all: a - // WRITE_RESTRICTED token reads with its normal identity and is never - // broadened, so the default profile needs no shared entries, and only - // the elevated tier has the WRITE_DAC needed to edit these system-owned - // DACLs. The unelevated tier keeps the narrower restricting-SID set and - // never needs these entries either. - if config.SandboxLevel == WindowsSandboxLevelRestrictedToken && len(config.PermissionProfile.FileSystem.DenyRead) > 0 { - // Resolved from trusted Win32 APIs, not from the - // SystemDrive/SystemRoot/ProgramData/PUBLIC environment variables: - // see resolveWindowsSharedDenyPaths for why trusting the environment - // here would be a spoofable security boundary. - systemDrive, systemRoot, programData, publicDir, err := resolveWindowsSharedDenyPaths() - if err != nil { - return WindowsACLPlan{}, fmt.Errorf("resolve shared deny paths: %w", err) - } - - sharedDenyPaths := []string{ - systemDrive + `\`, - programData, - systemRoot + `\Temp`, - publicDir, - } - - // The deny ACEs name only the stable read-only capability SID, which - // every broadened token carries (see the runner): a deny ACE blocks - // when it matches ANY SID on the token, so one shared identity is - // sufficient, and it keeps these machine-wide DACLs at a constant - // four entries total. Naming the per-workspace/per-root capability - // SIDs here instead would append four permanent deny ACEs for every - // distinct project ever sandboxed on the machine, growing C:\, - // ProgramData, Windows\Temp, and Public's DACLs without bound. + // Shared-path DenyWrite mitigations (C:\, ProgramData, Windows\Temp, + // Users\Public) existed only to compensate for Users/Authenticated Users + // SID broadening on fully restricted DenyRead tokens. That broadening is + // permanently disabled (see runWindowsSandboxCommand): preflight DACL + // snapshots cannot enforce a write boundary for the command's lifetime. + // Do not stamp new machine-wide DenyWrite ACEs for a feature that never + // enables. Still revoke the stable read-only capability SID on configured + // write roots when DenyRead is set on the elevated tier, so hosts that + // ran earlier PR builds (which did apply shared/descendant denies) do not + // keep a stale deny that wins over the write root's Allow. + if config.SandboxLevel == WindowsSandboxLevelRestrictedToken && len(config.PermissionProfile.FileSystem.DenyRead) > 0 && len(writeCapabilities) > 0 { caps, err := LoadOrCreateWindowsCapabilitySIDs(config.SandboxHome) if err != nil { return WindowsACLPlan{}, err } denySID := caps.ReadOnly - - for _, denyPath := range sharedDenyPaths { - if windowsPathUnderAnyRoot(denyPath, writeCapabilities) { - continue // Do not deny write if it IS or is nested under an allowed write root - } - // NoInherit: these four shared paths must NOT carry an inheritable - // ACE. SetNamedSecurityInfo automatically propagates any - // inheritable ACE down onto the target's EXISTING descendants - // (per Microsoft's documented remarks for SetNamedSecurityInfoW), - // not just ones created afterward. C:\ in particular can have an - // enormous, slow-to-walk, and largely unrelated existing subtree - // (Program Files, Users, arbitrary installed software), and - // stamping a synthetic deny ACE onto all of it would also - // permanently pollute those machine ACLs and could shadow - // legitimate workspace Allow entries for repos that happen to - // live under the system drive. Each of these four paths is - // listed explicitly (rather than relied on via inheritance from - // C:\) precisely so a plain, non-inherited Deny placed directly - // on each one is sufficient: it blocks the denied SIDs from - // writing (including creating new children) directly under that - // path without ever touching any descendant's own ACL. - entries = append(entries, WindowsACLEntry{ - Action: WindowsACLDenyWrite, - Path: denyPath, - Capability: denySID, - NoInherit: true, - // A non-inherited deny on this root object blocks new writes - // directly under it, but NOT writes to a pre-existing child - // that independently grants Users/Authenticated Users write - // (the access check for that child never evaluates a - // non-inherited parent ACE). applyWindowsACLPlan therefore - // enumerates this root's existing writable descendants and - // applies a direct, non-inheriting deny to each, a bounded, - // targeted scan that never rewrites the ACL of any descendant - // that is not itself already writable by those broad groups. - ScanDescendants: true, - }) - } - - // Reconcile stale shared/descendant denies: a write-root path here may - // previously have been covered by the shared-root/descendant DenyWrite - // mitigation above (either directly, if it IS one of the four shared - // paths, or as a discovered writable descendant applyWindowsSharedDescendantDenies - // denied in an earlier run) before the caller configured it as an - // allowed write root. applyWindowsACLPlan only merges the entries in - // THIS plan into the existing DACL; it never removes an ACE that is no - // longer requested, so that old deny would otherwise survive and win - // over the new Allow under Windows' deny-before-allow evaluation. Every - // write-root path unconditionally gets a revoke for the stable - // read-only capability SID: BuildWindowsACLPlan never intentionally - // places a deny for that SID on a write-root path (see the skip above), - // so this can never fight an entry this same plan is also adding, and a - // revoke against a SID with no matching ACE is a safe no-op. - // - // RevokeDescendants extends this same reconciliation below the exact - // root: promoting C:\Users\shared to a write root when an earlier run - // separately denied C:\Users\shared\child (as a discovered writable - // descendant of some OTHER shared root, or of a since-reconfigured - // write root) must also clear that descendant's stale deny, or it - // keeps winning over the root's new inheritable Allow — see jatmn's - // review. for _, capability := range writeCapabilities { entries = append(entries, WindowsACLEntry{ - Action: WindowsACLRevokeCapability, - Path: capability.Root, - Capability: denySID, - NoInherit: true, - // A previously-scanned tree can be promoted to a write root by - // configuring one of ITS OWN descendants' ancestors — e.g. - // C:\Users\shared\child was individually denied by an earlier - // run, then the caller configures C:\Users\shared itself as - // writable. Revoking only at capability.Root would miss the - // stale deny still sitting on child. See RevokeDescendants. + Action: WindowsACLRevokeCapability, + Path: capability.Root, + Capability: denySID, + NoInherit: true, RevokeDescendants: true, }) } diff --git a/internal/sandbox/windows_acl_paths_windows.go b/internal/sandbox/windows_acl_paths_windows.go index ee19762b4..c6e353e0b 100644 --- a/internal/sandbox/windows_acl_paths_windows.go +++ b/internal/sandbox/windows_acl_paths_windows.go @@ -10,20 +10,17 @@ import ( ) // resolveWindowsSharedDenyPaths resolves the canonical system paths that -// BuildWindowsACLPlan protects with shared DenyWrite entries (the system -// drive root, %SystemRoot%\Temp, ProgramData, and the Public user profile). +// earlier SID-broadening builds protected with shared DenyWrite entries +// (the system drive root, %SystemRoot%\Temp, ProgramData, and the Public +// user profile). SID broadening is disabled, so BuildWindowsACLPlan no +// longer stamps those denies; this resolver remains for tests and for any +// future access-time design that needs the same canonical roots. // -// These are resolved from trusted Win32 APIs (GetSystemWindowsDirectory, -// SHGetKnownFolderPath) rather than the SystemDrive/SystemRoot/ProgramData/ -// PUBLIC environment variables. Those variables are ordinary process -// environment state: anything able to influence the environment of the -// elevated `zero sandbox setup` process (which builds and applies this ACL -// plan) could spoof them to point the DenyWrite mitigation at the wrong -// paths, leaving the real system directories unprotected while the -// restricted token is still broadened with the Users and Authenticated -// Users SIDs (see createWindowsRestrictedTokenFromBase). The Win32 APIs used -// here are answered by the OS from its own configuration, not from the -// caller's environment block, so they are not spoofable the same way. +// Paths are resolved from trusted Win32 APIs (GetSystemWindowsDirectory, +// SHGetKnownFolderPath) rather than SystemDrive/SystemRoot/ProgramData/ +// PUBLIC environment variables, which are ordinary process environment +// state and spoofable by anything that can influence the elevated setup +// process. func resolveWindowsSharedDenyPaths() (systemDrive, systemRoot, programData, publicDir string, err error) { windowsDir, err := windows.GetSystemWindowsDirectory() if err != nil { diff --git a/internal/sandbox/windows_acl_test.go b/internal/sandbox/windows_acl_test.go index b41950a5c..bf7d20af4 100644 --- a/internal/sandbox/windows_acl_test.go +++ b/internal/sandbox/windows_acl_test.go @@ -54,34 +54,22 @@ func TestBuildWindowsACLPlanForWorkspaceWriteProfile(t *testing.T) { assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, workspaceSID, true) assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, cacheSID, true) + // SID broadening is disabled, so the plan must not stamp shared system-path + // DenyWrite ACEs. Write roots still get a revoke of the stable read-only + // SID so earlier PR builds' stale denies cannot shadow new Allows. caps, err := LoadOrCreateWindowsCapabilitySIDs(home) if err != nil { t.Fatalf("LoadOrCreateWindowsCapabilitySIDs: %v", err) } - systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest(t) - - // The shared system-path denies name only the one stable read-only SID - // that every broadened token carries. Naming the per-workspace/per-root - // SIDs here instead would append four permanent deny ACEs to these - // machine-wide DACLs for every distinct project ever sandboxed. - for _, path := range []string{systemDrive + `\`, programData, systemRoot + `\Temp`, publicDir} { - assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, path, caps.ReadOnly, false, true) - for _, entry := range plan.Entries { - if entry.Action != WindowsACLDenyWrite || windowsCapabilityPathKey(entry.Path) != windowsCapabilityPathKey(path) { - continue - } - if entry.Capability == workspaceSID || entry.Capability == cacheSID { - t.Fatalf("shared deny path %q names per-root SID %q; machine DACLs must only carry the stable read-only SID", path, entry.Capability) - } - } + assertNoSharedSystemDenyWrites(t, plan) + for _, root := range []string{`C:\workspace`, `D:\cache`} { + assertWindowsACLRevoke(t, plan, root, caps.ReadOnly, true) } } -// TestBuildWindowsACLPlanOmitsSharedDenyPathsWithoutDenyRead pins the -// scoping of the Users/Authenticated Users broadening: profiles without -// DenyRead run under a WRITE_RESTRICTED token, which reads with its normal -// identity and is never broadened, so their plans must not touch the shared -// system-path DACLs at all. +// TestBuildWindowsACLPlanOmitsSharedDenyPathsWithoutDenyRead pins that +// profiles without DenyRead never touch shared system-path DACLs or the +// stable read-only SID revoke path. func TestBuildWindowsACLPlanOmitsSharedDenyPathsWithoutDenyRead(t *testing.T) { home := t.TempDir() plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ @@ -99,25 +87,17 @@ func TestBuildWindowsACLPlanOmitsSharedDenyPathsWithoutDenyRead(t *testing.T) { if err != nil { t.Fatalf("BuildWindowsACLPlan: %v", err) } - systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest(t) - for _, path := range []string{systemDrive + `\`, programData, systemRoot + `\Temp`, publicDir} { - for _, entry := range plan.Entries { - if entry.Action == WindowsACLDenyWrite && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(path) { - t.Fatalf("plan without DenyRead touches shared path %q: %#v", path, entry) - } + assertNoSharedSystemDenyWrites(t, plan) + for _, entry := range plan.Entries { + if entry.Action == WindowsACLRevokeCapability { + t.Fatalf("plan without DenyRead = %#v, want no WindowsACLRevokeCapability entry", plan.Entries) } } } -// TestBuildWindowsACLPlanOmitsSharedDenyPathsWhenUnelevated pins the fix for -// the unelevated tier aborting every sandboxed command: BuildWindowsACLPlan -// must not add DenyWrite entries for C:\, C:\ProgramData, C:\Windows\Temp, or -// C:\Users\Public when SandboxLevel is WindowsSandboxLevelUnelevated, because -// SetNamedSecurityInfo on those system-owned paths requires WRITE_DAC that an -// ordinary (non-Administrator) user does not have. The unelevated tier never -// puts the Users/Authenticated Users SIDs on the token in the first place -// (see createWindowsRestrictedTokenFromBase), so it does not need these -// mitigating entries. +// TestBuildWindowsACLPlanOmitsSharedDenyPathsWhenUnelevated pins that the +// unelevated tier never stamps shared system-path DenyWrite ACEs (it also +// never broadens the restricted-SID list). func TestBuildWindowsACLPlanOmitsSharedDenyPathsWhenUnelevated(t *testing.T) { home := t.TempDir() plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ @@ -128,6 +108,7 @@ func TestBuildWindowsACLPlanOmitsSharedDenyPathsWhenUnelevated(t *testing.T) { FileSystem: FileSystemPolicy{ Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}, + DenyRead: []string{`C:\workspace\secret`}, }, Network: NetworkPolicy{Mode: NetworkDeny}, }, @@ -135,12 +116,10 @@ func TestBuildWindowsACLPlanOmitsSharedDenyPathsWhenUnelevated(t *testing.T) { if err != nil { t.Fatalf("BuildWindowsACLPlan: %v", err) } - systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest(t) - for _, path := range []string{systemDrive + `\`, programData, systemRoot + `\Temp`, publicDir} { - for _, entry := range plan.Entries { - if entry.Action == WindowsACLDenyWrite && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(path) { - t.Fatalf("unelevated ACL plan = %#v, want no DenyWrite entry for shared path %q", plan.Entries, path) - } + assertNoSharedSystemDenyWrites(t, plan) + for _, entry := range plan.Entries { + if entry.Action == WindowsACLRevokeCapability { + t.Fatalf("unelevated plan = %#v, want no WindowsACLRevokeCapability entry", plan.Entries) } } } @@ -178,25 +157,19 @@ func TestBuildWindowsACLPlanUsesReadOnlySIDWithoutWriteRoots(t *testing.T) { if err != nil { t.Fatalf("BuildWindowsACLPlan: %v", err) } - if len(plan.Entries) != 5 { - t.Fatalf("ACL entries = %#v, want five entries (1 deny-read, 4 deny-write)", plan.Entries) + // Without write roots there is nothing to revoke; without SID broadening + // there are no shared system-path DenyWrite entries either. + if len(plan.Entries) != 1 { + t.Fatalf("ACL entries = %#v, want one deny-read entry", plan.Entries) } assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, caps.ReadOnly, true) - systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest(t) - assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, systemDrive+`\`, caps.ReadOnly, false, true) - assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, programData, caps.ReadOnly, false, true) - assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, systemRoot+`\Temp`, caps.ReadOnly, false, true) - assertWindowsACLEntryInheritance(t, plan, WindowsACLDenyWrite, publicDir, caps.ReadOnly, false, true) + assertNoSharedSystemDenyWrites(t, plan) } -// TestBuildWindowsACLPlanMarksSharedDenyPathsForDescendantScan pins that the -// four shared-root DenyWrite entries (and ONLY those) request the apply-time -// existing-writable-descendant scan. That scan is the enforcement that keeps a -// pre-existing writable child of one of those roots (which a non-inherited deny -// on the root object alone does not cover) from becoming a write-jail escape -// once the fully restricted DenyRead token is broadened with the Users and -// Authenticated Users SIDs. -func TestBuildWindowsACLPlanMarksSharedDenyPathsForDescendantScan(t *testing.T) { +// TestBuildWindowsACLPlanDisablesSharedDenyPathDescendantScan pins that SID +// broadening is off: the plan must not request ScanDescendants or shared-root +// DenyWrite entries that only existed to compensate for Users/AuthUsers SIDs. +func TestBuildWindowsACLPlanDisablesSharedDenyPathDescendantScan(t *testing.T) { home := t.TempDir() plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ SandboxHome: home, @@ -215,134 +188,19 @@ func TestBuildWindowsACLPlanMarksSharedDenyPathsForDescendantScan(t *testing.T) if err != nil { t.Fatalf("BuildWindowsACLPlan: %v", err) } - systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest(t) - sharedKeys := map[string]bool{} - for _, path := range []string{systemDrive + `\`, programData, systemRoot + `\Temp`, publicDir} { - sharedKeys[windowsCapabilityPathKey(path)] = true - } - for _, path := range []string{systemDrive + `\`, programData, systemRoot + `\Temp`, publicDir} { - found := false - for _, entry := range plan.Entries { - if entry.Action == WindowsACLDenyWrite && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(path) && entry.ScanDescendants { - found = true - } - } - if !found { - t.Fatalf("shared deny path %q is not marked ScanDescendants; existing writable descendants would stay unenforced", path) - } - } + assertNoSharedSystemDenyWrites(t, plan) for _, entry := range plan.Entries { - if entry.ScanDescendants && !sharedKeys[windowsCapabilityPathKey(entry.Path)] { - t.Fatalf("non-shared entry %#v requests descendant scan; only the four shared roots should", entry) + if entry.ScanDescendants { + t.Fatalf("plan entry %#v requests descendant scan; shared DenyWrite compensation is disabled", entry) } } } -// TestBuildWindowsACLPlanSkipsSharedDenyPathNestedUnderWriteRoot pins the fix -// for a shared-path deny landing on a configured write root's own descendant: -// if a workspace's write root is (or contains) one of the four shared paths — -// here C:\Users contains the Public shared path — a DenyWrite there would sit -// ahead of that root's Allow for every broadened token and win under Windows' -// deny-before-allow evaluation, jailing a directory the user explicitly -// configured as writable. Only the shared paths NOT nested under any -// configured write root should get the compensating deny. -func TestBuildWindowsACLPlanSkipsSharedDenyPathNestedUnderWriteRoot(t *testing.T) { - home := t.TempDir() - systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest(t) - // publicDir is a Windows-style path (backslash-separated) even when this - // test runs on Linux/macOS, so its parent must be computed with a literal - // backslash split, not filepath.Dir (which uses the native separator and - // would treat the whole string as one component on non-Windows GOOS). - lastSeparator := strings.LastIndex(publicDir, `\`) - if lastSeparator <= 0 { - t.Fatalf("test fixture assumes publicDir %q has a parent reachable via a backslash split", publicDir) - } - usersRoot := publicDir[:lastSeparator] - plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ - SandboxHome: home, - WorkspaceRoots: []string{usersRoot}, - SandboxLevel: WindowsSandboxLevelRestrictedToken, - PermissionProfile: PermissionProfile{ - FileSystem: FileSystemPolicy{ - Kind: FileSystemRestricted, - WriteRoots: []WritableRoot{{Root: usersRoot}}, - DenyRead: []string{`C:\workspace\secret-read`}, - }, - Network: NetworkPolicy{Mode: NetworkDeny}, - }, - }) - if err != nil { - t.Fatalf("BuildWindowsACLPlan: %v", err) - } - for _, entry := range plan.Entries { - if entry.Action == WindowsACLDenyWrite && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(publicDir) { - t.Fatalf("plan denies write on %q, which is nested under configured write root %q: %#v", publicDir, usersRoot, entry) - } - } - // The other three shared paths are untouched by this write root and must - // still get their compensating deny. - for _, path := range []string{systemDrive + `\`, programData, systemRoot + `\Temp`} { - found := false - for _, entry := range plan.Entries { - if entry.Action == WindowsACLDenyWrite && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(path) { - found = true - } - } - if !found { - t.Fatalf("plan = %#v, want shared path %q still denied (unaffected by the %q write root)", plan.Entries, path, usersRoot) - } - } -} - -// TestBuildWindowsACLPlanSkipsSharedDenyPathExactlyEqualToWriteRoot exercises -// the windowsPathUnderAnyRoot exact-match branch (as opposed to the -// nested-under-a-write-root case above): a write root configured AT one of -// the four shared paths themselves must get its Allow entry with no -// conflicting DenyWrite, or a broadened token could never write there despite -// the user explicitly configuring it as writable. -func TestBuildWindowsACLPlanSkipsSharedDenyPathExactlyEqualToWriteRoot(t *testing.T) { - home := t.TempDir() - _, systemRoot, _, _ := windowsSharedDenyPathsForTest(t) - writeRoot := systemRoot + `\Temp` - - plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ - SandboxHome: home, - WorkspaceRoots: []string{writeRoot}, - SandboxLevel: WindowsSandboxLevelRestrictedToken, - PermissionProfile: PermissionProfile{ - FileSystem: FileSystemPolicy{ - Kind: FileSystemRestricted, - WriteRoots: []WritableRoot{{Root: writeRoot}}, - DenyRead: []string{`C:\workspace\secret-read`}, - }, - Network: NetworkPolicy{Mode: NetworkDeny}, - }, - }) - if err != nil { - t.Fatalf("BuildWindowsACLPlan: %v", err) - } - writeRootSID, err := WindowsWorkspaceCapabilitySID(home, writeRoot) - if err != nil { - t.Fatalf("WindowsWorkspaceCapabilitySID: %v", err) - } - assertWindowsACLEntry(t, plan, WindowsACLAllowWrite, writeRoot, writeRootSID, false) - for _, entry := range plan.Entries { - if entry.Action == WindowsACLDenyWrite && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(writeRoot) { - t.Fatalf("plan denies write on %q, which IS the configured write root: %#v", writeRoot, entry) - } - } -} - -// TestBuildWindowsACLPlanRevokesStaleSharedDenyOnPromotedWriteRoot pins the -// fix for jatmn's P2 finding: every write-root path gets an unconditional -// WindowsACLRevokeCapability entry for the stable read-only capability SID, -// so a stale shared/descendant DenyWrite ACE an earlier setup round applied -// there (before it became a write root) does not survive to win over the new -// Allow under Windows' deny-before-allow evaluation. This covers both ways a -// path can have been promoted: it IS one of the four shared paths (Public -// here), or it was a previously discovered writable descendant elsewhere in -// the tree that the user later configured directly as a write root. -func TestBuildWindowsACLPlanRevokesStaleSharedDenyOnPromotedWriteRoot(t *testing.T) { +// TestBuildWindowsACLPlanRevokesStaleSharedDenyOnWriteRoot pins cleanup for +// hosts that ran earlier PR builds which stamped shared/descendant DenyWrite +// ACEs: write roots still get an unconditional revoke of the stable read-only +// SID (with RevokeDescendants) when DenyRead is configured on the elevated tier. +func TestBuildWindowsACLPlanRevokesStaleSharedDenyOnWriteRoot(t *testing.T) { home := t.TempDir() caps, err := LoadOrCreateWindowsCapabilitySIDs(home) if err != nil { @@ -367,35 +225,14 @@ func TestBuildWindowsACLPlanRevokesStaleSharedDenyOnPromotedWriteRoot(t *testing if err != nil { t.Fatalf("BuildWindowsACLPlan: %v", err) } + assertNoSharedSystemDenyWrites(t, plan) for _, root := range []string{publicDir, promotedDescendant} { - found := false - revokesDescendants := false - for _, entry := range plan.Entries { - if entry.Action == WindowsACLRevokeCapability && - windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(root) && - strings.EqualFold(entry.Capability, caps.ReadOnly) { - found = true - revokesDescendants = entry.RevokeDescendants - } - } - if !found { - t.Fatalf("plan = %#v, want a WindowsACLRevokeCapability entry for write root %q naming the stable read-only SID %q", plan.Entries, root, caps.ReadOnly) - } - // jatmn's follow-up P2: the revoke must also reach stale denies on the - // root's own descendants (e.g. a previously-scanned C:\Users\shared\child - // left denied before C:\Users\shared was promoted to a write root), not - // just the exact configured root path. - if !revokesDescendants { - t.Fatalf("write root %q revoke entry has RevokeDescendants=false, want true so stale descendant denies are also cleared", root) - } + assertWindowsACLRevoke(t, plan, root, caps.ReadOnly, true) } } // TestBuildWindowsACLPlanOmitsRevokeCapabilityWithoutDenyRead pins that the -// reconciliation entry is scoped the same way the shared-path denies -// themselves are: a profile without DenyRead never touches the stable -// read-only SID at all (see TestBuildWindowsACLPlanOmitsSharedDenyPathsWithoutDenyRead), -// so it must not add a revoke entry either. +// stale-deny reconciliation entry is scoped to DenyRead elevated profiles. func TestBuildWindowsACLPlanOmitsRevokeCapabilityWithoutDenyRead(t *testing.T) { home := t.TempDir() plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ @@ -420,6 +257,33 @@ func TestBuildWindowsACLPlanOmitsRevokeCapabilityWithoutDenyRead(t *testing.T) { } } +func assertNoSharedSystemDenyWrites(t *testing.T, plan WindowsACLPlan) { + t.Helper() + systemDrive, systemRoot, programData, publicDir := windowsSharedDenyPathsForTest(t) + for _, path := range []string{systemDrive + `\`, programData, systemRoot + `\Temp`, publicDir} { + for _, entry := range plan.Entries { + if entry.Action == WindowsACLDenyWrite && windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(path) { + t.Fatalf("plan stamps shared system DenyWrite on %q = %#v; SID broadening is disabled so shared denies must not be planned", path, entry) + } + } + } +} + +func assertWindowsACLRevoke(t *testing.T, plan WindowsACLPlan, path, capability string, revokeDescendants bool) { + t.Helper() + for _, entry := range plan.Entries { + if entry.Action == WindowsACLRevokeCapability && + windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(path) && + strings.EqualFold(entry.Capability, capability) { + if entry.RevokeDescendants != revokeDescendants { + t.Fatalf("revoke on %q RevokeDescendants=%v, want %v", path, entry.RevokeDescendants, revokeDescendants) + } + return + } + } + t.Fatalf("plan = %#v, want WindowsACLRevokeCapability on %q for %q", plan.Entries, path, capability) +} + func TestBuildWindowsACLPlanRejectsUnrestrictedProfiles(t *testing.T) { _, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ SandboxHome: t.TempDir(), diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index e40a22767..37b92b397 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -75,76 +75,24 @@ 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 - // Broadening with Users/Authenticated Users is only useful on the fully - // restricted token, where READS also require a restricted-SID match and - // system paths like Program Files and System32 grant those groups rather - // than Everyone. A WRITE_RESTRICTED token already performs reads with the - // normal token identity, so broadening there cannot improve reads at all; - // it would only let Users/Authenticated Users write grants pass the - // restricted-SID write check and weaken the default write jail for no - // benefit. It also needs the elevated tier: only that tier can enforce - // the shared-directory DenyWrite mitigation BuildWindowsACLPlan adds for - // the broadened SIDs (it requires Administrator rights); see - // createWindowsRestrictedTokenFromBase. - // The broadened identities are also gated on the machine's volume - // layout: the compensating shared-path DenyWrite mitigation covers only - // system-drive paths, so on a host with any other fixed volume (whose - // root typically grants Authenticated Users Modify with volume-wide - // inheritance) the broadened token could write outside every configured - // write root. Fail closed there and keep the narrow SID set — reads of - // Users-granted system paths stay broken on such hosts, but the write - // jail holds. + // Users/Authenticated Users SID broadening is permanently disabled. // - // Before broadening, revalidate/reapply direct denies on any currently - // Users/AuthUsers-writable descendants of the shared roots. Setup alone is - // a point-in-time snapshot; non-inheriting denies do not cover children - // created afterward. If coverage cannot be re-established, keep the narrow - // SID set rather than widening the write jail. - // - // KNOWN LIMITATION (TOCTOU): this scan-then-broaden sequence cannot be made - // fully atomic at this layer. Another process could create a new - // Users/AuthUsers-writable child under a shared root in the gap between - // windowsEnsureSharedDescendantCoverage returning clean and the token - // actually being created below; that child would carry no compensating - // deny, yet the freshly broadened token could still write it. Closing this - // completely would need either a filesystem-level guarantee (e.g. a - // minifilter or a lock held across the whole window) or re-checking the - // exact same live directories the kernel itself would consult during the - // access check, which this process cannot do atomically with token - // creation from user mode. What IS done: everything unrelated to the scan - // result (SID resolution, capability-file I/O) is resolved BEFORE the - // scan, specifically so the scan runs as the LAST thing before - // createWindowsRestrictedTokenForCapabilitySIDs, keeping the window to the - // minimum a few Go statements and one syscall can achieve — not zero. This - // is accepted as a narrow, disclosed residual risk consistent with the - // rest of this function's documented tradeoffs (Schannel, MSYS2 above): - // the realistic window is a hostile local process winning a race measured - // in microseconds against a command that was already about to run inside - // the write jail, not a passive gap an attacker can wait out. - broadenReadSIDs := config.SandboxLevel == WindowsSandboxLevelRestrictedToken && !writeRestricted && - NormalizeNetworkMode(config.PermissionProfile.Network.Mode) != NetworkAllow && - windowsSystemDriveIsOnlyFixedVolume() - if broadenReadSIDs { - // The shared-directory DenyWrite mitigation names the one stable - // read-only capability SID rather than the per-workspace SIDs (see - // BuildWindowsACLPlan), so every broadened token must carry it for - // those deny ACEs to bind. Resolved BEFORE the coverage scan (rather - // than after, as a prior revision did) so this file I/O cannot widen - // the TOCTOU window documented above: the scan below is the last thing - // that happens before token creation. - caps, err := LoadOrCreateWindowsCapabilitySIDs(config.SandboxHome) - if err != nil { - fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) - return 1 - } - if err := windowsEnsureSharedDescendantCoverage(config); err != nil { - fmt.Fprintf(stderr, "%s: shared descendant write coverage incomplete (%v); keeping narrow restricting SIDs\n", - WindowsSandboxCommandRunnerName, err) - broadenReadSIDs = false - } else { - tokenSIDs = append(tokenSIDs, caps.ReadOnly) - } - } + // Adding those groups to the restricted-SID list would let the sandboxed + // process execute binaries under Program Files / Windows (which grant + // Users rather than Everyone), but the same restricted-SID match also + // unlocks every existing Users/AuthUsers write grant on the machine. + // Compensating DenyWrite ACEs applied from a preflight path walk cannot + // establish an access-time write boundary for the command's lifetime: + // reparse targets can hide writable children the walk never sees, and a + // normal process can create a group-writable child under a shared root + // after the scan (or after token creation) because the synthetic denies + // are deliberately non-inheriting. Until write confinement is enforced + // by the OS at open time (AppContainer/LPAC package identity with + // capability grants, or a path-policy sandbox API), keep the narrow + // restricting-SID set. DenyRead profiles may fail to launch ordinary + // system binaries as a result; that is preferred to a silent write-jail + // escape. See PR #640 review. + const broadenReadSIDs = false token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs, writeRestricted, broadenReadSIDs) if err != nil { fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) diff --git a/internal/sandbox/windows_token_windows.go b/internal/sandbox/windows_token_windows.go index d5c121dde..0c8e77559 100644 --- a/internal/sandbox/windows_token_windows.go +++ b/internal/sandbox/windows_token_windows.go @@ -87,20 +87,16 @@ func createWindowsRestrictedTokenForCapabilitySIDs(capabilitySIDStrings []string // broadenReadSIDs is set, it also restricts to WinBuiltinUsersSid and // WinAuthenticatedUserSid so the sandboxed process can read/execute binaries // under paths like C:\Program Files or C:\Windows whose ACLs grant -// Users/Authenticated Users rather than Everyone. That only matters on the -// fully restricted token (writeRestricted=false), where reads also require a -// restricted-SID match; a WRITE_RESTRICTED token reads with its normal -// identity, so broadening it would gain nothing for reads while letting the -// groups' write grants pass the restricted-SID write check. Because the -// restricting-SID check applies to writes as well as reads, broadening also -// grants write wherever those groups already have it — BuildWindowsACLPlan -// mitigates that by adding DenyWrite ACEs to the known shared -// Users/Authenticated-Users-writable directories, but it can only do so -// with Administrator rights (see WindowsSandboxLevelRestrictedToken). -// broadenReadSIDs must therefore stay false both when writeRestricted is set -// and for WindowsSandboxLevelUnelevated, which cannot enforce that -// mitigation: those keep the original (narrower) SID scope instead of -// widening the write jail with nothing to close the gap. +// Users/Authenticated Users rather than Everyone. +// +// Callers must pass broadenReadSIDs=false. Preflight DenyWrite compensation +// cannot enforce a write boundary for the command's lifetime (reparse +// coverage gaps and post-scan group-writable children), so the runner keeps +// broadening disabled until access-time confinement (AppContainer/LPAC or +// equivalent) exists. The parameter remains only so the token builder can +// refuse the unsafe broadenReadSIDs+writeRestricted combination and so a +// future access-time design can re-enable a controlled form without another +// signature change. func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []windowsLocalSID, writeRestricted, broadenReadSIDs bool) (windows.Token, error) { // Defensive guardrail for the invariant documented above: combining the // two would silently widen the write jail (broadenReadSIDs's write grants) From 2fef9f6382540a63495b9786d6c388119578b9f2 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:39:27 -0400 Subject: [PATCH 23/26] fix(sandbox): address restricted-token SID review findings Harden the shared-root descendant walker for jatmn's PR #640 review: skip reparse points without following, track directory object identity to avoid re-enumeration, keep complete write-deny coverage checks, and add partial-deny plus junction regression tests. Refs #640 --- .../windows_acl_descendants_windows.go | 142 ++++++++++++++---- .../windows_acl_descendants_windows_test.go | 117 +++++++++++++++ 2 files changed, 229 insertions(+), 30 deletions(-) diff --git a/internal/sandbox/windows_acl_descendants_windows.go b/internal/sandbox/windows_acl_descendants_windows.go index a8f7a35c2..c68b12728 100644 --- a/internal/sandbox/windows_acl_descendants_windows.go +++ b/internal/sandbox/windows_acl_descendants_windows.go @@ -34,18 +34,24 @@ import ( // These bounds must be large enough that a stock C:\ (with its Windows, // Program Files, and WinSxS trees) actually completes — see the comment on // the vars below for the reasoning and the honest limits of that estimate. -// - Reparse points (junctions, symlinks, volume mount points) are NOT a -// special case: CreateFile/GetNamedSecurityInfo/ReadDir called on a path -// without FILE_FLAG_OPEN_REPARSE_POINT already transparently resolve -// through a directory junction or symlink to its target (standard NTFS -// reparse behavior), so treating a reparse point exactly like a normal -// directory here already inspects and descends into whatever it actually -// points at. A stock drive has real compatibility junctions (e.g. -// C:\Documents and Settings -> C:\Users) that must not hard-fail the scan -// (see jatmn's review); this walker no longer special-cases them at all. -// What bounds a pathological loop (a junction pointing at an ancestor) is -// the same depth/entry cap as everything else — worst case that fails -// closed, it does not run forever. +// - Reparse points (junctions, symlinks, volume mount points) use a +// no-follow, identity-aware policy (see jatmn's review). Path APIs without +// FILE_FLAG_OPEN_REPARSE_POINT transparently resolve through a directory +// junction or symlink, so treating a reparse as an ordinary directory +// would either hard-fail on deliberately non-listable compatibility +// junctions (C:\Documents and Settings, ProgramData\Application Data, …) +// or re-walk an already-scanned tree through the target and blow the +// depth/entry cap. The walker therefore: +// 1. Detects reparse points via FILE_ATTRIBUTE_REPARSE_POINT (and the +// ModeSymlink/ModeIrregular bits ReadDir already reports) and never +// inspects their DACL, never applies a deny, and never descends. +// 2. Records the (volume serial, file index) identity of every real +// directory it does enter, so an alternate path to the same object +// (short name, case variant, or any residual reparse resolution) is +// skipped rather than re-enumerated. +// Stock compatibility junctions always have a real path sibling that the +// walk reaches separately (Documents and Settings -> Users); skipping the +// reparse does not leave that tree unexamined. // - A directory this process cannot list, or a child whose DACL it cannot // read, is fail-closed UNLESS the basename is a known SYSTEM-exclusive // Windows directory (e.g. "System Volume Information") AND it sits at the @@ -111,10 +117,11 @@ const windowsBroadenedWriteProbeMask windows.ACCESS_MASK = (windows.FILE_GENERIC // to each. It returns every snapshot it applied (including on error) so the // caller can roll the whole apply back. A descendant it identified as writable // but could not deny is a hole it cannot close, so that failure is returned -// (fail closed). An incomplete enumeration (caps, reparse, unreadable child) -// is also returned as an error. Descendants that already carry an equivalent -// deny for denySID are left untouched so setup reruns and command-time -// revalidation do not accumulate duplicate permanent ACEs. +// (fail closed). An incomplete enumeration (caps, unreadable non-reparse child) +// is also returned as an error. Reparse points are skipped by the enumerator +// (no-follow). Descendants that already carry a complete write deny for +// denySID are left untouched so setup reruns and command-time revalidation do +// not accumulate duplicate permanent ACEs. func applyWindowsSharedDescendantDenies(root, denySID string, writeRoots []string) ([]windowsACLSnapshot, error) { descendants, err := windowsEnumerateWritableDescendants(root, writeRoots) if err != nil { @@ -156,9 +163,11 @@ func applyWindowsSharedDescendantDenies(root, denySID string, writeRoots []strin // much an escape surface as a writable directory — but only directories are // descended into. // -// Fail closed: exhausting the depth or entry caps, encountering a reparse -// point, or failing to list/inspect a non-allowlisted entry returns an error -// rather than a partial success the caller could mistake for complete coverage. +// Fail closed: exhausting the depth or entry caps, or failing to list/inspect +// a non-allowlisted, non-reparse entry returns an error rather than a partial +// success the caller could mistake for complete coverage. Reparse points are +// skipped (no-follow), not treated as incomplete coverage: their targets are +// reached through the real path when it lies under the same root. func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]string, error) { if windowsCapabilityPathKey(root) == "" { return nil, nil @@ -184,10 +193,25 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st } var out []string visited := 0 + // seenIDs records real directory object identities already entered so an + // alternate path to the same object is not re-enumerated (identity-aware + // half of the reparse policy). + seenIDs := make(map[windowsFileObjectID]struct{}) queue := []node{{path: root, depth: 0}} for len(queue) > 0 { current := queue[0] queue = queue[1:] + // Defensive: never list through a reparse path that somehow reached + // the queue (root is expected to be a real directory). + if windowsPathIsReparsePoint(current.path) { + continue + } + if id, ok := windowsFileObjectIdentity(current.path); ok { + if _, seen := seenIDs[id]; seen { + continue + } + seenIDs[id] = struct{}{} + } entries, err := os.ReadDir(current.path) if err != nil { if windowsPathIsDriveRootPath(filepath.Dir(current.path)) && windowsDescendantScanNameIsSystemLocked(filepath.Base(current.path)) { @@ -201,7 +225,18 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st if isExcluded(childKey) { continue } - isReparse := (entry.Type()&os.ModeSymlink != 0) || (entry.Type()&os.ModeIrregular != 0) + // No-follow: stock compatibility junctions (and any other reparse + // point) are never DACL-inspected, denied, or descended. Mode bits + // catch what ReadDir already classified; GetFileAttributes covers + // any reparse form those bits miss. + isReparse := (entry.Type()&os.ModeSymlink != 0) || (entry.Type()&os.ModeIrregular != 0) || windowsPathIsReparsePoint(child) + if isReparse { + if visited >= windowsDescendantScanMaxDirs { + return nil, fmt.Errorf("descendant scan exceeded %d entries below %s", windowsDescendantScanMaxDirs, root) + } + visited++ + continue + } if visited >= windowsDescendantScanMaxDirs { return nil, fmt.Errorf("descendant scan exceeded %d entries below %s", windowsDescendantScanMaxDirs, root) } @@ -219,7 +254,7 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st if writable { out = append(out, child) } - if isReparse || !entry.IsDir() { + if !entry.IsDir() { continue } childDepth := current.depth + 1 @@ -249,6 +284,51 @@ func windowsEnumerateWritableDescendants(root string, writeRoots []string) ([]st return out, nil } +// windowsFileObjectID is the NTFS object identity used to detect that two +// paths name the same directory (volume serial + 64-bit file index). +type windowsFileObjectID struct { + volume uint32 + index uint64 +} + +// windowsFileObjectIdentity returns the on-disk identity of path when it can +// be opened as a real (non-reparse) directory. ok is false on any open/inspect +// failure so the walker falls through to path-based enumeration rather than +// treating an unreadable directory as already-seen. +func windowsFileObjectIdentity(path string) (windowsFileObjectID, bool) { + ptr, err := windows.UTF16PtrFromString(path) + if err != nil { + return windowsFileObjectID{}, false + } + handle, err := windows.CreateFile( + ptr, + windows.FILE_READ_ATTRIBUTES, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return windowsFileObjectID{}, false + } + defer windows.CloseHandle(handle) + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return windowsFileObjectID{}, false + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return windowsFileObjectID{}, false + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 { + return windowsFileObjectID{}, false + } + return windowsFileObjectID{ + volume: info.VolumeSerialNumber, + index: (uint64(info.FileIndexHigh) << 32) | uint64(info.FileIndexLow), + }, true +} + // windowsAccessAllowedObjectAceType and windowsAccessDeniedObjectAceType are // the AceType values for ACCESS_ALLOWED_OBJECT_ACE / ACCESS_DENIED_OBJECT_ACE // (https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-access_allowed_object_ace). @@ -488,15 +568,17 @@ func windowsUncoveredWritableDescendants(root, denySID string, writeRoots []stri } // windowsPathDeniesCapabilitySID reports whether path's DACL already contains -// a deny ACE naming the given capability SID string (the synthetic identity -// used for shared-root / descendant DenyWrite entries) that covers write -// access. A deny ACE for the right SID is only "already denies write" if its -// mask actually includes write-relevant bits: the same stable capability SID -// is also used for DenyRead entries (planWindowsDenyReadPaths), so a path -// that only carries a pre-existing DenyRead ACE for wantSID must NOT be -// mistaken for one that already blocks writes — see jatmn's review, which -// found this would mask a real writable descendant under a DenyRead path and -// skip closing it. +// deny ACE(s) naming the given capability SID string (the synthetic identity +// used for shared-root / descendant DenyWrite entries) that together cover +// every write-relevant bit in windowsBroadenedWriteProbeMask. +// +// A partial deny is not coverage: denying only FILE_WRITE_ATTRIBUTES (or only +// read/execute via a DenyRead ACE that reuses the same stable SID) leaves +// FILE_WRITE_DATA / FILE_APPEND_DATA open for a Users/AuthUsers grant, so the +// apply and verification paths must still merge the full canonical DenyWrite +// rather than skipping the path — see jatmn's review. Accumulated deny ACEs +// for wantSID are OR'd before the completeness check so a multi-ACE full +// cover still counts. func windowsPathDeniesCapabilitySID(path, wantSID string) (bool, error) { want, err := windows.StringToSid(wantSID) if err != nil { diff --git a/internal/sandbox/windows_acl_descendants_windows_test.go b/internal/sandbox/windows_acl_descendants_windows_test.go index 6e6e3c93a..9271d4bc2 100644 --- a/internal/sandbox/windows_acl_descendants_windows_test.go +++ b/internal/sandbox/windows_acl_descendants_windows_test.go @@ -5,7 +5,9 @@ package sandbox import ( "encoding/binary" "os" + "os/exec" "path/filepath" + "strings" "testing" "unsafe" @@ -589,6 +591,114 @@ func TestWindowsPathDeniesCapabilitySIDRequiresEssentialWriteMask(t *testing.T) } } +// denyCapabilityMask adds a direct (non-inheriting) deny ACE for capabilitySID +// covering only mask. Used to build partial-deny fixtures that must not pass +// windowsPathDeniesCapabilitySID's complete-coverage check. +func denyCapabilityMask(t *testing.T, path, capabilitySID string, mask windows.ACCESS_MASK) { + t.Helper() + sid, err := windows.StringToSid(capabilitySID) + if err != nil { + t.Fatalf("StringToSid(%q): %v", capabilitySID, err) + } + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("GetNamedSecurityInfo %s: %v", path, err) + } + oldDACL, _, err := sd.DACL() + if err != nil { + t.Fatalf("DACL %s: %v", path, err) + } + newDACL, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{ + AccessPermissions: mask, + AccessMode: windows.DENY_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(sid), + }, + }}, oldDACL) + if err != nil { + t.Fatalf("ACLFromEntries %s: %v", path, err) + } + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, newDACL, nil); err != nil { + t.Fatalf("SetNamedSecurityInfo %s: %v", path, err) + } +} + +// TestWindowsPathDeniesCapabilitySIDRejectsPartialWriteDeny is the regression +// for jatmn's complete-coverage finding: a stable-SID deny that only blocks +// FILE_WRITE_ATTRIBUTES (or any other proper subset of the write probe mask) +// must not be accepted as descendant coverage. FILE_WRITE_DATA would still +// pass through a Users/AuthUsers allow under a partial-deny check that only +// tested non-zero overlap with the probe mask. +func TestWindowsPathDeniesCapabilitySIDRejectsPartialWriteDeny(t *testing.T) { + const sidStr = "S-1-1-0" + dir := t.TempDir() + denyCapabilityMask(t, dir, sidStr, windows.FILE_WRITE_ATTRIBUTES) + + denied, err := windowsPathDeniesCapabilitySID(dir, sidStr) + if err != nil { + t.Fatalf("windowsPathDeniesCapabilitySID: %v", err) + } + if denied { + t.Fatal("windowsPathDeniesCapabilitySID = true for FILE_WRITE_ATTRIBUTES-only deny, want false: partial deny must not certify write coverage") + } + + // applyWindowsSharedDescendantDenies must still apply the full canonical + // deny when the only pre-existing ACE is this partial one. + scanRoot := t.TempDir() + child := mkdir(t, filepath.Join(scanRoot, "partial")) + grantUsersWrite(t, child) + denyCapabilityMask(t, child, sidStr, windows.FILE_WRITE_ATTRIBUTES) + if denied, err := windowsPathDeniesCapabilitySID(child, sidStr); err != nil || denied { + t.Fatalf("precondition: partial deny on child must not count as coverage (denied=%v err=%v)", denied, err) + } + if _, err := applyWindowsSharedDescendantDenies(scanRoot, sidStr, nil); err != nil { + t.Fatalf("applyWindowsSharedDescendantDenies: %v", err) + } + if denied, err := windowsPathDeniesCapabilitySID(child, sidStr); err != nil || !denied { + t.Fatalf("after apply: want full write deny on partial-deny child (denied=%v err=%v)", denied, err) + } +} + +// TestWindowsEnumerateWritableDescendantsSkipsJunctions is the real-Windows +// regression for jatmn's compatibility-junction finding: a directory junction +// under the scan root must not be followed (re-walking the target tree) or +// hard-failed when the reparse is non-listable. The real target path is still +// examined when reached without going through the reparse. +func TestWindowsEnumerateWritableDescendantsSkipsJunctions(t *testing.T) { + root := t.TempDir() + realDir := mkdir(t, filepath.Join(root, "real")) + writable := mkdir(t, filepath.Join(realDir, "writable")) + grantUsersWrite(t, writable) + junc := filepath.Join(root, "junc") + // mklink /J needs no elevation; create the junction via cmd. + out, err := exec.Command("cmd", "/c", "mklink", "/J", junc, realDir).CombinedOutput() + if err != nil { + t.Skipf("cannot create junction (mklink /J): %v %s", err, strings.TrimSpace(string(out))) + } + t.Cleanup(func() { _ = os.Remove(junc) }) + + if !windowsPathIsReparsePoint(junc) { + t.Fatalf("fixture bug: %q is not a reparse point", junc) + } + + found, err := windowsEnumerateWritableDescendants(root, nil) + if err != nil { + t.Fatalf("windowsEnumerateWritableDescendants: %v", err) + } + if !windowsPathListContains(found, writable) { + t.Fatalf("enumeration = %#v, want writable child %q via real path", found, writable) + } + // Junction path itself must not appear: no-follow skips reparse entries + // entirely (DACL inspect would follow and risk double-counting). + juncWritable := filepath.Join(junc, "writable") + if windowsPathListContains(found, junc) || windowsPathListContains(found, juncWritable) { + t.Fatalf("enumeration = %#v, must not include junction path %q or its followed child", found, junc) + } +} + func TestWindowsEnsureSharedDescendantCoverageDeduplicatesRootDeny(t *testing.T) { dir := t.TempDir() sid := "S-1-1-0" @@ -609,4 +719,11 @@ func TestWindowsEnsureSharedDescendantCoverageDeduplicatesRootDeny(t *testing.T) if err != nil || !denied { t.Fatalf("expected root to be denied before re-check, denied=%v, err=%v", denied, err) } + // Second check must still report complete coverage so command-time + // windowsEnsureSharedDescendantCoverage skips SetEntriesInAclW rather + // than stacking another permanent DENY_ACCESS ACE on the root. + deniedAgain, err := windowsPathDeniesCapabilitySID(dir, sid) + if err != nil || !deniedAgain { + t.Fatalf("expected idempotent complete deny on re-check, denied=%v, err=%v", deniedAgain, err) + } } From d916f0be7dbb1ef71144c44ce25ff3e11f8fc41c Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:11:29 -0400 Subject: [PATCH 24/26] fix(sandbox): preserve DenyRead when revoking experimental write denies Migration revoke for promoted write roots now strips only experimental DenyWrite ACEs for the stable SID and re-applies co-resident DenyRead. Drop the unused volume-gate helper and resolve system paths via GetWindowsDirectory. --- internal/sandbox/windows_acl_apply_windows.go | 107 ++++++++++++-- .../sandbox/windows_acl_apply_windows_test.go | 115 +++++++++++++++ internal/sandbox/windows_acl_paths_windows.go | 4 +- internal/sandbox/windows_volumes_windows.go | 134 ------------------ 4 files changed, 215 insertions(+), 145 deletions(-) delete mode 100644 internal/sandbox/windows_volumes_windows.go diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index 20f17efbe..092b5880d 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -262,7 +262,7 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo if err != nil { return fail(fmt.Errorf("read windows DACL for %s: %w", path, err)) } - accessEntries, err := windowsExplicitAccessEntries(group.Entries, isDir) + accessEntries, err := windowsExplicitAccessEntries(group.Entries, isDir, oldDACL) if err != nil { return fail(err) } @@ -330,13 +330,38 @@ func windowsACLGroupRequiresExistingTarget(group windowsACLPathGroup) bool { return false } -func windowsExplicitAccessEntries(entries []WindowsACLEntry, isDir bool) ([]windows.EXPLICIT_ACCESS, error) { +func windowsExplicitAccessEntries(entries []WindowsACLEntry, isDir bool, oldDACL *windows.ACL) ([]windows.EXPLICIT_ACCESS, error) { out := make([]windows.EXPLICIT_ACCESS, 0, len(entries)) for _, entry := range entries { sid, err := windows.StringToSid(entry.Capability) if err != nil { return nil, fmt.Errorf("parse windows capability SID %q: %w", entry.Capability, err) } + if entry.Action == WindowsACLRevokeCapability { + // Migration cleanup for hosts that ran experimental SID-broadening + // builds: strip the synthetic full DenyWrite ACE for this SID, but + // re-emit any co-resident DenyRead ACEs for the same stable SID so + // a concurrent profile's read boundary is not deleted (jatmn P1). + // SET_ACCESS with a zero mask clears every ACE for the trustee + // (REVOKE_ACCESS leaves DENY ACEs untouched empirically); the + // preserved read-deny entries that follow restore DenyRead only. + out = append(out, windows.EXPLICIT_ACCESS{ + AccessPermissions: 0, + AccessMode: windows.SET_ACCESS, + Inheritance: 0, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(sid), + }, + }) + preserved, err := windowsPreservedReadDenyAccessEntries(oldDACL, sid, isDir) + if err != nil { + return nil, err + } + out = append(out, preserved...) + continue + } accessMode, permissions, err := windowsACLAccess(entry.Action) if err != nil { return nil, err @@ -359,6 +384,75 @@ func windowsExplicitAccessEntries(entries []WindowsACLEntry, isDir bool) ([]wind return out, nil } +// windowsPreservedReadDenyAccessEntries returns DENY_ACCESS EXPLICIT_ACCESS +// entries that re-apply any non-write-related DENY ACEs for wantSID from +// oldDACL. Write-related DENY ACEs (the experimental shared/descendant +// DenyWrite shape) are intentionally omitted so migration revoke can drop +// them without also clearing a live DenyRead for the same SID. +func windowsPreservedReadDenyAccessEntries(oldDACL *windows.ACL, wantSID *windows.SID, isDir bool) ([]windows.EXPLICIT_ACCESS, error) { + if oldDACL == nil || wantSID == nil { + return nil, nil + } + var out []windows.EXPLICIT_ACCESS + for index := uint16(0); index < oldDACL.AceCount; index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(oldDACL, uint32(index), &ace); err != nil { + return nil, fmt.Errorf("read ACE %d while preserving read deny: %w", index, err) + } + if ace.Header.AceType != windows.ACCESS_DENIED_ACE_TYPE && ace.Header.AceType != windowsAccessDeniedObjectAceType { + continue + } + if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 { + continue + } + sid, ok := windowsAceSID(ace) + if !ok || !sid.Equals(wantSID) { + continue + } + if windowsIsExperimentalWriteDenyMask(ace.Mask) { + continue + } + // Preserve non-write DENY ACEs (typically DenyRead for the stable + // sandbox-home ReadOnly SID). + inheritance := uint32(0) + if isDir && ace.Header.AceFlags&(windows.OBJECT_INHERIT_ACE|windows.CONTAINER_INHERIT_ACE) != 0 { + inheritance = windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT + } + out = append(out, windows.EXPLICIT_ACCESS{ + AccessPermissions: ace.Mask, + AccessMode: windows.DENY_ACCESS, + Inheritance: inheritance, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(wantSID), + }, + }) + } + return out, nil +} + +// windowsIsExperimentalWriteDenyMask reports whether mask is a synthetic +// DenyWrite (or partial write deny) from earlier broadening builds — the only +// ACEs migration revoke may drop for the stable ReadOnly SID. Pure DenyRead +// masks share some STANDARD_RIGHTS bits with FILE_GENERIC_WRITE, so this keys +// off content-write / delete / DAC bits that DenyRead never carries. +func windowsIsExperimentalWriteDenyMask(mask windows.ACCESS_MASK) bool { + _, writeMask, err := windowsACLAccess(WindowsACLDenyWrite) + if err != nil { + return false + } + if mask&writeMask == writeMask { + return true + } + // Content-write / ownership bits unique to write denies (not in DenyRead's + // FILE_GENERIC_READ|FILE_GENERIC_EXECUTE mask alone). + const writeContent = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA | + windows.FILE_WRITE_EA | windows.FILE_WRITE_ATTRIBUTES | + windowsFileDeleteChild | windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER + return mask&writeContent != 0 +} + func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACCESS_MASK, error) { switch action { case WindowsACLAllowWrite: @@ -368,14 +462,7 @@ func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACC case WindowsACLDenyWrite: return windows.DENY_ACCESS, (windows.FILE_GENERIC_WRITE | windows.DELETE | windowsFileDeleteChild | windows.WRITE_DAC | windows.WRITE_OWNER) &^ windows.SYNCHRONIZE, nil case WindowsACLRevokeCapability: - // SetEntriesInAclW's REVOKE_ACCESS mode is documented to strip a - // trustee's existing ACEs, but empirically (verified against this - // exact code path) it leaves a pre-existing DENY ACE for the trustee - // untouched — the merge simply has nothing to OR into and no ACE gets - // added or removed. SET_ACCESS with a zero mask does what REVOKE_ACCESS - // is supposed to: it replaces the trustee's entry outright, and - // SetEntriesInAclW omits an ACE entirely for a zero-permission SET, - // which is what actually clears a stale allow OR deny ACE for this SID. + // Handled specially in windowsExplicitAccessEntries (preserve DenyRead). return windows.SET_ACCESS, 0, nil default: return 0, 0, fmt.Errorf("unsupported windows ACL action %q", action) diff --git a/internal/sandbox/windows_acl_apply_windows_test.go b/internal/sandbox/windows_acl_apply_windows_test.go index 6e45cee6f..6a7743ef1 100644 --- a/internal/sandbox/windows_acl_apply_windows_test.go +++ b/internal/sandbox/windows_acl_apply_windows_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "testing" + "unsafe" "golang.org/x/sys/windows" ) @@ -109,6 +110,120 @@ func TestApplyWindowsACLPathGroupRevokeCapabilityRemovesStaleDeny(t *testing.T) } } +// TestApplyWindowsACLRevokePreservesDenyRead pins that migration revoke for a +// promoted write root removes only the experimental DenyWrite ACE for the +// stable SID and leaves a co-resident DenyRead for the same SID intact — so a +// concurrent profile's read boundary is not deleted (jatmn P1). +func TestApplyWindowsACLRevokePreservesDenyRead(t *testing.T) { + // Synthetic capability SID (not a group this process is in) so DenyWrite's + // WRITE_DAC/DELETE bits do not lock the test out of its own temp dir. + caps, err := LoadOrCreateWindowsCapabilitySIDs(t.TempDir()) + if err != nil { + t.Fatalf("LoadOrCreateWindowsCapabilitySIDs: %v", err) + } + sid := caps.ReadOnly + dir := t.TempDir() + + // Profile A shape plus experimental broadening: both DenyRead and full + // DenyWrite for the same stable SID on one path (two ACEs in one apply so + // a second DENY_ACCESS merge cannot replace the first). + if _, _, err := applyWindowsACLPathGroup(windowsACLPathGroup{ + Path: dir, + Entries: []WindowsACLEntry{ + { + Action: WindowsACLDenyRead, + Path: dir, + Capability: sid, + NoInherit: true, + }, + { + Action: WindowsACLDenyWrite, + Path: dir, + Capability: sid, + NoInherit: true, + }, + }, + }); err != nil { + t.Fatalf("apply DenyRead+DenyWrite: %v", err) + } + writeDenied, err := windowsPathDeniesCapabilitySID(dir, sid) + if err != nil { + t.Fatalf("windowsPathDeniesCapabilitySID before: %v", err) + } + if !writeDenied { + t.Fatal("fixture: expected full write deny present before revoke") + } + if !dirDeniesReadSID(t, dir, sid) { + t.Fatal("fixture: expected read deny present before revoke") + } + + // Profile B promotes dir to a write root: revoke stale write deny only. + if _, _, err := applyWindowsACLPathGroup(windowsACLPathGroup{ + Path: dir, + Entries: []WindowsACLEntry{{ + Action: WindowsACLRevokeCapability, + Path: dir, + Capability: sid, + NoInherit: true, + }}, + }); err != nil { + t.Fatalf("revoke: %v", err) + } + writeDenied, err = windowsPathDeniesCapabilitySID(dir, sid) + if err != nil { + t.Fatalf("windowsPathDeniesCapabilitySID after: %v", err) + } + if writeDenied { + t.Fatal("write deny for SID still present after migration revoke") + } + if !dirDeniesReadSID(t, dir, sid) { + t.Fatal("DenyRead for same SID was removed by migration revoke; read boundary must be preserved") + } +} + +// dirDeniesReadSID reports whether path's DACL has a DENY ACE for wantSID whose +// mask covers FILE_GENERIC_READ (DenyRead shape) without the full write-probe +// mask of experimental DenyWrite. +func dirDeniesReadSID(t *testing.T, path, wantSID string) bool { + t.Helper() + want, err := windows.StringToSid(wantSID) + if err != nil { + t.Fatalf("StringToSid %q: %v", wantSID, err) + } + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("GetNamedSecurityInfo %s: %v", path, err) + } + dacl, _, err := sd.DACL() + if err != nil { + t.Fatalf("DACL %s: %v", path, err) + } + if dacl == nil { + return false + } + _, readMask, err := windowsACLAccess(WindowsACLDenyRead) + if err != nil { + t.Fatalf("windowsACLAccess DenyRead: %v", err) + } + for index := uint16(0); index < dacl.AceCount; index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, uint32(index), &ace); err != nil { + t.Fatalf("GetAce %d of %s: %v", index, path, err) + } + if ace.Header.AceType != windows.ACCESS_DENIED_ACE_TYPE { + continue + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if !sid.Equals(want) { + continue + } + if ace.Mask&readMask == readMask && !windowsIsExperimentalWriteDenyMask(ace.Mask) { + return true + } + } + return false +} + // A materialized target that does not exist yet is created, ACL'd through the // handle, and removed on rollback. func TestApplyWindowsACLPathGroupMaterializes(t *testing.T) { diff --git a/internal/sandbox/windows_acl_paths_windows.go b/internal/sandbox/windows_acl_paths_windows.go index c6e353e0b..def8f1e33 100644 --- a/internal/sandbox/windows_acl_paths_windows.go +++ b/internal/sandbox/windows_acl_paths_windows.go @@ -22,7 +22,9 @@ import ( // state and spoofable by anything that can influence the elevated setup // process. func resolveWindowsSharedDenyPaths() (systemDrive, systemRoot, programData, publicDir string, err error) { - windowsDir, err := windows.GetSystemWindowsDirectory() + // GetWindowsDirectory is the long-standing x/sys export; GetSystemWindowsDirectory + // is not available on every pinned golang.org/x/sys revision CI may use. + windowsDir, err := windows.GetWindowsDirectory() if err != nil { return "", "", "", "", fmt.Errorf("resolve system windows directory: %w", err) } diff --git a/internal/sandbox/windows_volumes_windows.go b/internal/sandbox/windows_volumes_windows.go deleted file mode 100644 index 19452e675..000000000 --- a/internal/sandbox/windows_volumes_windows.go +++ /dev/null @@ -1,134 +0,0 @@ -//go:build windows - -package sandbox - -import ( - "strings" - - "golang.org/x/sys/windows" -) - -// windowsSystemDriveIsOnlyFixedVolume reports whether the system drive is -// this machine's only fixed volume. -// -// The compensating shared-directory DenyWrite mitigation covers exactly four -// paths, all on the system drive, while the broadened Users/Authenticated -// Users restricting SIDs participate in every access check on every volume. -// A stock non-system NTFS data volume grants Authenticated Users Modify at -// its root with (OI)(CI)(IO) inheritance, so on a multi-volume host a -// broadened token could write anywhere on such a volume, outside every -// configured write root — and no bounded descendant scan can patch a grant -// inherited volume-wide. The broadening is therefore only sound when there -// is no other fixed volume to protect. -// -// A second fixed volume need not have a drive letter to be reachable: it can -// be mounted at an NTFS folder mount point (e.g. C:\mnt\data), which -// GetLogicalDriveStrings never reports (it only enumerates drive-letter -// roots). This enumerates every volume on the machine via -// FindFirstVolume/FindNextVolume and checks ALL of its mount points — -// drive letters and mounted folders alike — via GetVolumePathNamesForVolumeName, -// so a fixed volume mounted only as a folder is caught the same as one -// mounted on a drive letter. -// -// Fail closed: an enumeration failure, an unresolvable system drive, any -// additional fixed volume (by any mount path, including one with no -// conventional mount point at all), or any non-fixed volume (removable, -// optical, RAM disk, or otherwise not confirmed harmless) all report false, -// keeping the narrow restricting-SID set (reads of Users-granted system -// paths stay broken on such hosts, but the write jail holds). -func windowsSystemDriveIsOnlyFixedVolume() bool { - windowsDir, err := windows.GetSystemWindowsDirectory() - if err != nil || len(windowsDir) < 2 { - return false - } - systemDrive := strings.ToUpper(windowsDir[:2]) // e.g. "C:" - - volumeNameBuf := make([]uint16, 260) - handle, err := windows.FindFirstVolume(&volumeNameBuf[0], uint32(len(volumeNameBuf))) - if err != nil { - return false - } - defer windows.FindVolumeClose(handle) - - for { - volumeName := windows.UTF16ToString(volumeNameBuf) - onlySystemDrive, err := windowsVolumeMountsOnlySystemDrive(volumeName, systemDrive) - if err != nil { - return false - } - if !onlySystemDrive { - return false - } - if err := windows.FindNextVolume(handle, &volumeNameBuf[0], uint32(len(volumeNameBuf))); err != nil { - if err == windows.ERROR_NO_MORE_FILES { - break - } - return false - } - } - return true -} - -// windowsVolumeMountsOnlySystemDrive reports whether volumeName (a -// "\\?\Volume{GUID}\" path from FindFirstVolume/FindNextVolume) is a fixed -// volume mounted ONLY at the system drive root. Fail closed for everything -// else: a removable drive (USB media) and an optical/RAM-disk volume are -// reachable extra storage exactly like a second fixed volume — needing no -// network access to reach — so they are no longer assumed harmless just -// because GetDriveType says DRIVE_FIXED does not apply. A fixed volume with -// NO conventional mount point (a bare "\\?\Volume{GUID}\" with no drive -// letter or folder mount) is also reachable directly through that raw volume -// path, so an empty mount list fails closed too rather than being read as -// "unreachable." Any OTHER mount path — a different drive letter or a folder -// mount point — makes it a reachable extra fixed volume, regardless of which -// path form reaches it. -func windowsVolumeMountsOnlySystemDrive(volumeName, systemDrive string) (bool, error) { - volumeNamePtr, err := windows.UTF16PtrFromString(volumeName) - if err != nil { - return false, err - } - // Only a genuine local fixed disk is examined further. Removable media, - // optical drives, RAM disks, and any type Windows cannot positively - // classify (DRIVE_UNKNOWN, DRIVE_NO_ROOT_DIR, ...) all disqualify - // broadening outright rather than being assumed not to matter — see - // jatmn's review: treating every non-DRIVE_FIXED volume as safe ignored - // reachable removable/remote storage. - if windows.GetDriveType(volumeNamePtr) != windows.DRIVE_FIXED { - return false, nil - } - - buf := make([]uint16, 1024) - var returnLength uint32 - err = windows.GetVolumePathNamesForVolumeName(volumeNamePtr, &buf[0], uint32(len(buf)), &returnLength) - if err == windows.ERROR_MORE_DATA { - buf = make([]uint16, returnLength) - err = windows.GetVolumePathNamesForVolumeName(volumeNamePtr, &buf[0], uint32(len(buf)), &returnLength) - } - if err != nil { - return false, err - } - - mountPaths := windowsSplitNulList(buf) - return windowsMountPathsAreOnlySystemDrive(mountPaths, systemDrive), nil -} - -// windowsSplitNulList splits the double-NUL-terminated UTF-16 string list -// returned by GetLogicalDriveStrings into Go strings. -func windowsSplitNulList(buf []uint16) []string { - var out []string - start := 0 - for i, c := range buf { - if c == 0 { - if i > start { - out = append(out, windows.UTF16ToString(buf[start:i])) - } - start = i + 1 - } - } - if start < len(buf) { - if s := windows.UTF16ToString(buf[start:]); s != "" { - out = append(out, s) - } - } - return out -} From d39fab0aea512d5a2a529488a27d3e0477c4a182 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:17:21 -0400 Subject: [PATCH 25/26] fix(sandbox): reject DenyRead with elevated restricted-token sandbox Fully restricted tokens without Users/AuthUsers cannot load ordinary system binaries, and SID broadening is permanently off. Fail closed with an actionable unsupported-mode error before launch, with regression coverage. --- internal/sandbox/windows_command_runner.go | 24 +++++++++ .../sandbox/windows_command_runner_test.go | 51 +++++++++++++++++++ .../sandbox/windows_command_runner_windows.go | 38 ++++++-------- 3 files changed, 91 insertions(+), 22 deletions(-) create mode 100644 internal/sandbox/windows_command_runner_test.go diff --git a/internal/sandbox/windows_command_runner.go b/internal/sandbox/windows_command_runner.go index cb320f4ca..c16186423 100644 --- a/internal/sandbox/windows_command_runner.go +++ b/internal/sandbox/windows_command_runner.go @@ -3,6 +3,7 @@ package sandbox import ( "fmt" "io" + "strings" ) func RunWindowsSandboxCommandRunner(args []string, stderr io.Writer) int { @@ -17,3 +18,26 @@ func RunWindowsSandboxCommandRunner(args []string, stderr io.Writer) int { } return runWindowsSandboxCommand(config, stderr) } + +// windowsDenyReadRestrictedTokenUnsupported reports that elevated restricted- +// token sandboxing cannot run profiles with DenyRead until access-time +// confinement exists. A fully restricted token without Users/AuthUsers cannot +// load ordinary system executables; adding those groups reopens write grants +// outside WriteRoots. Prefer a clear rejection over a silent launch failure. +func windowsDenyReadRestrictedTokenUnsupported(config WindowsSandboxCommandConfig) error { + if config.SandboxLevel != WindowsSandboxLevelRestrictedToken { + return nil + } + if len(config.PermissionProfile.FileSystem.DenyRead) == 0 { + return nil + } + return fmt.Errorf( + "DenyRead is not supported with the elevated Windows restricted-token sandbox: "+ + "without Users/Authenticated Users in the restricting SID set, ordinary system "+ + "binaries under Program Files and Windows cannot load, and adding those groups "+ + "would admit their existing write grants outside WriteRoots. "+ + "Use `--sandbox forbid`, the unelevated sandbox tier, omit DenyRead, or wait for "+ + "access-time confinement (AppContainer/LPAC-style). Configured DenyRead paths: %s", + strings.Join(config.PermissionProfile.FileSystem.DenyRead, ", "), + ) +} diff --git a/internal/sandbox/windows_command_runner_test.go b/internal/sandbox/windows_command_runner_test.go new file mode 100644 index 000000000..283bda143 --- /dev/null +++ b/internal/sandbox/windows_command_runner_test.go @@ -0,0 +1,51 @@ +package sandbox + +import ( + "strings" + "testing" +) + +func TestWindowsDenyReadRestrictedTokenUnsupported(t *testing.T) { + // Restricted-token + DenyRead must be rejected before launch. + err := windowsDenyReadRestrictedTokenUnsupported(WindowsSandboxCommandConfig{ + SandboxLevel: WindowsSandboxLevelRestrictedToken, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + DenyRead: []string{`C:\secret`, `D:\private`}, + }, + }, + }) + if err == nil { + t.Fatal("expected unsupported error for restricted-token DenyRead profile") + } + msg := err.Error() + for _, want := range []string{"DenyRead", "not supported", "restricted-token", `C:\secret`} { + if !strings.Contains(msg, want) { + t.Fatalf("error %q missing %q", msg, want) + } + } + + // No DenyRead: allowed (WRITE_RESTRICTED path can launch system tools). + if err := windowsDenyReadRestrictedTokenUnsupported(WindowsSandboxCommandConfig{ + SandboxLevel: WindowsSandboxLevelRestrictedToken, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{Kind: FileSystemRestricted}, + }, + }); err != nil { + t.Fatalf("unexpected error without DenyRead: %v", err) + } + + // Unelevated + DenyRead remains allowed (different token tier). + if err := windowsDenyReadRestrictedTokenUnsupported(WindowsSandboxCommandConfig{ + SandboxLevel: WindowsSandboxLevelUnelevated, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + DenyRead: []string{`C:\secret`}, + }, + }, + }); err != nil { + t.Fatalf("unexpected error for unelevated DenyRead: %v", err) + } +} diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 37b92b397..011e63fb3 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -14,6 +14,14 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) return 1 } + // Fully restricted DenyRead tokens cannot load ordinary Users-granted + // system binaries without SID broadening; broadening is permanently + // off because it admits write grants outside WriteRoots. Reject before + // launch until access-time confinement exists (PR #640). + if err := windowsDenyReadRestrictedTokenUnsupported(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()) @@ -69,29 +77,15 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ // this has no in-token fix; preflight blocking and output hints live in // internal/tools/shell_runtime.go. tokenSIDs := windowsRuntimeTokenSIDs(capabilitySIDs, offlineSID, config.PermissionProfile.Network.Mode) - // A WRITE_RESTRICTED token keeps reads unrestricted so sandboxed commands - // can actually launch executables; it is only unsafe when DenyRead paths - // are configured, because the kernel skips restricted-SID deny ACEs for - // reads under that flag (#612). Profiles with DenyRead keep the fully - // restricted token, trading spawn capability for read-deny enforcement. + // WRITE_RESTRICTED keeps reads unrestricted so sandboxed commands can load + // Users-granted executables. It is only used when DenyRead is empty (#612: + // WRITE_RESTRICTED skips restricted-SID deny ACEs for reads). DenyRead on + // the restricted-token tier is rejected above rather than launching a fully + // restricted narrow-SID token that cannot execute normal tools. writeRestricted := len(config.PermissionProfile.FileSystem.DenyRead) == 0 - // Users/Authenticated Users SID broadening is permanently disabled. - // - // Adding those groups to the restricted-SID list would let the sandboxed - // process execute binaries under Program Files / Windows (which grant - // Users rather than Everyone), but the same restricted-SID match also - // unlocks every existing Users/AuthUsers write grant on the machine. - // Compensating DenyWrite ACEs applied from a preflight path walk cannot - // establish an access-time write boundary for the command's lifetime: - // reparse targets can hide writable children the walk never sees, and a - // normal process can create a group-writable child under a shared root - // after the scan (or after token creation) because the synthetic denies - // are deliberately non-inheriting. Until write confinement is enforced - // by the OS at open time (AppContainer/LPAC package identity with - // capability grants, or a path-policy sandbox API), keep the narrow - // restricting-SID set. DenyRead profiles may fail to launch ordinary - // system binaries as a result; that is preferred to a silent write-jail - // escape. See PR #640 review. + // Users/Authenticated Users SID broadening is permanently disabled: those + // groups unlock write grants outside WriteRoots, and preflight DenyWrite + // compensation cannot enforce an access-time write boundary. See PR #640. const broadenReadSIDs = false token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs, writeRestricted, broadenReadSIDs) if err != nil { From 20f56323beb8585b6053d487a27df3437ac432ec Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:08:48 -0400 Subject: [PATCH 26/26] fix(sandbox): reject DenyRead on both Windows restricted-token tiers Unelevated auto-fallback built the same fully restricted narrow-SID token as the elevated path, so DenyRead still failed before ordinary tools could load. Reject nonempty DenyRead before setup or launch on both tiers, drop the unelevated workaround advice, and cover manager plus runner levels. --- internal/sandbox/manager_test.go | 81 +++++++++++++++++++ internal/sandbox/profile.go | 9 ++- .../runner_windows_integration_test.go | 39 ++++++++- internal/sandbox/windows_command_runner.go | 41 ++++++---- .../sandbox/windows_command_runner_test.go | 71 ++++++++-------- .../sandbox/windows_command_runner_windows.go | 23 +++--- internal/sandbox/windows_runner.go | 8 +- internal/sandbox/windows_setup_windows.go | 6 ++ 8 files changed, 210 insertions(+), 68 deletions(-) diff --git a/internal/sandbox/manager_test.go b/internal/sandbox/manager_test.go index 566bdf7c7..e5deefdf5 100644 --- a/internal/sandbox/manager_test.go +++ b/internal/sandbox/manager_test.go @@ -230,6 +230,87 @@ func TestSandboxManagerBuildsCommandPlanThroughWindowsRunner(t *testing.T) { } } +// TestSandboxManagerRejectsWindowsDenyReadOnBothRestrictedTokenTiers is the +// regression for PR #640: DenyRead cannot be launched or provisioned through +// either the elevated restricted-token path or the unelevated auto fallback. +// Both build the same fully restricted narrow-SID token. +func TestSandboxManagerRejectsWindowsDenyReadOnBothRestrictedTokenTiers(t *testing.T) { + backend := Backend{Name: BackendWindowsRestrictedToken, Available: true, Executable: `C:\zero\zero-windows-command-runner.exe`, Platform: "windows"} + manager := NewSandboxManager(SandboxManagerOptions{GOOS: "windows", Backend: backend}) + policy := DefaultPolicy() + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: `C:\workspace`}}, + DenyRead: []string{`C:\workspace\secret`}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + cmd := CommandSpec{Name: "cmd.exe", Args: []string{"/c", "dir"}, Dir: `C:\workspace`} + + t.Run("elevated_restricted_token", func(t *testing.T) { + restore := windowsSandboxInitialized + t.Cleanup(func() { windowsSandboxInitialized = restore }) + windowsSandboxInitialized = func() bool { return true } + + _, err := manager.BuildCommandPlan(SandboxManagerRequest{ + WorkspaceRoot: `C:\workspace`, + Command: cmd, + Policy: policy, + Profile: profile, + Preference: SandboxPreferenceAuto, + ValidateExecution: true, + }) + if err == nil { + t.Fatal("expected BuildCommandPlan error for elevated restricted-token DenyRead") + } + msg := err.Error() + for _, want := range []string{"DenyRead", "not supported", "restricted-token"} { + if !strings.Contains(msg, want) { + t.Fatalf("error %q missing %q", msg, want) + } + } + }) + + t.Run("unelevated_auto_fallback", func(t *testing.T) { + restore := windowsSandboxInitialized + t.Cleanup(func() { windowsSandboxInitialized = restore }) + windowsSandboxInitialized = func() bool { return false } + + req, err := manager.BuildExecutionRequest(SandboxManagerRequest{ + WorkspaceRoot: `C:\workspace`, + Command: cmd, + Policy: policy, + Profile: profile, + Preference: SandboxPreferenceAuto, + ValidateExecution: true, + }) + if err != nil { + t.Fatalf("BuildExecutionRequest: %v", err) + } + if req.EnforcementLevel != EnforcementUnelevated { + t.Fatalf("EnforcementLevel = %v, want unelevated auto fallback before DenyRead rejection", req.EnforcementLevel) + } + _, err = manager.BuildCommandPlan(SandboxManagerRequest{ + WorkspaceRoot: `C:\workspace`, + Command: cmd, + Policy: policy, + Profile: profile, + Preference: SandboxPreferenceAuto, + ValidateExecution: true, + }) + if err == nil { + t.Fatal("expected BuildCommandPlan error for unelevated DenyRead") + } + if !strings.Contains(err.Error(), "DenyRead") || !strings.Contains(err.Error(), "not supported") { + t.Fatalf("unelevated DenyRead error = %v", err) + } + if strings.Contains(err.Error(), "Use `--sandbox forbid`, the unelevated") { + t.Fatalf("error still recommends unelevated as a workaround: %v", err) + } + }) +} + func TestSandboxManagerDegradesUnavailableCommandPlan(t *testing.T) { policy := DefaultPolicy() backend := Backend{Name: BackendUnavailable, Platform: "windows", Fallback: true, Message: "native sandbox unavailable"} diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index 47947316c..c702f5b45 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -150,10 +150,11 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop // carry credentials and are now discoverable through the preserved caller // environment. Two deliberate limits: // -// - Windows is skipped: a non-empty profile DenyRead switches the Windows -// runner onto the capability-SID/ACL deny path and away from the -// WRITE_RESTRICTED token, which the unelevated tier depends on. Revisit -// once the Windows deny-read model is settled. +// - Windows is skipped: a non-empty profile DenyRead is unsupported on both +// restricted-token runner levels under the narrow SID set (PR #640). The +// fully restricted token cannot load ordinary system binaries without +// Users/AuthUsers, and adding those groups reopens write grants outside +// WriteRoots. Revisit once access-time confinement exists. // - A candidate nested under a user-configured AllowRead entry is dropped, // so `allowRead: ["~/.aws"]` remains an explicit opt-out. // diff --git a/internal/sandbox/runner_windows_integration_test.go b/internal/sandbox/runner_windows_integration_test.go index a2475ecc2..617f7cd96 100644 --- a/internal/sandbox/runner_windows_integration_test.go +++ b/internal/sandbox/runner_windows_integration_test.go @@ -211,10 +211,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{ @@ -426,6 +434,31 @@ 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 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) + } + } +} + func powershellSingleQuote(value string) string { out := "'" for _, r := range value { diff --git a/internal/sandbox/windows_command_runner.go b/internal/sandbox/windows_command_runner.go index c16186423..911e7c2da 100644 --- a/internal/sandbox/windows_command_runner.go +++ b/internal/sandbox/windows_command_runner.go @@ -19,25 +19,38 @@ func RunWindowsSandboxCommandRunner(args []string, stderr io.Writer) int { return runWindowsSandboxCommand(config, stderr) } -// windowsDenyReadRestrictedTokenUnsupported reports that elevated restricted- -// token sandboxing cannot run profiles with DenyRead until access-time -// confinement exists. A fully restricted token without Users/AuthUsers cannot -// load ordinary system executables; adding those groups reopens write grants -// outside WriteRoots. Prefer a clear rejection over a silent launch failure. +// windowsDenyReadRestrictedTokenUnsupported reports that Windows restricted- +// token sandboxing (elevated restricted-token or unelevated) cannot run +// profiles with DenyRead until access-time confinement exists. Both runner +// levels build the same fully restricted narrow-SID token when DenyRead is +// set: without Users/AuthUsers it cannot load ordinary system executables; +// adding those groups reopens write grants outside WriteRoots. Prefer a clear +// rejection over a silent launch failure. Do not recommend the other tier as a +// workaround: the limitation is the token mechanism, not elevation. func windowsDenyReadRestrictedTokenUnsupported(config WindowsSandboxCommandConfig) error { - if config.SandboxLevel != WindowsSandboxLevelRestrictedToken { + switch config.SandboxLevel { + case WindowsSandboxLevelRestrictedToken, WindowsSandboxLevelUnelevated: + default: return nil } - if len(config.PermissionProfile.FileSystem.DenyRead) == 0 { + return windowsDenyReadRestrictedTokenUnsupportedProfile(config.PermissionProfile) +} + +// windowsDenyReadRestrictedTokenUnsupportedProfile is the level-agnostic check +// used by the manager, command-plan builder, setup, and runner so DenyRead is +// rejected before any restricted-token path can provision or launch. +func windowsDenyReadRestrictedTokenUnsupportedProfile(profile PermissionProfile) error { + if len(profile.FileSystem.DenyRead) == 0 { return nil } return fmt.Errorf( - "DenyRead is not supported with the elevated Windows restricted-token sandbox: "+ - "without Users/Authenticated Users in the restricting SID set, ordinary system "+ - "binaries under Program Files and Windows cannot load, and adding those groups "+ - "would admit their existing write grants outside WriteRoots. "+ - "Use `--sandbox forbid`, the unelevated sandbox tier, omit DenyRead, or wait for "+ - "access-time confinement (AppContainer/LPAC-style). Configured DenyRead paths: %s", - strings.Join(config.PermissionProfile.FileSystem.DenyRead, ", "), + "DenyRead is not supported with the Windows restricted-token sandbox "+ + "(elevated or unelevated): without Users/Authenticated Users in the "+ + "restricting SID set, ordinary system binaries under Program Files and "+ + "Windows cannot load, and adding those groups would admit their existing "+ + "write grants outside WriteRoots. "+ + "Use `--sandbox forbid`, omit DenyRead, or wait for access-time confinement "+ + "(AppContainer/LPAC-style). Configured DenyRead paths: %s", + strings.Join(profile.FileSystem.DenyRead, ", "), ) } diff --git a/internal/sandbox/windows_command_runner_test.go b/internal/sandbox/windows_command_runner_test.go index 283bda143..1873e5692 100644 --- a/internal/sandbox/windows_command_runner_test.go +++ b/internal/sandbox/windows_command_runner_test.go @@ -6,46 +6,47 @@ import ( ) func TestWindowsDenyReadRestrictedTokenUnsupported(t *testing.T) { - // Restricted-token + DenyRead must be rejected before launch. - err := windowsDenyReadRestrictedTokenUnsupported(WindowsSandboxCommandConfig{ - SandboxLevel: WindowsSandboxLevelRestrictedToken, - PermissionProfile: PermissionProfile{ - FileSystem: FileSystemPolicy{ - Kind: FileSystemRestricted, - DenyRead: []string{`C:\secret`, `D:\private`}, + // Both restricted-token runner levels reject DenyRead before launch/setup. + for _, level := range []WindowsSandboxLevel{ + WindowsSandboxLevelRestrictedToken, + WindowsSandboxLevelUnelevated, + } { + err := windowsDenyReadRestrictedTokenUnsupported(WindowsSandboxCommandConfig{ + SandboxLevel: level, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + DenyRead: []string{`C:\secret`, `D:\private`}, + }, }, - }, - }) - if err == nil { - t.Fatal("expected unsupported error for restricted-token DenyRead profile") - } - msg := err.Error() - for _, want := range []string{"DenyRead", "not supported", "restricted-token", `C:\secret`} { - if !strings.Contains(msg, want) { - t.Fatalf("error %q missing %q", msg, want) + }) + if err == nil { + t.Fatalf("expected unsupported error for %s DenyRead profile", level) + } + msg := err.Error() + for _, want := range []string{"DenyRead", "not supported", "restricted-token", "unelevated", `C:\secret`} { + if !strings.Contains(msg, want) { + t.Fatalf("%s error %q missing %q", level, msg, want) + } + } + // Must not point users at the other restricted-token tier as a workaround. + if strings.Contains(msg, "Use `--sandbox forbid`, the unelevated") { + t.Fatalf("%s error still recommends unelevated as a DenyRead workaround: %q", level, msg) } } // No DenyRead: allowed (WRITE_RESTRICTED path can launch system tools). - if err := windowsDenyReadRestrictedTokenUnsupported(WindowsSandboxCommandConfig{ - SandboxLevel: WindowsSandboxLevelRestrictedToken, - PermissionProfile: PermissionProfile{ - FileSystem: FileSystemPolicy{Kind: FileSystemRestricted}, - }, - }); err != nil { - t.Fatalf("unexpected error without DenyRead: %v", err) - } - - // Unelevated + DenyRead remains allowed (different token tier). - if err := windowsDenyReadRestrictedTokenUnsupported(WindowsSandboxCommandConfig{ - SandboxLevel: WindowsSandboxLevelUnelevated, - PermissionProfile: PermissionProfile{ - FileSystem: FileSystemPolicy{ - Kind: FileSystemRestricted, - DenyRead: []string{`C:\secret`}, + for _, level := range []WindowsSandboxLevel{ + WindowsSandboxLevelRestrictedToken, + WindowsSandboxLevelUnelevated, + } { + if err := windowsDenyReadRestrictedTokenUnsupported(WindowsSandboxCommandConfig{ + SandboxLevel: level, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{Kind: FileSystemRestricted}, }, - }, - }); err != nil { - t.Fatalf("unexpected error for unelevated DenyRead: %v", err) + }); err != nil { + t.Fatalf("unexpected error without DenyRead at %s: %v", level, err) + } } } diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 011e63fb3..0b1c67876 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -8,20 +8,21 @@ import ( ) func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writer) int { + // Fully restricted DenyRead tokens cannot load ordinary Users-granted + // system binaries without SID broadening; broadening is permanently off + // because it admits write grants outside WriteRoots. Reject on both the + // elevated and unelevated restricted-token tiers before setup or launch + // until access-time confinement exists (PR #640). + if err := windowsDenyReadRestrictedTokenUnsupported(config); err != nil { + fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) + return 1 + } switch config.SandboxLevel { case WindowsSandboxLevelRestrictedToken: if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(config)); err != nil { fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) return 1 } - // Fully restricted DenyRead tokens cannot load ordinary Users-granted - // system binaries without SID broadening; broadening is permanently - // off because it admits write grants outside WriteRoots. Reject before - // launch until access-time confinement exists (PR #640). - if err := windowsDenyReadRestrictedTokenUnsupported(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()) @@ -79,9 +80,9 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ tokenSIDs := windowsRuntimeTokenSIDs(capabilitySIDs, offlineSID, config.PermissionProfile.Network.Mode) // WRITE_RESTRICTED keeps reads unrestricted so sandboxed commands can load // Users-granted executables. It is only used when DenyRead is empty (#612: - // WRITE_RESTRICTED skips restricted-SID deny ACEs for reads). DenyRead on - // the restricted-token tier is rejected above rather than launching a fully - // restricted narrow-SID token that cannot execute normal tools. + // WRITE_RESTRICTED skips restricted-SID deny ACEs for reads). Non-empty + // DenyRead is rejected above for both runner levels rather than launching a + // fully restricted narrow-SID token that cannot execute normal tools. writeRestricted := len(config.PermissionProfile.FileSystem.DenyRead) == 0 // Users/Authenticated Users SID broadening is permanently disabled: those // groups unlock write grants outside WriteRoots, and preflight DenyWrite diff --git a/internal/sandbox/windows_runner.go b/internal/sandbox/windows_runner.go index 032f2a844..400ecad58 100644 --- a/internal/sandbox/windows_runner.go +++ b/internal/sandbox/windows_runner.go @@ -317,6 +317,12 @@ func ParseWindowsSandboxCommandArgs(args []string) (WindowsSandboxCommandConfig, } func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, policy Policy) (CommandPlan, error) { + // Reject DenyRead before provisioning either restricted-token runner level. + // Both elevated and unelevated build the same fully restricted narrow-SID + // token for DenyRead profiles, which cannot load ordinary system binaries. + if err := windowsDenyReadRestrictedTokenUnsupportedProfile(execRequest.PermissionProfile); err != nil { + return CommandPlan{}, err + } spec := execRequest.Command var sandboxHomeEnv map[string]string if spec.Env != nil { @@ -329,7 +335,7 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli childEnv := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendWindowsRestrictedToken, execRequest.WorkspaceRoot, spec.sensitiveEnvKeys) childEnv = sandboxRuntimeEnvironment(childEnv, execRequest.PermissionProfile.Runtime) // The unelevated enforcement tier maps to the runner's unelevated level: same - // restricted token, but the runner applies the workspace ACLs itself instead + // restricted token, but the runner applies the workspace ACL plan itself instead // of requiring the elevated setup marker. level := WindowsSandboxLevelRestrictedToken if execRequest.EnforcementLevel == EnforcementUnelevated { diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 888355397..d2c2d2f90 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -17,6 +17,12 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) fmt.Fprintln(stderr, WindowsSandboxSetupName+": Administrator rights are required. Re-run `zero sandbox setup` from an elevated (Run as administrator) terminal.") return 1 } + // Do not provision DenyRead ACLs for a token mode that cannot launch normal + // tools with DenyRead under the narrow restricting-SID set (PR #640). + if err := windowsDenyReadRestrictedTokenUnsupportedProfile(config.PermissionProfile); err != nil { + fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) + return 1 + } plan, err := BuildWindowsACLPlan(config.commandConfig()) if err != nil { fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error())