-
Notifications
You must be signed in to change notification settings - Fork 265
Set Windows DACL on discovery directory under LOCALAPPDATA #5951
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| //go:build !windows | ||
|
|
||
| package discovery | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "os" | ||
| ) | ||
|
|
||
| // restrictDiscoveryDirPermissions tightens POSIX mode bits on the discovery | ||
| // directory. On non-Windows platforms this is the Chmod that previously lived | ||
| // inline in writeServerInfoTo. | ||
| func restrictDiscoveryDirPermissions(dir string) error { | ||
| if err := os.Chmod(dir, dirPermissions); err != nil { | ||
| return fmt.Errorf("failed to set discovery directory permissions: %w", err) | ||
| } | ||
| return nil | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| //go:build windows | ||
|
|
||
| package discovery | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| "golang.org/x/sys/windows" | ||
| ) | ||
|
|
||
| // restrictDiscoveryDirPermissions replaces the discovery directory DACL with | ||
| // an explicit ACL granting FILE-equivalent GenericAll only to the process | ||
| // user and SYSTEM, and marks the DACL protected so parent ACEs cannot | ||
| // re-inherit. os.Chmod is advisory on NTFS and does not strip inherited | ||
| // ACEs under %LOCALAPPDATA%, which is the gap that lets another interactive | ||
| // user rewrite server.json (for example to point at an attacker named pipe). | ||
| // | ||
| // Inheritance (OICI) is intentional: server.json and any future children | ||
| // pick up the same restriction instead of inheriting a looser parent ACL. | ||
| func restrictDiscoveryDirPermissions(dir string) error { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we also protect or validate the intermediate toolhive directory? The adversarial test grants Everyone inheritable Modify on the parent and then creates parent\toolhive\server, but this function restricts only the server leaf. That leaves toolhive with inherited Modify; Modify includes DELETE on that object and directory-creation rights in its parent, so another user can rename toolhive aside and recreate toolhive\server, bypassing the protected leaf. Please secure the relevant path chain, or enforce and document a restrictive-ancestor invariant, and extend the Windows test to cover ancestor replacement. |
||
| userSID, err := currentProcessUserSID() | ||
| if err != nil { | ||
| return fmt.Errorf("failed to resolve current user SID: %w", err) | ||
| } | ||
| systemSID, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to resolve SYSTEM SID: %w", err) | ||
| } | ||
|
|
||
| acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{ | ||
| { | ||
| AccessPermissions: windows.GENERIC_ALL, | ||
| AccessMode: windows.GRANT_ACCESS, | ||
| Inheritance: windows.OBJECT_INHERIT_ACE | windows.CONTAINER_INHERIT_ACE, | ||
| Trustee: windows.TRUSTEE{ | ||
| TrusteeForm: windows.TRUSTEE_IS_SID, | ||
| TrusteeValue: windows.TrusteeValueFromSID(userSID), | ||
| }, | ||
| }, | ||
| { | ||
| AccessPermissions: windows.GENERIC_ALL, | ||
| AccessMode: windows.GRANT_ACCESS, | ||
| Inheritance: windows.OBJECT_INHERIT_ACE | windows.CONTAINER_INHERIT_ACE, | ||
| Trustee: windows.TRUSTEE{ | ||
| TrusteeForm: windows.TRUSTEE_IS_SID, | ||
| TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP, | ||
| TrusteeValue: windows.TrusteeValueFromSID(systemSID), | ||
| }, | ||
| }, | ||
| }, nil) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to build discovery directory DACL: %w", err) | ||
| } | ||
|
|
||
| if err := windows.SetNamedSecurityInfo( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we validate the directory owner before considering the lockdown complete? This call replaces only the DACL and passes a nil owner. If another user pre-created the directory and granted the process enough rights for this operation to succeed, that user remains the owner; Windows owners retain the ability to change the DACL and can make it permissive again. Please either fail closed when the owner is not the process user or securely establish the expected owner, and add coverage for a pre-existing directory with hostile ownership. |
||
| dir, | ||
| windows.SE_FILE_OBJECT, | ||
| windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, | ||
| nil, | ||
| nil, | ||
| acl, | ||
| nil, | ||
| ); err != nil { | ||
| return fmt.Errorf("failed to set discovery directory DACL: %w", err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func currentProcessUserSID() (*windows.SID, error) { | ||
| token := windows.GetCurrentProcessToken() | ||
| tokenUser, err := token.GetTokenUser() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return tokenUser.User.Sid, nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| //go:build windows | ||
|
|
||
| package discovery | ||
|
|
||
| import ( | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "strings" | ||
| "testing" | ||
| "time" | ||
| "unsafe" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| "golang.org/x/sys/windows" | ||
| ) | ||
|
|
||
| // TestWriteServerInfo_WindowsDACL_NoOtherInteractiveUsers asserts the | ||
| // acceptance criterion from #5217: after writeServerInfoTo, the discovery | ||
| // directory DACL grants access only to the current user and SYSTEM, and does | ||
| // not retain Everyone / Authenticated Users / other interactive-user ACEs | ||
| // that MkdirAll would otherwise inherit (and that os.Chmod cannot strip). | ||
| func TestWriteServerInfo_WindowsDACL_NoOtherInteractiveUsers(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| parent := t.TempDir() | ||
| dir := filepath.Join(parent, "toolhive", "server") | ||
|
|
||
| // Seed a deliberately loose ACL on the parent so newly created children | ||
| // inherit Everyone. This models a shared / misconfigured LOCALAPPDATA | ||
| // tree better than relying on whatever TempDir happens to carry. | ||
| grantEveryone(t, parent) | ||
|
|
||
| info := &ServerInfo{ | ||
| URL: "npipe://thv-api", | ||
| PID: 1, | ||
| Nonce: "dacl-nonce", | ||
| StartedAt: time.Now().UTC(), | ||
| } | ||
| require.NoError(t, writeServerInfoTo(dir, info)) | ||
|
|
||
| assertDiscoveryDACLRestricted(t, dir) | ||
| } | ||
|
|
||
| // TestRestrictDiscoveryDirPermissions_ReplacesExistingLooseACL covers the | ||
| // sibling failure mode: the discovery directory already exists with a loose | ||
| // DACL (Everyone + inherited ACEs). restrictDiscoveryDirPermissions must | ||
| // replace that ACL rather than merge, and block further inheritance. | ||
| func TestRestrictDiscoveryDirPermissions_ReplacesExistingLooseACL(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| dir := t.TempDir() | ||
| grantEveryone(t, dir) | ||
|
|
||
| before := discoveryDirSDDL(t, dir) | ||
| require.Contains(t, strings.ToUpper(before), "WD", "precondition: Everyone (WD) must be present before restrict") | ||
|
|
||
| require.NoError(t, restrictDiscoveryDirPermissions(dir)) | ||
| assertDiscoveryDACLRestricted(t, dir) | ||
| } | ||
|
|
||
| // TestRestrictDiscoveryDirPermissions_NewDirectory covers the create path | ||
| // (no pre-existing ACL to replace) so MkdirAll + restrict still lands a | ||
| // protected owner/SYSTEM-only DACL. | ||
| func TestRestrictDiscoveryDirPermissions_NewDirectory(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| parent := t.TempDir() | ||
| grantEveryone(t, parent) | ||
| dir := filepath.Join(parent, "fresh-server") | ||
| require.NoError(t, os.MkdirAll(dir, dirPermissions)) | ||
|
|
||
| require.NoError(t, restrictDiscoveryDirPermissions(dir)) | ||
| assertDiscoveryDACLRestricted(t, dir) | ||
| } | ||
|
|
||
| func grantEveryone(t *testing.T, path string) { | ||
| t.Helper() | ||
| // icacls grants are the product-path way to introduce a loose ACE; | ||
| // quoting keeps PowerShell from expanding (OI)/(CI). | ||
| cmd := exec.Command("icacls", path, "/grant", "*S-1-1-0:(OI)(CI)M") | ||
| out, err := cmd.CombinedOutput() | ||
| require.NoError(t, err, "icacls grant Everyone failed: %s", out) | ||
| } | ||
|
|
||
| func assertDiscoveryDACLRestricted(t *testing.T, dir string) { | ||
| t.Helper() | ||
|
|
||
| userSID, err := currentProcessUserSID() | ||
| require.NoError(t, err) | ||
| systemSID, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) | ||
| require.NoError(t, err) | ||
| everyoneSID, err := windows.CreateWellKnownSid(windows.WinWorldSid) | ||
| require.NoError(t, err) | ||
| authUsersSID, err := windows.CreateWellKnownSid(windows.WinAuthenticatedUserSid) | ||
| require.NoError(t, err) | ||
|
|
||
| sd, err := windows.GetNamedSecurityInfo( | ||
| dir, | ||
| windows.SE_FILE_OBJECT, | ||
| windows.DACL_SECURITY_INFORMATION, | ||
| ) | ||
| require.NoError(t, err) | ||
|
|
||
| control, _, err := sd.Control() | ||
| require.NoError(t, err) | ||
| assert.NotZero(t, control&windows.SE_DACL_PROTECTED, "DACL must be protected against inheritance") | ||
|
|
||
| dacl, _, err := sd.DACL() | ||
| require.NoError(t, err) | ||
| require.NotNil(t, dacl) | ||
|
|
||
| aces, err := allowACEsFromACL(dacl) | ||
| require.NoError(t, err) | ||
|
|
||
| var userSeen, systemSeen bool | ||
| for _, ace := range aces { | ||
| sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) | ||
| switch { | ||
| case userSID.Equals(sid): | ||
| userSeen = true | ||
| case systemSID.Equals(sid): | ||
| systemSeen = true | ||
| case everyoneSID.Equals(sid): | ||
| t.Fatalf("DACL still grants Everyone (%s)", sid) | ||
| case authUsersSID.Equals(sid): | ||
| t.Fatalf("DACL still grants Authenticated Users (%s)", sid) | ||
| default: | ||
| // Administrators / package SIDs / other interactive users must | ||
| // not remain after an explicit replace. Fail closed on anything | ||
| // that is not the process user or SYSTEM. | ||
| t.Fatalf("unexpected allow ACE for SID %s (want only current user + SYSTEM)", sid) | ||
| } | ||
| } | ||
| assert.True(t, userSeen, "DACL must grant the current process user") | ||
| assert.True(t, systemSeen, "DACL must grant SYSTEM") | ||
| } | ||
|
|
||
| func discoveryDirSDDL(t *testing.T, dir string) string { | ||
| t.Helper() | ||
| sd, err := windows.GetNamedSecurityInfo( | ||
| dir, | ||
| windows.SE_FILE_OBJECT, | ||
| windows.DACL_SECURITY_INFORMATION, | ||
| ) | ||
| require.NoError(t, err) | ||
| return sd.String() | ||
| } | ||
|
|
||
| func allowACEsFromACL(acl *windows.ACL) ([]*windows.ACCESS_ALLOWED_ACE, error) { | ||
| aces := make([]*windows.ACCESS_ALLOWED_ACE, 0, acl.AceCount) | ||
| for i := uint16(0); i < acl.AceCount; i++ { | ||
| var ace *windows.ACCESS_ALLOWED_ACE | ||
| if err := windows.GetAce(acl, uint32(i), &ace); err != nil { | ||
| return nil, err | ||
| } | ||
| if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { | ||
| continue | ||
| } | ||
| aces = append(aces, ace) | ||
| } | ||
| return aces, nil | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we move this restriction earlier, before the startup path acquires server.json.lock and calls discovery.Discover? In pkg/api/server.go, writeDiscoveryFile currently does MkdirAll → WithFileLock → Discover → WriteServerInfo, so an existing loose directory is trusted before this function runs. An attacker can write a server.json with an attacker-controlled pipe and nonce, return that nonce from /health, and make Discover report StateRunning; the caller then returns before WriteServerInfo, so the DACL is never repaired. I think the permission setup needs to happen before the lock and Discover, with a regression test covering that production ordering.