Skip to content

Commit cb128ec

Browse files
committed
fix(daemon): validate status directory access
1 parent e067f09 commit cb128ec

6 files changed

Lines changed: 328 additions & 20 deletions

‎internal/daemon/status_dir_owner_unix.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ import (
88
"syscall"
99
)
1010

11-
func checkStatusDirOwner(info os.FileInfo) error {
11+
func checkStatusDirOwner(_ *os.Root, info os.FileInfo) error {
1212
stat, ok := info.Sys().(*syscall.Stat_t)
1313
if !ok {
14-
return nil
14+
return fmt.Errorf("status directory ownership metadata is unavailable")
1515
}
1616
if int(stat.Uid) != os.Geteuid() {
1717
return fmt.Errorf("status directory is owned by uid %d, not the current user", stat.Uid)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//go:build !windows
2+
3+
package daemon
4+
5+
import (
6+
"os"
7+
"strings"
8+
"syscall"
9+
"testing"
10+
"time"
11+
)
12+
13+
func secureStatusTestDirPlatform(t *testing.T, dir string) {
14+
t.Helper()
15+
if err := os.Chmod(dir, 0o700); err != nil {
16+
t.Fatal(err)
17+
}
18+
}
19+
20+
func TestCheckStatusDirOwnerRejectsMissingMetadata(t *testing.T) {
21+
err := checkStatusDirOwner(nil, statusDirOwnerTestInfo{})
22+
if err == nil || !strings.Contains(err.Error(), "metadata is unavailable") {
23+
t.Fatalf("checkStatusDirOwner error = %v, want unavailable metadata rejection", err)
24+
}
25+
}
26+
27+
func TestCheckStatusDirOwnerRejectsDifferentUser(t *testing.T) {
28+
info := statusDirOwnerTestInfo{sys: &syscall.Stat_t{Uid: uint32(os.Geteuid() + 1)}}
29+
err := checkStatusDirOwner(nil, info)
30+
if err == nil || !strings.Contains(err.Error(), "not the current user") {
31+
t.Fatalf("checkStatusDirOwner error = %v, want owner mismatch rejection", err)
32+
}
33+
}
34+
35+
type statusDirOwnerTestInfo struct {
36+
sys any
37+
}
38+
39+
func (statusDirOwnerTestInfo) Name() string { return "." }
40+
func (statusDirOwnerTestInfo) Size() int64 { return 0 }
41+
func (statusDirOwnerTestInfo) Mode() os.FileMode { return os.ModeDir | 0o700 }
42+
func (statusDirOwnerTestInfo) ModTime() time.Time { return time.Time{} }
43+
func (statusDirOwnerTestInfo) IsDir() bool { return true }
44+
func (info statusDirOwnerTestInfo) Sys() any { return info.sys }

‎internal/daemon/status_dir_owner_windows.go‎

Lines changed: 104 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,110 @@
22

33
package daemon
44

5-
import "os"
5+
import (
6+
"errors"
7+
"fmt"
8+
"os"
9+
"unsafe"
610

7-
// Windows has no portable uid to compare. os.Root still binds every operation
8-
// to one directory handle and rejects reparse-point traversal, while the normal
9-
// daemon directory lives below the current user's profile.
10-
func checkStatusDirOwner(os.FileInfo) error {
11+
"golang.org/x/sys/windows"
12+
)
13+
14+
const statusDirectoryWriteAccess = windows.ACCESS_MASK(
15+
windows.GENERIC_ALL |
16+
windows.GENERIC_WRITE |
17+
windows.DELETE |
18+
windows.WRITE_DAC |
19+
windows.WRITE_OWNER |
20+
windows.FILE_WRITE_DATA |
21+
windows.FILE_APPEND_DATA |
22+
windows.FILE_WRITE_ATTRIBUTES |
23+
windows.FILE_WRITE_EA |
24+
0x40, // FILE_DELETE_CHILD
25+
)
26+
27+
// checkStatusDirOwner validates ownership and write access through a handle
28+
// opened beneath root. Path-based ACL inspection would recreate the ancestor
29+
// swap race that Root is intended to close.
30+
func checkStatusDirOwner(root *os.Root, _ os.FileInfo) (returnErr error) {
31+
directory, err := root.Open(".")
32+
if err != nil {
33+
return fmt.Errorf("open status directory for access validation: %w", err)
34+
}
35+
defer func() {
36+
if err := directory.Close(); err != nil {
37+
returnErr = errors.Join(returnErr, fmt.Errorf("close status directory access handle: %w", err))
38+
}
39+
}()
40+
41+
raw, err := directory.SyscallConn()
42+
if err != nil {
43+
return fmt.Errorf("access status directory handle: %w", err)
44+
}
45+
var descriptor *windows.SECURITY_DESCRIPTOR
46+
var queryErr error
47+
if err := raw.Control(func(handle uintptr) {
48+
descriptor, queryErr = windows.GetSecurityInfo(
49+
windows.Handle(handle),
50+
windows.SE_FILE_OBJECT,
51+
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
52+
)
53+
}); err != nil {
54+
return fmt.Errorf("inspect status directory access: %w", err)
55+
}
56+
if queryErr != nil {
57+
return fmt.Errorf("inspect status directory owner and DACL: %w", queryErr)
58+
}
59+
if descriptor == nil {
60+
return fmt.Errorf("status directory security descriptor is unavailable")
61+
}
62+
63+
user, err := windows.GetCurrentProcessToken().GetTokenUser()
64+
if err != nil {
65+
return fmt.Errorf("resolve current Windows user: %w", err)
66+
}
67+
owner, _, err := descriptor.Owner()
68+
if err != nil {
69+
return fmt.Errorf("read status directory owner: %w", err)
70+
}
71+
if owner == nil || !owner.Equals(user.User.Sid) {
72+
return fmt.Errorf("status directory is not owned by the current Windows user")
73+
}
74+
75+
dacl, _, err := descriptor.DACL()
76+
if err != nil {
77+
return fmt.Errorf("read status directory DACL: %w", err)
78+
}
79+
if dacl == nil {
80+
return fmt.Errorf("status directory has an unrestricted Windows DACL")
81+
}
82+
for index := uint16(0); index < dacl.AceCount; index++ {
83+
var ace *windows.ACCESS_ALLOWED_ACE
84+
if err := windows.GetAce(dacl, uint32(index), &ace); err != nil {
85+
return fmt.Errorf("read status directory DACL entry %d: %w", index, err)
86+
}
87+
switch ace.Header.AceType {
88+
case windows.ACCESS_DENIED_ACE_TYPE:
89+
continue
90+
case windows.ACCESS_ALLOWED_ACE_TYPE:
91+
default:
92+
return fmt.Errorf("status directory DACL entry %d has unsupported type %d", index, ace.Header.AceType)
93+
}
94+
if ace.Mask&statusDirectoryWriteAccess == 0 {
95+
continue
96+
}
97+
trustee := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
98+
if !allowedStatusDirectoryTrustee(trustee, user.User.Sid) {
99+
return fmt.Errorf("status directory DACL grants write access to unexpected trustee %s", trustee.String())
100+
}
101+
}
11102
return nil
12103
}
104+
105+
func allowedStatusDirectoryTrustee(trustee, user *windows.SID) bool {
106+
return trustee != nil && (trustee.Equals(user) ||
107+
trustee.IsWellKnown(windows.WinLocalSystemSid) ||
108+
trustee.IsWellKnown(windows.WinBuiltinAdministratorsSid) ||
109+
trustee.IsWellKnown(windows.WinCreatorOwnerSid) ||
110+
trustee.IsWellKnown(windows.WinCreatorOwnerRightsSid))
111+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
//go:build windows
2+
3+
package daemon
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"runtime"
9+
"strings"
10+
"testing"
11+
12+
"golang.org/x/sys/windows"
13+
)
14+
15+
func secureStatusTestDirPlatform(t *testing.T, dir string) {
16+
t.Helper()
17+
user, err := windows.GetCurrentProcessToken().GetTokenUser()
18+
if err != nil {
19+
t.Fatal(err)
20+
}
21+
descriptor, err := windows.SecurityDescriptorFromString(
22+
fmt.Sprintf("O:%sD:P(A;OICI;GA;;;%s)(A;OICI;GA;;;SY)", user.User.Sid.String(), user.User.Sid.String()),
23+
)
24+
if err != nil {
25+
t.Fatal(err)
26+
}
27+
dacl, _, err := descriptor.DACL()
28+
if err != nil {
29+
t.Fatal(err)
30+
}
31+
if err := windows.SetNamedSecurityInfo(
32+
dir,
33+
windows.SE_FILE_OBJECT,
34+
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
35+
nil,
36+
nil,
37+
dacl,
38+
nil,
39+
); err != nil {
40+
t.Fatal(err)
41+
}
42+
}
43+
44+
func TestCheckStatusDirOwnerRejectsBroadDACL(t *testing.T) {
45+
dir := t.TempDir()
46+
secureStatusTestDirPlatform(t, dir)
47+
worldSID, err := windows.CreateWellKnownSid(windows.WinWorldSid)
48+
if err != nil {
49+
t.Fatal(err)
50+
}
51+
var pinner runtime.Pinner
52+
pinner.Pin(worldSID)
53+
defer pinner.Unpin()
54+
broadDACL, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{
55+
AccessPermissions: windows.GENERIC_ALL,
56+
AccessMode: windows.GRANT_ACCESS,
57+
Inheritance: windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT,
58+
Trustee: windows.TRUSTEE{
59+
TrusteeForm: windows.TRUSTEE_IS_SID,
60+
TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP,
61+
TrusteeValue: windows.TrusteeValueFromSID(worldSID),
62+
},
63+
}}, nil)
64+
if err != nil {
65+
t.Fatal(err)
66+
}
67+
if err := windows.SetNamedSecurityInfo(
68+
dir,
69+
windows.SE_FILE_OBJECT,
70+
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
71+
nil,
72+
nil,
73+
broadDACL,
74+
nil,
75+
); err != nil {
76+
t.Fatal(err)
77+
}
78+
79+
root, err := os.OpenRoot(dir)
80+
if err != nil {
81+
t.Fatal(err)
82+
}
83+
defer root.Close()
84+
info, err := root.Stat(".")
85+
if err != nil {
86+
t.Fatal(err)
87+
}
88+
err = checkStatusDirOwner(root, info)
89+
if err == nil || !strings.Contains(err.Error(), "unexpected trustee") {
90+
t.Fatalf("checkStatusDirOwner error = %v, want broad DACL rejection", err)
91+
}
92+
}

‎internal/daemon/status_file.go‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,23 @@ func writeStatusFileAtomically(
108108
if replace != nil {
109109
rename = func(src, dst string) error { return replace(root, src, dst) }
110110
}
111+
var committedWarning error
111112
if err := fsutil.RenameWithRetry(tempName, statusName, rename); err != nil {
112-
return fmt.Errorf("replace status file: %w", err)
113+
var committedReplacement *fsutil.CommittedReplacementCleanupError
114+
if !errors.As(err, &committedReplacement) {
115+
return fmt.Errorf("replace status file: %w", err)
116+
}
117+
committedWarning = fmt.Errorf("clean up replaced status file: %w", err)
113118
}
114119
committed = true
115120
if syncParent == nil {
116121
syncParent = syncStatusRoot
117122
}
118123
if err := syncParent(root); err != nil {
119-
return &statusFileCommittedError{cause: fmt.Errorf("sync status directory: %w", err)}
124+
committedWarning = errors.Join(committedWarning, fmt.Errorf("sync status directory: %w", err))
125+
}
126+
if committedWarning != nil {
127+
return &statusFileCommittedError{cause: committedWarning}
120128
}
121129
return nil
122130
}
@@ -132,7 +140,7 @@ func validateStatusRoot(root *os.Root) error {
132140
if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
133141
return fmt.Errorf("status directory permissions are %04o, want owner-only", info.Mode().Perm())
134142
}
135-
if err := checkStatusDirOwner(info); err != nil {
143+
if err := checkStatusDirOwner(root, info); err != nil {
136144
return err
137145
}
138146
return nil

0 commit comments

Comments
 (0)