fix(safeio): retain private file parent handles

This commit is contained in:
2026-08-18 08:51:59 +02:00
parent 0b77d1e850
commit e7a7f4f066
11 changed files with 359 additions and 212 deletions
+38 -28
View File
@@ -4,46 +4,28 @@ package safeio
import (
"os"
"path/filepath"
"strings"
"golang.org/x/sys/windows"
)
const windowsRetainedHandleShareMode uint32 = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE
// ReadCanonicalRegular opens each component with FILE_FLAG_OPEN_REPARSE_POINT and rejects a
// reparse point on the opened handle before opening the next component. Retained handles allow
// ordinary read/write sharing but deny delete sharing, which blocks rename or deletion after a
// component is opened and throughout the final read. Windows' Win32 API does not expose a
// portable descriptor-relative equivalent of POSIX openat, so a hostile local actor can still
// replace a not-yet-opened normal component between absolute-path opens. Installation directories
// therefore need trusted local filesystem/ACL ownership on Windows.
// ReadCanonicalRegular resolves every component under the already-opened parent with NT
// RootDirectory-relative opens. Each retained handle rejects reparse points and denies delete
// sharing, so a later ancestor replacement cannot redirect the final regular-file open.
func ReadCanonicalRegular(path string, maximum int64) ([]byte, error) {
if err := ValidateCanonicalPath(path); err != nil {
return nil, err
}
volume := filepath.VolumeName(path)
root := volume + string(filepath.Separator)
components := strings.Split(strings.TrimPrefix(path, root), string(filepath.Separator))
if volume == "" || len(components) == 0 || components[0] == "" {
parents, target, err := openCanonicalWindowsParent(path)
if err != nil || parents == nil || len(parents.handles) == 0 {
if parents != nil {
parents.Close()
}
return nil, ErrUnsafeFile
}
current := root
parents := make([]windows.Handle, 0, len(components)-1)
defer func() { closeWindowsHandles(parents) }()
for _, component := range components[:len(components)-1] {
current = filepath.Join(current, component)
handle, err := openWindowsComponent(current, true)
if err != nil {
return nil, ErrUnsafeFile
}
parents = append(parents, handle)
}
current = filepath.Join(current, components[len(components)-1])
handle, err := openWindowsComponent(current, false)
defer parents.Close()
handle, err := openWindowsRelativeComponent(parents.handles[len(parents.handles)-1], target, false, windows.GENERIC_READ)
if err != nil {
return nil, ErrUnsafeFile
}
@@ -93,6 +75,34 @@ func openWindowsComponentWithAccess(path string, directory bool, access uint32)
return handle, nil
}
// openWindowsRelativeComponent is the generic NT equivalent of openat for one canonical leaf.
// Its root handle has already pinned all lexical ancestors; FILE_OPEN_REPARSE_POINT makes a
// reparse point observable so it can be rejected rather than followed.
func openWindowsRelativeComponent(parent windows.Handle, name string, directory bool, access uint32) (windows.Handle, error) {
options := uint32(windows.FILE_SYNCHRONOUS_IO_NONALERT | windows.FILE_OPEN_REPARSE_POINT)
if directory {
options |= windows.FILE_DIRECTORY_FILE
} else {
options |= windows.FILE_NON_DIRECTORY_FILE
}
handle, err := openWindowsRelativeObject(parent, name, access, windows.FILE_OPEN, options, nil)
if err != nil {
return 0, err
}
var information windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &information); err != nil {
_ = windows.CloseHandle(handle)
return 0, err
}
if information.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 ||
(directory && information.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0) ||
(!directory && (information.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0 || information.NumberOfLinks != 1)) {
_ = windows.CloseHandle(handle)
return 0, ErrUnsafeFile
}
return handle, nil
}
func closeWindowsHandles(handles []windows.Handle) {
for _, handle := range handles {
windows.CloseHandle(handle)