Skip to content

fix(windows): add Users and Authenticated Users SIDs to restricted token SIDs#640

Open
euxaristia wants to merge 8 commits into
Gitlawb:mainfrom
euxaristia:fix/windows-sandbox-restricted-token-sids
Open

fix(windows): add Users and Authenticated Users SIDs to restricted token SIDs#640
euxaristia wants to merge 8 commits into
Gitlawb:mainfrom
euxaristia:fix/windows-sandbox-restricted-token-sids

Conversation

@euxaristia

@euxaristia euxaristia commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

This PR adds the WinBuiltinUsersSid and WinAuthenticatedUserSid well-known SIDs to the Windows restricted token's SIDs list. This allows the sandboxed process to read and execute programs like Go, Python, or Node from folders like C:\Program Files or C:\Windows where permissions are granted to the Users/Authenticated Users groups, without compromising the write jail.

Summary by CodeRabbit

  • Bug Fixes

    • Improved Windows sandbox restricted-token behavior by expanding read permissions and strengthening deny-write ACL coverage for common shared system paths (including Public and ProgramData).
    • Added support for non-inheritable deny ACEs so shared-directory protections are applied correctly and aren’t collapsed by ACL deduplication.
  • Tests

    • Updated Windows ACL planning tests and smoke/integration checks to verify shared-directory marker writes are blocked only for the restricted-token tier, and omitted in unelevated mode.
    • Added new test cases for missing DenyRead scenarios and for preserving distinct inheritance variants.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 14af2787-4d03-445f-be89-45889480ed2f

📥 Commits

Reviewing files that changed from the base of the PR and between 949397f and 821f436.

📒 Files selected for processing (2)
  • internal/sandbox/windows_acl.go
  • internal/sandbox/windows_acl_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/sandbox/windows_acl.go
  • internal/sandbox/windows_acl_test.go

Walkthrough

Windows restricted-token ACL planning now adds non-inheriting shared-directory deny-write entries, while token creation conditionally broadens read SIDs by sandbox level. Windows ACL tests and integration smoke tests cover restricted-token, unelevated, Public, and ProgramData behavior.

Changes

Windows sandbox enforcement

Layer / File(s) Summary
Shared-path deny-write ACL planning
internal/sandbox/windows_acl.go, internal/sandbox/windows_acl_apply_windows.go, internal/sandbox/windows_acl_paths_*.go
Restricted-token plans resolve shared Windows paths and add capability-based, non-inheriting deny-write entries while preserving allowed write roots. ACL application computes inheritance per entry.
Restricted-token read scope
internal/sandbox/windows_command_runner_windows.go, internal/sandbox/windows_token_windows.go
The command runner passes a sandbox-level-dependent read-scope flag, and restricted-token construction conditionally adds Builtin Users and Authenticated Users SID entries.
ACL and integration validation
internal/sandbox/windows_acl_test.go, internal/sandbox/runner_windows_integration_test.go
Tests validate shared-path entries and their omission for unsupported tiers; smoke tests verify sandboxed writes to Public and ProgramData fail without creating marker files.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SandboxCommand
  participant BuildWindowsACLPlan
  participant SharedPathResolver
  participant RestrictedToken
  participant SharedDirectory
  SandboxCommand->>BuildWindowsACLPlan: Build restricted-token ACL plan
  BuildWindowsACLPlan->>SharedPathResolver: Resolve shared Windows paths
  SharedPathResolver-->>BuildWindowsACLPlan: Return system and shared roots
  BuildWindowsACLPlan->>RestrictedToken: Add deny-write entries for capability SID
  SandboxCommand->>RestrictedToken: Create token with broadenReadSIDs
  RestrictedToken->>SharedDirectory: Attempt sandboxed write
  SharedDirectory-->>RestrictedToken: Reject write
Loading

Possibly related issues

  • Gitlawb/zero issue 662: Extends Windows token and ACL enforcement for shared-path and deny-read behavior.

