Skip to content

Commit f551a44

Browse files
fix(sandbox): prove ownership before deleting a sandbox account
removeWindowsSandboxIdentity is called with a DERIVED name, so it could be pointed at a name that happens to belong to somebody else's local account. Deleting a user is not a recoverable mistake, and the only thing standing between the two cases was the name matching a pattern we generate ourselves. Raised by CodeRabbit against the test fixtures, but the production teardown path had the same hazard, so the guard belongs there rather than in the tests. Ownership is now read back from the comment provisioning stamps on the account, and an account that is not ours is left alone rather than deleted. The gated tests get the protection for free, since their pre-clean goes through the same helper. The test asserts against real accounts every Windows install carries, which are definitively not ours, and needs no privilege because it only has to establish that they are not classified as managed. Classifying everything as managed makes it fail. Also appends the trimmed offline-group SID rather than the raw one. Worth noting the reported consequence does not hold: newWindowsWFPUserCondition canonicalises before converting, so a padded value would have been trimmed before reaching StringToSid. The resolver returns SID.String(), which never carries whitespace, so this is defensive tidying rather than a fix.
1 parent aa861df commit f551a44

3 files changed

Lines changed: 86 additions & 2 deletions

File tree

‎internal/sandbox/windows_identity_windows.go‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ var (
117117
procNetLocalGroupAddMembers = netapi32.NewProc("NetLocalGroupAddMembers")
118118
procNetUserDel = netapi32.NewProc("NetUserDel")
119119
procNetUserSetInfo = netapi32.NewProc("NetUserSetInfo")
120+
procNetUserGetInfo = netapi32.NewProc("NetUserGetInfo")
121+
procNetApiBufferFree = netapi32.NewProc("NetApiBufferFree")
120122
)
121123

122124
// userInfo1 mirrors USER_INFO_1. Field order and widths must match the Win32
@@ -497,14 +499,61 @@ func provisionWindowsSandboxIdentity(workspaceKey string, role windowsSandboxRol
497499
// A missing account is success, so teardown converges the same way provisioning
498500
// does. Requires an elevated caller.
499501
func removeWindowsSandboxIdentity(username string) error {
502+
// Account names here are derived, not discovered, so this could be pointed at
503+
// a name that happens to belong to somebody else's local account. Deleting a
504+
// user is not a recoverable mistake, so ownership is proven before deleting
505+
// rather than inferred from the name matching a pattern we generate.
506+
managed, err := windowsSandboxUserIsManaged(username)
507+
if err != nil {
508+
return err
509+
}
510+
if !managed {
511+
return nil
512+
}
500513
name, err := windows.UTF16PtrFromString(username)
501514
if err != nil {
502515
return err
503516
}
504517
status, _, _ := procNetUserDel.Call(0, uintptr(unsafe.Pointer(name)))
518+
runtime.KeepAlive(name)
505519
return netAPIStatus("NetUserDel", status, nerrUserNotFound)
506520
}
507521

522+
// windowsSandboxUserIsManaged reports whether a local account is one Zero
523+
// created, by reading back the comment provisioning stamps on it.
524+
//
525+
// A missing account is not managed rather than an error, since the callers are
526+
// all teardown paths converging on "this account should not exist".
527+
func windowsSandboxUserIsManaged(username string) (bool, error) {
528+
name, err := windows.UTF16PtrFromString(username)
529+
if err != nil {
530+
return false, err
531+
}
532+
var buffer *byte
533+
status, _, _ := procNetUserGetInfo.Call(
534+
0, // local machine
535+
uintptr(unsafe.Pointer(name)),
536+
1, // level: USER_INFO_1
537+
uintptr(unsafe.Pointer(&buffer)),
538+
)
539+
runtime.KeepAlive(name)
540+
if status == nerrUserNotFound {
541+
return false, nil
542+
}
543+
if err := netAPIStatus("NetUserGetInfo", status); err != nil {
544+
return false, err
545+
}
546+
if buffer == nil {
547+
return false, nil
548+
}
549+
defer procNetApiBufferFree.Call(uintptr(unsafe.Pointer(buffer)))
550+
info := (*userInfo1)(unsafe.Pointer(buffer))
551+
if info.Comment == nil {
552+
return false, nil
553+
}
554+
return windows.UTF16PtrToString(info.Comment) == windowsSandboxUserComment, nil
555+
}
556+
508557
// errWindowsSandboxIdentityUnavailable reports that no sandbox principal has
509558
// been provisioned yet, so callers can fall back to the restricted-token
510559
// backend instead of failing the command.

‎internal/sandbox/windows_identity_windows_test.go‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,3 +425,38 @@ func TestWindowsSandboxUserNameSeparatesRoles(t *testing.T) {
425425
t.Fatalf("two workspaces produced the same offline account %q", other)
426426
}
427427
}
428+
429+
// removeWindowsSandboxIdentity is called with a DERIVED name, so it can be
430+
// pointed at a name that happens to belong to somebody else's local account.
431+
// Deleting a user is not recoverable, so ownership has to be proven rather than
432+
// inferred from the name matching a pattern we generate ourselves.
433+
//
434+
// Asserted against real accounts that exist on every Windows install and are
435+
// definitively not ours. Needs no privilege, since it only has to establish that
436+
// they are not classified as managed; NetUserDel is never reached for them.
437+
func TestWindowsSandboxUserIsManagedRejectsForeignAccounts(t *testing.T) {
438+
checked := 0
439+
for _, name := range []string{"Administrator", "Guest", "DefaultAccount"} {
440+
managed, err := windowsSandboxUserIsManaged(name)
441+
if err != nil {
442+
// Localized or disabled installs may not carry every one of these.
443+
continue
444+
}
445+
checked++
446+
if managed {
447+
t.Fatalf("%q classified as a Zero sandbox principal; removal would delete a real account", name)
448+
}
449+
}
450+
if checked == 0 {
451+
t.Skip("none of the well-known local accounts could be queried on this machine")
452+
}
453+
// A name nothing owns must also come back false rather than erroring, since
454+
// teardown calls this on accounts that may never have existed.
455+
managed, err := windowsSandboxUserIsManaged("zero-sbx-dnosuchacct")
456+
if err != nil {
457+
t.Fatalf("querying a missing account: %v", err)
458+
}
459+
if managed {
460+
t.Fatal("a missing account was classified as managed")
461+
}
462+
}

‎internal/sandbox/windows_network.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ func BuildWindowsNetworkInfraPlan(config WindowsSandboxCommandConfig) (WindowsNe
8181
if err != nil {
8282
return WindowsNetworkPlan{}, err
8383
}
84-
if strings.TrimSpace(groupSID) != "" {
85-
identitySIDs = append(identitySIDs, groupSID)
84+
if trimmed := strings.TrimSpace(groupSID); trimmed != "" {
85+
identitySIDs = append(identitySIDs, trimmed)
8686
}
8787
}
8888
return WindowsNetworkPlan{

0 commit comments

Comments
 (0)