Possibly related PRs

  • Gitlawb/zero#427: Introduces the unelevated Windows sandbox flow and its smoke test, which this change extends.
  • Gitlawb/zero#612: Modifies restricted-token construction and Windows access-denial behavior.
  • Gitlawb/zero#658: Adjusts the DenyRead-driven restricted-token handling changed by this PR.

Suggested reviewers: vasanthdev2004, gnanam1990, kevincodex1

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: broadening restricted Windows token SIDs with Users and Authenticated Users.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed this against #612 (which removed the windowsWriteRestricted flag so the restricting-SID access check runs on both reads and writes, fixing the DenyRead bypass). I traced the security tradeoff carefully.

What I verified:

  • The #612 fix is preserved. createWindowsRestrictedTokenFromBase still calls CreateRestrictedToken with windowsDisableMaxPrivilege|windowsLUAToken only (windows_token_windows.go:118) — windowsWriteRestricted (0x08) is not re-added, so check-2 of the restricting SIDs runs for read and write.
  • DenyRead is NOT reintroduced. DenyRead is enforced by a Deny ACE for the read-deny capability SID, which is itself in the restricting-SID set (windowsRuntimeTokenSIDs -> capabilitySIDs). Deny ACEs override Allow ACEs in check-2, so adding Users/Authenticated Users — which only match Allow ACEs on DenyRead paths — does not let reads past the deny. The #612 integration assertion (TestWindowsUnelevatedRealSandboxSmoke, runner_windows_integration_test.go:192-205) still holds.
  • The read fix is legitimate. After #612, check-2 runs on reads, and the old restricting list {capability SIDs, logon SID, world SID} could not read binaries in C:\Program Files / C:\Windows, whose ACLs grant read/execute to BUILTIN\Users / Authenticated Users rather than to Everyone. That broke the documented "full disk is readable" posture (profile.go:104-108) and stopped go/python/node from running out of Program Files. Adding these two SIDs to the restricting list makes check-2 pass for those reads, which restores the intended read-all behavior.

Build/vet/test: go build ./..., go vet ./..., and go test ./internal/sandbox/... all pass; gofmt clean.

The concern that blocks me:

The restricting-SID access check can't scope a SID to read-only — a restricting SID grants whatever the object's DACL grants to it, for reads AND writes. The write jail is enforced solely by check-2: there is no blanket deny-elsewhere ACL, only AllowWrite on workspace roots plus explicit DenyWrite/DenyRead entries (BuildWindowsACLPlan / applyWindowsACLPlan). So adding two well-known SIDs that carry real WRITE grants widens the jail.

I checked the actual ACLs on this host with icacls:

  • C:\ProgramData grants BUILTIN\Users:(CI)(WD,AD,WEA,WA) — Users can create files/subdirectories.
  • C:\ grants NT AUTHORITY\Authenticated Users:(AD) — Authenticated Users can create top-level directories.

Before this PR, check-2 failed for writes to those paths (no restricting SID matched the Users/AuthUsers grant), so the writes were denied. After this PR, check-2 passes, so a sandboxed process can create files in C:\ProgramData and new folders under C:, all outside the workspace. This is a demonstrable widening of the write jail — the sandbox's core property.

The codebase already documents this principle. windows_command_runner_windows.go:54-70 states, for the MSYS signal-pipe SIDs, that "None of the granted SIDs can be added to the restricted list without collapsing the write jail (each has write access nearly everywhere)." The same reasoning applies to BUILTIN\Users and Authenticated Users for shared system paths.

Requesting changes. To land this I'd want one of:

  1. Add explicit DenyWrite ACEs for the affected shared roots (e.g. C:, C:\ProgramData, and any other Users-writable system path the sandbox should not write to) in BuildWindowsACLPlan, so the deny-ACE override restores the jail despite the added SIDs; plus an integration assertion that a write to a Users-writable shared path outside the workspace is blocked. Or
  2. If the widening is judged acceptable for the unelevated tier, state that explicitly in the PR body and add a test that asserts the new boundary (e.g. a write to C:\ProgramData is now permitted-by-design, or explicitly denied if mitigated), so the posture change is recorded rather than implicit.

I'm comfortable with the read-side intent and confirmed DenyRead stays intact — my objection is only that the write-side tradeoff is real, unmitigated, and not acknowledged in the change.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Keep global system ACL changes out of the unelevated setup path
    internal/sandbox/windows_acl.go:82
    BuildWindowsACLPlan now unconditionally puts C:\\, %ProgramData%, and %SystemRoot%\\Temp in every plan. The unelevated runner applies that exact plan before launching the command, and applyWindowsACLPlan fails the setup when SetNamedSecurityInfo cannot update a target DACL. Ordinary users do not have WRITE_DAC on those system-owned directories, so an unelevated sandbox command now aborts with access denied before it starts; the tier is specifically intended to require edits only to user-owned workspace/temp roots. Do not apply these global DACL mutations from the unelevated runner (or use an enforcement mechanism that does not require administrator rights), and cover a non-admin run.

  • [P1] Preserve the deny below the system drive instead of making it root-only
    internal/sandbox/windows_acl.go:113
    A normal workspace or %TEMP% write root lies under C:\\, causing this code to set NoInherit on the C:\\ deny. The ACL builder then emits an ACE with zero inheritance, so it protects only the drive root; the two direct denies cover only ProgramData and Windows Temp. Since this PR adds BUILTIN\\Users and Authenticated Users to the restricting SID set, any other shared child that grants either group write (for example C:\\Users\\Public) passes the restricted-token check and can be modified outside the configured write roots. Retain a deny that covers non-carved-out descendants (with a safe explicit allowed-root exception) and add a real-Windows regression probe for an independent shared writable child.

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.
@euxaristia

Copy link
Copy Markdown
Contributor Author

Pushed a fixup addressing both P1 findings.

Root cause: the DenyWrite mitigation for the widened Users/Authenticated Users SIDs was applied on the same code path used by both the elevated and unelevated tiers, but the unelevated tier has no WRITE_DAC on system-owned paths like C:, ProgramData, or Windows\Temp, so it aborted every command.

Fix: WinBuiltinUsersSid/WinAuthenticatedUserSid are now only added to the restricted token for the elevated restricted-token tier (zero sandbox setup, run as Administrator). That's also the only tier with the rights to enforce the DenyWrite mitigation on shared system paths, so BuildWindowsACLPlan now only emits those entries for that tier. The unelevated tier keeps the original, narrower restricting-SID set (no Program Files/System32 read widening there), which sidesteps the access-denied abort entirely.

Also fixed the C:\Users\Public gap: inheriting a deny ACE from C:\ never actually protected pre-existing children like it, since NTFS doesn't retroactively propagate an inherited ACE onto objects that already exist. Added it as an explicit DenyWrite target instead, and dropped the NoInherit toggle that tried to route around this by disabling inheritance whenever a write root sat under C:\ (it wasn't needed: a write root's own explicit Allow ACE already takes precedence over anything inherited, by canonical ACE ordering).

Added a unit test (TestBuildWindowsACLPlanOmitsSharedDenyPathsWhenUnelevated) covering the unelevated non-admin path, and a real-Windows regression probe for the C:\Users\Public write jail in the elevated smoke test.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
internal/sandbox/windows_acl.go (3)

91-113: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate env-var default logic vs. test helper.

This exact SystemDrive/SystemRoot/ProgramData/PUBLIC default-resolution logic is duplicated verbatim in windowsSharedDenyPathsForTest (windows_acl_test.go, lines 107-124). If the defaults ever change here, the test helper can silently drift out of sync and stop catching regressions.

♻️ Extract a shared helper
func windowsSharedDenyPaths() (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`
	}
	return systemDrive, systemRoot, programData, publicDir
}

Then have both BuildWindowsACLPlan and windowsSharedDenyPathsForTest call into it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/windows_acl.go` around lines 91 - 113, Extract the
duplicated Windows environment default resolution into a shared
windowsSharedDenyPaths helper, then update BuildWindowsACLPlan and the test
helper windowsSharedDenyPathsForTest to use it. Preserve the existing
SystemDrive, SystemRoot, ProgramData, and PUBLIC fallback values and resulting
deny paths.

125-128: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Exemption branch has no direct test coverage.

None of the existing tests set a WriteRoot that exactly equals a shared deny path (e.g. C:\ProgramData), so the windowsPathEqualsAnyRoot continue here is never actually exercised. Given this is write-jail-relevant logic, a dedicated case verifying a write root at, say, C:\Windows\Temp gets the Allow entry without a conflicting DenyWrite would give confidence this exemption behaves correctly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/windows_acl.go` around lines 125 - 128, Add a focused test
for the ACL generation flow around windowsPathEqualsAnyRoot using a WriteRoot
that exactly matches a shared deny path, such as C:\Windows\Temp. Assert the
resulting ACL includes an Allow entry for that path and no conflicting DenyWrite
entry, exercising the continue branch in the sharedDenyPaths loop.

115-123: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Reuse writeSIDs for the shared-deny list. windowsWriteCapabilitySIDs already dedupes the write-root SIDs, so rebuilding them here is unnecessary; append caps.ReadOnly to a copy of writeSIDs instead. If the extra capability-file read matters on the DenyRead-only path, pass the loaded value through so windowsReadDenyCapabilitySIDs does not reload it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/windows_acl.go` around lines 115 - 123, Update the
shared-deny construction in the Windows ACL planning flow to copy the
already-deduplicated writeSIDs and append caps.ReadOnly, rather than rebuilding
SIDs from writeCapabilities. Reuse the loaded capability value by passing it
through to windowsReadDenyCapabilitySIDs so the DenyRead-only path does not
reload the capability file.
internal/sandbox/windows_command_runner_windows.go (1)

72-76: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Centralize the sandbox-level condition gating this mitigation.

The same config.SandboxLevel == WindowsSandboxLevelRestrictedToken check independently gates the ACL DenyWrite entries in BuildWindowsACLPlan (windows_acl.go) and the SID broadening here. These two must always agree, or you either widen reads without the write mitigation or try to apply DenyWrite ACEs from a tier lacking WRITE_DAC. Consider a single source of truth:

+// broadensReadSIDs reports whether this sandbox level both broadens the
+// restricted token's read SIDs (Users/Authenticated Users) and has the
+// Administrator rights needed to apply the corresponding DenyWrite ACL
+// mitigation to shared system paths.
+func (level WindowsSandboxLevel) broadensReadSIDs() bool {
+	return level == WindowsSandboxLevelRestrictedToken
+}
-	broadenReadSIDs := config.SandboxLevel == WindowsSandboxLevelRestrictedToken
+	broadenReadSIDs := config.SandboxLevel.broadensReadSIDs()

and in windows_acl.go:

-	if config.SandboxLevel == WindowsSandboxLevelRestrictedToken {
+	if config.SandboxLevel.broadensReadSIDs() {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/windows_command_runner_windows.go` around lines 72 - 76,
Centralize the restricted-token condition that enables the DenyWrite mitigation
and broadened read SIDs. Define or reuse a shared predicate for this
sandbox-level capability, then update both BuildWindowsACLPlan and the
createWindowsRestrictedTokenForCapabilitySIDs call site to use it instead of
comparing config.SandboxLevel independently. Ensure both paths always enable or
disable the mitigation together.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/sandbox/runner_windows_integration_test.go`:
- Around line 229-244: Update the programDataMarker os.Stat check in the Windows
smoke test to handle unexpected errors like the sibling outsideMarker and
publicMarker checks: retain the missing-file success path, but add an else-if
using os.IsNotExist(err) and fail the test with a diagnostic for any other Stat
error.

---

Nitpick comments:
In `@internal/sandbox/windows_acl.go`:
- Around line 91-113: Extract the duplicated Windows environment default
resolution into a shared windowsSharedDenyPaths helper, then update
BuildWindowsACLPlan and the test helper windowsSharedDenyPathsForTest to use it.
Preserve the existing SystemDrive, SystemRoot, ProgramData, and PUBLIC fallback
values and resulting deny paths.
- Around line 125-128: Add a focused test for the ACL generation flow around
windowsPathEqualsAnyRoot using a WriteRoot that exactly matches a shared deny
path, such as C:\Windows\Temp. Assert the resulting ACL includes an Allow entry
for that path and no conflicting DenyWrite entry, exercising the continue branch
in the sharedDenyPaths loop.
- Around line 115-123: Update the shared-deny construction in the Windows ACL
planning flow to copy the already-deduplicated writeSIDs and append
caps.ReadOnly, rather than rebuilding SIDs from writeCapabilities. Reuse the
loaded capability value by passing it through to windowsReadDenyCapabilitySIDs
so the DenyRead-only path does not reload the capability file.

In `@internal/sandbox/windows_command_runner_windows.go`:
- Around line 72-76: Centralize the restricted-token condition that enables the
DenyWrite mitigation and broadened read SIDs. Define or reuse a shared predicate
for this sandbox-level capability, then update both BuildWindowsACLPlan and the
createWindowsRestrictedTokenForCapabilitySIDs call site to use it instead of
comparing config.SandboxLevel independently. Ensure both paths always enable or
disable the mitigation together.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b15d1528-6c30-403b-af7f-13a4109aff7a

📥 Commits

Reviewing files that changed from the base of the PR and between 050970d and 9eaba2b.

📒 Files selected for processing (6)
  • internal/sandbox/runner_windows_integration_test.go
  • internal/sandbox/windows_acl.go
  • internal/sandbox/windows_acl_apply_windows.go
  • internal/sandbox/windows_acl_test.go
  • internal/sandbox/windows_command_runner_windows.go
  • internal/sandbox/windows_token_windows.go

Comment thread internal/sandbox/runner_windows_integration_test.go
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.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Avoid inheritable denies on system roots
    internal/sandbox/windows_acl.go:139
    The new shared-path mitigation adds DenyWrite entries to C:\, C:\ProgramData, C:\Windows\Temp, and C:\Users\Public, and windowsExplicitAccessEntries makes every directory entry inheritable with SUB_CONTAINERS_AND_OBJECTS_INHERIT. Elevated zero sandbox setup then applies that plan with SetNamedSecurityInfo and leaves successful ACL changes in place. This is much broader than the intended four target directories: Microsoft documents that setting a DACL propagates inheritable ACEs to existing child objects, so the C:\ deny can recursively stamp synthetic capability-SID deny ACEs across existing descendants of the system drive. That can make setup slow or brittle on protected descendants, pollute unrelated machine ACLs permanently, and can also interfere with ordinary allowed workspace writes when a repo lives under the system drive and descendants inherit both the broad deny and the workspace allow. Please avoid applying inheritable deny ACEs to broad system roots; use non-propagating entries or a targeted mechanism that does not rewrite arbitrary existing descendants.

  • [P1] Resolve shared deny paths from trusted Windows locations
    internal/sandbox/windows_acl.go:91
    The security boundary now depends on SystemDrive, SystemRoot, ProgramData, and PUBLIC from the setup process environment to decide which shared locations receive the compensating DenyWrite ACEs. If elevated setup is launched with any of those variables spoofed or unusual, the marker hash is computed from the same spoofed plan and validation later passes, while the restricted-token runner still broadens the token with WinBuiltinUsersSid and WinAuthenticatedUserSid. The real C:\Users\Public, C:\ProgramData, or C:\Windows\Temp can therefore remain uncovered, letting the widened token write through the existing Users/Authenticated Users grants outside the configured write roots. Please resolve these critical paths from trusted Windows APIs or canonical system locations, and make the tests assert those canonical targets rather than mirroring os.Getenv.

…estricted-token-sids

# Conflicts:
#	internal/sandbox/windows_command_runner_windows.go
#	internal/sandbox/windows_token_windows.go
…itance 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.
@euxaristia

Copy link
Copy Markdown
Contributor Author

Merged main in first (this branch was 14 commits behind), then pushed 3597b62 for jatmn's second round of P1s:

  • resolveWindowsSharedDenyPaths() now resolves the system drive/root, ProgramData, and Public paths via windows.GetSystemWindowsDirectory() and windows.KnownFolderPath(FOLDERID_ProgramData/FOLDERID_Public) instead of trusting SystemDrive/SystemRoot/ProgramData/PUBLIC env vars, which an attacker able to influence the elevated setup process's environment could spoof.
  • The four shared DenyWrite entries (system drive, ProgramData, Windows\Temp, Users\Public) now carry a NoInherit flag so SetNamedSecurityInfo doesn't recursively stamp them onto the entire existing subtree of the system drive. A plain non-inherited deny directly on each path already blocks writes there, including new children, without touching any descendant's own ACL.

The merge conflict was two different PRs adding a bool parameter to the same function (this PR's broadenReadSIDs, #658's writeRestricted on main) - kept both as separate parameters.

One trade-off worth flagging: stripping inheritance entirely (rather than NO_PROPAGATE_INHERIT_ACE) means a pre-existing nested subfolder under one of these four paths that already has its own Users/Authenticated-Users write grant from Windows' defaults is no longer separately covered by this mitigation. The four explicit paths themselves remain fully protected either way.

Ran the full internal/sandbox suite on a native Windows host, all green.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Link an approved parent issue before accepting this external contribution
    CONTRIBUTING.md:30
    The author is a CONTRIBUTOR, not a maintainer/collaborator, and the PR body has no linked issue or approved case. The contribution policy requires each community PR to be tied to an issue carrying issue-approved; without that approval, it says the PR must be closed without review. Please link the approved parent issue (or obtain the approval) before this proceeds.

  • [P1] Do not add the broad restricting SIDs to write-restricted commands
    internal/sandbox/windows_command_runner_windows.go:75
    writeRestricted is true for the normal profile with no DenyRead, and that token mode already performs reads using the normal token identity. Adding BUILTIN\\Users and Authenticated Users to its restricted SID list therefore cannot fix Program Files/System32 reads, but it does make their write grants pass the second, restricted-SID write check. This makes the default elevated sandbox take the write-jail risk without the claimed benefit. Only broaden the SIDs on the fully restricted (DenyRead) path where they are needed for read access, or avoid the broadening altogether.

  • [P1] Preserve the write jail for existing shared-directory descendants
    internal/sandbox/windows_acl.go:129
    The compensating DenyWrite ACEs are deliberately non-inheriting, so they protect only the four directory objects themselves. An existing writable descendant of %ProgramData%, %PUBLIC%, %SystemRoot%\\Temp, or the system drive retains its own Users/Authenticated Users allow ACE and no synthetic deny. Once the elevated fully restricted token carries those SIDs, a command can modify that descendant outside every configured write root. The new smoke check writes only directly under %PUBLIC%, so it cannot exercise this bypass. Use an enforcement design that covers reachable existing writable descendants without recursively stamping unrelated system ACLs, and add a real-Windows regression probe for a nested writable child.

  • [P2] Avoid permanently appending per-workspace SIDs to machine-wide DACLs
    internal/sandbox/windows_acl.go:119
    Each elevated setup adds four permanent deny ACEs for every workspace/write-root capability SID, while successful setup discards its rollback and those capability SIDs are minted and retained per distinct root. Re-running setup for different projects therefore grows the DACLs on C:\\, ProgramData, Windows Temp, and Public indefinitely with obsolete SIDs. Eventually this bloats or exhausts those shared system DACLs and can make setup fail for later projects. Use stable shared denial identities or replace/remove the prior setup entries as part of the setup lifecycle.

… 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.
@euxaristia

Copy link
Copy Markdown
Contributor Author

Pushed 949397f for the two technical P1s and the P2.

  • Broad SIDs on write-restricted commands: broadenReadSIDs is now conditioned on the token being fully restricted, not just on the elevated tier. A WRITE_RESTRICTED token reads with its normal identity, so the finding is right that broadening it could not fix Program Files or System32 reads and only opened the write side. The default elevated profile's token no longer carries Users or Authenticated Users at all; only DenyRead profiles, whose fully restricted tokens need those groups to read system binaries, are broadened.
  • DACL growth: the shared system-path DenyWrite entries are now only planned for DenyRead profiles (the only tokens that carry the broadened SIDs) and name a single stable identity, the read-only capability SID, which every broadened token now carries in its restricted list. A deny ACE blocks on matching any token SID, so one shared identity is sufficient, and the machine-wide DACLs stay at a constant four entries total across any number of sandboxed projects instead of growing per workspace.
  • Existing writable descendants: partially addressed by the scoping above. For non-DenyRead profiles (the default) the exposure is gone outright, since the token never carries the broadened SIDs; the smoke's Public-directory probe now pins that. For DenyRead profiles the gap you describe is real and remains: a fully restricted token that carries Users can write to an existing Users-writable descendant of the four shared paths, and the non-inheriting denies do not reach it. I did not find an enforcement design that covers unknown existing descendants without either enumerating and stamping their ACLs at setup (a recursive walk of ProgramData/Temp/Public and effective-rights check per directory) or moving to a different mechanism such as a low-integrity token with labeled write roots, which is a larger design change with its own tradeoffs. This push scopes the exposure to DenyRead profiles rather than closing it there; if one of those two designs (or another) is preferred for the remaining case, I can take it in a follow-up.

The issue-approval finding is a process item and is not addressed by this push.

go build, go vet, and go test ./internal/sandbox pass locally. The real-Windows smoke (ZERO_SANDBOX_REAL_SMOKE=1) requires elevation this environment does not have; the plan-level unit tests cover the new scoping and identity assertions.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/sandbox/windows_token_windows.go (1)

113-114: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Enforce mutually exclusive token flags programmatically.

The documentation thoroughly explains why broadenReadSIDs must remain false when writeRestricted is set (otherwise it would widen the write jail without mitigation). Enforcing this invariant in code acts as a robust guardrail, preventing future refactoring in the caller from accidentally breaking this critical security property.

🛡️ Proposed invariant check
 func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []windowsLocalSID, broadenReadSIDs bool, writeRestricted bool) (windows.Token, error) {
+	if broadenReadSIDs && writeRestricted {
+		return 0, errors.New("broadenReadSIDs cannot be combined with writeRestricted")
+	}
 	logonSID, err := copyWindowsLogonSID(base)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/windows_token_windows.go` around lines 113 - 114, Update
createWindowsRestrictedTokenFromBase to reject or otherwise fail immediately
when broadenReadSIDs and writeRestricted are both true, enforcing that these
token flags are mutually exclusive before any token manipulation occurs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/sandbox/windows_acl.go`:
- Around line 130-156: Update dedupeWindowsACLEntries to include
WindowsACLEntry.NoInherit in its deduplication key, ensuring entries with
different inheritance behavior remain distinct. Leave windowsPathEqualsAnyRoot
unchanged.

---

Nitpick comments:
In `@internal/sandbox/windows_token_windows.go`:
- Around line 113-114: Update createWindowsRestrictedTokenFromBase to reject or
otherwise fail immediately when broadenReadSIDs and writeRestricted are both
true, enforcing that these token flags are mutually exclusive before any token
manipulation occurs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 35704100-e1c5-4c9a-982c-5ccf2d18d5ae

📥 Commits

Reviewing files that changed from the base of the PR and between afa2ffa and 949397f.

📒 Files selected for processing (8)
  • internal/sandbox/runner_windows_integration_test.go
  • internal/sandbox/windows_acl.go
  • internal/sandbox/windows_acl_apply_windows.go
  • internal/sandbox/windows_acl_paths_other.go
  • internal/sandbox/windows_acl_paths_windows.go
  • internal/sandbox/windows_acl_test.go
  • internal/sandbox/windows_command_runner_windows.go
  • internal/sandbox/windows_token_windows.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/sandbox/windows_command_runner_windows.go
  • internal/sandbox/runner_windows_integration_test.go

Comment thread internal/sandbox/windows_acl.go
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.
@euxaristia

Copy link
Copy Markdown
Contributor Author

Pushed a fix for CodeRabbit's dedupe finding: dedupeWindowsACLEntries now includes NoInherit in its key, so a direct-only deny and an inheritable deny on the same path and SID stay distinct instead of collapsing into one shape. TestDedupeWindowsACLEntriesKeepsInheritanceVariants pins it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants