383 lines
14 KiB
Go
383 lines
14 KiB
Go
//go:build windows
|
|
|
|
package safeio
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"unsafe"
|
|
|
|
"golang.org/x/sys/windows"
|
|
)
|
|
|
|
const (
|
|
// Retained input handles deny delete sharing while permitting ordinary reads
|
|
// and writes by trusted callers.
|
|
windowsRetainedHandleShareMode uint32 = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE
|
|
// A discoverable stage is protected by mandatory zero-share semantics for its
|
|
// entire lifetime. This denies reads, writes, rename, delete, and hard-link
|
|
// acquisition by another handle until our final handle-relative rename.
|
|
windowsOutputHandleShareMode uint32 = 0
|
|
)
|
|
|
|
// 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.
|
|
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] == "" {
|
|
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)
|
|
if err != nil {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
file := os.NewFile(uintptr(handle), "thothctl-safeio")
|
|
if file == nil {
|
|
windows.CloseHandle(handle)
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
defer file.Close()
|
|
var before, after windows.ByHandleFileInformation
|
|
if windows.GetFileInformationByHandle(handle, &before) != nil || before.NumberOfLinks > 1 {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
contents, err := readBoundedRegularFile(file, maximum)
|
|
if err != nil {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
if windows.GetFileInformationByHandle(handle, &after) != nil || after.NumberOfLinks > 1 || !sameWindowsFile(before, after) {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
// Re-opened pathname identity is checked as well as the retained handle. This
|
|
// catches replacement by an ordinary local actor where share/delete policy permits it.
|
|
check, err := openWindowsComponent(current, false)
|
|
if err != nil {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
defer windows.CloseHandle(check)
|
|
var named windows.ByHandleFileInformation
|
|
if windows.GetFileInformationByHandle(check, &named) != nil || named.NumberOfLinks > 1 || !sameWindowsFile(before, named) {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
return contents, nil
|
|
}
|
|
|
|
func openWindowsComponent(path string, directory bool) (windows.Handle, error) {
|
|
flags := uint32(windows.FILE_FLAG_OPEN_REPARSE_POINT)
|
|
if directory {
|
|
flags |= windows.FILE_FLAG_BACKUP_SEMANTICS
|
|
} else {
|
|
flags |= windows.FILE_ATTRIBUTE_NORMAL
|
|
}
|
|
handle, err := windows.CreateFile(
|
|
windows.StringToUTF16Ptr(path),
|
|
windows.GENERIC_READ,
|
|
windowsRetainedHandleShareMode,
|
|
nil,
|
|
windows.OPEN_EXISTING,
|
|
flags,
|
|
0,
|
|
)
|
|
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.NumberOfLinks > 1 || information.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 ||
|
|
(directory && information.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0) ||
|
|
(!directory && information.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0) {
|
|
windows.CloseHandle(handle)
|
|
return 0, ErrUnsafeFile
|
|
}
|
|
return handle, nil
|
|
}
|
|
|
|
func closeWindowsHandles(handles []windows.Handle) {
|
|
for _, handle := range handles {
|
|
windows.CloseHandle(handle)
|
|
}
|
|
}
|
|
|
|
var windowsDeleteHandle = deleteWindowsHandle
|
|
var windowsCloseHandle = windows.CloseHandle
|
|
var windowsCloseStage = func(file *os.File) error { return file.Close() }
|
|
|
|
func cleanupWindowsStage(handle windows.Handle, closeStage func() error) error {
|
|
disposeErr := windowsDeleteHandle(handle)
|
|
closeErr := closeStage()
|
|
if disposeErr == nil && closeErr == nil {
|
|
// A successful disposition plus close proves the named stage is gone.
|
|
return ErrUnsafeFile
|
|
}
|
|
// Either a failed disposition or an uncertain close leaves the named
|
|
// candidate's reachability unresolved. Never classify this as retry-safe.
|
|
return ErrIndeterminateFile
|
|
}
|
|
|
|
func writeCanonicalExclusive(path string, contents []byte, mode fs.FileMode) error {
|
|
if err := ValidateCanonicalPath(path); err != nil || len(contents) > 16<<20 || mode.Perm() != 0o600 {
|
|
return ErrUnsafeFile
|
|
}
|
|
parent, retainedParents, err := openWindowsParents(path)
|
|
if err != nil {
|
|
return ErrUnsafeFile
|
|
}
|
|
defer closeWindowsHandles(retainedParents)
|
|
if len(retainedParents) == 0 {
|
|
return ErrUnsafeFile
|
|
}
|
|
parentHandle := retainedParents[len(retainedParents)-1]
|
|
securityDescriptor, securityAttributes, err := ownerOnlySecurityAttributes()
|
|
if err != nil {
|
|
return ErrUnsafeFile
|
|
}
|
|
_ = securityDescriptor
|
|
stageName, err := privateWindowsStageName()
|
|
if err != nil {
|
|
return ErrUnsafeFile
|
|
}
|
|
stagePath := filepath.Join(parent, stageName)
|
|
stageHandle, err := windows.CreateFile(windows.StringToUTF16Ptr(stagePath), windows.GENERIC_WRITE|windows.DELETE, windowsOutputHandleShareMode, securityAttributes, windows.CREATE_NEW, windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0)
|
|
if err != nil {
|
|
return ErrUnsafeFile
|
|
}
|
|
stageFile := os.NewFile(uintptr(stageHandle), "thothctl-safeio-stage")
|
|
if stageFile == nil {
|
|
disposeErr := windowsDeleteHandle(stageHandle)
|
|
closeErr := windowsCloseHandle(stageHandle)
|
|
if disposeErr != nil || closeErr != nil {
|
|
return ErrIndeterminateFile
|
|
}
|
|
return ErrUnsafeFile
|
|
}
|
|
closed := false
|
|
closeStage := func() error {
|
|
if closed {
|
|
return nil
|
|
}
|
|
closed = true
|
|
return windowsCloseStage(stageFile)
|
|
}
|
|
defer func() { _ = closeStage() }()
|
|
var staged windows.ByHandleFileInformation
|
|
if err := windows.GetFileInformationByHandle(stageHandle, &staged); err != nil || staged.NumberOfLinks != 1 || staged.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 || staged.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0 {
|
|
return cleanupWindowsStage(stageHandle, closeStage)
|
|
}
|
|
fail := func() error { return cleanupWindowsStage(stageHandle, closeStage) }
|
|
if n, err := stageFile.Write(contents); err != nil || n != len(contents) {
|
|
return fail()
|
|
}
|
|
if err := stageFile.Sync(); err != nil {
|
|
return fail()
|
|
}
|
|
var after windows.ByHandleFileInformation
|
|
if err := windows.GetFileInformationByHandle(stageHandle, &after); err != nil || after.NumberOfLinks != 1 || after.FileSizeHigh != uint32(uint64(len(contents))>>32) || after.FileSizeLow != uint32(len(contents)) {
|
|
return fail()
|
|
}
|
|
// Keep the exact stage handle open with zero sharing through this final check
|
|
// and atomic no-replace rename. No pathname reopen or cleanup is needed.
|
|
if err := renameWindowsHandle(stageHandle, parentHandle, filepath.Base(path)); err != nil {
|
|
return fail()
|
|
}
|
|
// Handle-relative rename is the final commit. Handle close is intentionally
|
|
// ignored after success; no fallible observation or pathname cleanup follows.
|
|
_ = closeStage()
|
|
return nil
|
|
}
|
|
|
|
func deleteWindowsHandle(handle windows.Handle) error {
|
|
var disposition byte = 1
|
|
return windows.SetFileInformationByHandle(handle, windows.FileDispositionInfo, &disposition, uint32(unsafe.Sizeof(disposition)))
|
|
}
|
|
|
|
type windowsFileRenameInfo struct {
|
|
ReplaceIfExists uint8
|
|
RootDirectory windows.Handle
|
|
FileNameLength uint32
|
|
FileName [1]uint16
|
|
}
|
|
|
|
func renameWindowsHandle(handle, parent windows.Handle, leaf string) error {
|
|
name, err := windows.UTF16FromString(leaf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name = name[:len(name)-1]
|
|
base := unsafe.Offsetof(windowsFileRenameInfo{}.FileName)
|
|
buffer := make([]byte, int(base)+len(name)*2)
|
|
info := (*windowsFileRenameInfo)(unsafe.Pointer(&buffer[0]))
|
|
info.ReplaceIfExists = 0
|
|
info.RootDirectory = parent
|
|
info.FileNameLength = uint32(len(name) * 2)
|
|
nameBytes := unsafe.Slice((*uint16)(unsafe.Pointer(&buffer[base])), len(name))
|
|
copy(nameBytes, name)
|
|
return windows.SetFileInformationByHandle(handle, windows.FileRenameInfo, &buffer[0], uint32(len(buffer)))
|
|
}
|
|
func privateWindowsStageName() (string, error) {
|
|
var random [16]byte
|
|
if _, err := rand.Read(random[:]); err != nil {
|
|
return "", err
|
|
}
|
|
return ".thothctl-candidate-" + hex.EncodeToString(random[:]), nil
|
|
}
|
|
|
|
func openWindowsParents(path string) (string, []windows.Handle, error) {
|
|
volume := filepath.VolumeName(path)
|
|
root := volume + string(filepath.Separator)
|
|
components := strings.Split(strings.TrimPrefix(path, root), string(filepath.Separator))
|
|
if volume == "" || len(components) < 2 || components[0] == "" {
|
|
return "", nil, ErrUnsafeFile
|
|
}
|
|
current := root
|
|
parents := make([]windows.Handle, 0, len(components)-1)
|
|
for _, component := range components[:len(components)-1] {
|
|
current = filepath.Join(current, component)
|
|
h, err := openWindowsComponent(current, true)
|
|
if err != nil {
|
|
closeWindowsHandles(parents)
|
|
return "", nil, ErrUnsafeFile
|
|
}
|
|
parents = append(parents, h)
|
|
}
|
|
return current, parents, nil
|
|
}
|
|
|
|
func validateCanonicalOutputPath(path string) error {
|
|
if err := ValidateCanonicalPath(path); err != nil {
|
|
return err
|
|
}
|
|
current, parents, err := openWindowsParents(path)
|
|
if err != nil {
|
|
return ErrUnsafeFile
|
|
}
|
|
defer closeWindowsHandles(parents)
|
|
if _, err := os.Lstat(filepath.Join(current, filepath.Base(path))); err == nil || !os.IsNotExist(err) {
|
|
return ErrUnsafeFile
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validatePlatformPathSyntax(path string) error {
|
|
// Win32 device namespaces and alternate data streams do not represent an
|
|
// independent regular file with owner-only output permissions.
|
|
lower := strings.ToLower(path)
|
|
if strings.HasPrefix(lower, `\\?\`) || strings.HasPrefix(lower, `\\.\`) ||
|
|
strings.HasPrefix(lower, `\device\`) || strings.HasPrefix(lower, `\??\`) || strings.HasPrefix(path, `\\`) {
|
|
return ErrUnsafeFile
|
|
}
|
|
volume := filepath.VolumeName(path)
|
|
for _, component := range strings.Split(strings.TrimPrefix(path, volume+string(filepath.Separator)), string(filepath.Separator)) {
|
|
if strings.Contains(component, ":") || strings.HasSuffix(component, " ") || strings.HasSuffix(component, ".") || isWindowsDeviceComponent(component) {
|
|
return ErrUnsafeFile
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Win32 aliases these names to devices, even when an extension is appended.
|
|
// Rejecting them lexically is required because CreateFile does not promise an
|
|
// independent regular leaf for a path containing one of these components.
|
|
func isWindowsDeviceComponent(component string) bool {
|
|
base := strings.ToUpper(component)
|
|
if i := strings.IndexByte(base, '.'); i >= 0 {
|
|
base = base[:i]
|
|
}
|
|
switch base {
|
|
case "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$":
|
|
return true
|
|
}
|
|
if len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) {
|
|
if base[3] >= '1' && base[3] <= '9' {
|
|
return true
|
|
}
|
|
}
|
|
// Unicode superscript 1, 2 and 3 are accepted as COM/LPT suffixes by
|
|
// Win32's device-name compatibility rules.
|
|
if len([]rune(base)) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) {
|
|
suffix := []rune(base)[3]
|
|
return suffix == '¹' || suffix == '²' || suffix == '³'
|
|
}
|
|
return false
|
|
}
|
|
|
|
func sameWindowsFile(a, b windows.ByHandleFileInformation) bool {
|
|
return a.VolumeSerialNumber == b.VolumeSerialNumber && a.FileIndexHigh == b.FileIndexHigh && a.FileIndexLow == b.FileIndexLow
|
|
}
|
|
|
|
// ownerOnlySecurityAttributes builds a non-inheriting DACL granting the current
|
|
// token's user read/write access and no access to inherited or other trustees.
|
|
// The descriptor is retained by the returned Go value for the duration of CreateFile.
|
|
func ownerOnlySecurityAttributes() (*windows.SECURITY_DESCRIPTOR, *windows.SecurityAttributes, error) {
|
|
user, err := windows.GetCurrentProcessToken().GetTokenUser()
|
|
if err != nil || user == nil || user.User.Sid == nil {
|
|
return nil, nil, ErrUnsafeFile
|
|
}
|
|
var pinner runtime.Pinner
|
|
pinner.Pin(user.User.Sid)
|
|
defer pinner.Unpin()
|
|
trustee := windows.TRUSTEE{
|
|
TrusteeForm: windows.TRUSTEE_IS_SID,
|
|
TrusteeType: windows.TRUSTEE_IS_USER,
|
|
TrusteeValue: windows.TrusteeValueFromSID(user.User.Sid),
|
|
}
|
|
entries := []windows.EXPLICIT_ACCESS{{
|
|
AccessPermissions: windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.DELETE,
|
|
AccessMode: windows.SET_ACCESS,
|
|
Inheritance: windows.NO_INHERITANCE,
|
|
Trustee: trustee,
|
|
}}
|
|
descriptor, err := windows.BuildSecurityDescriptor(nil, nil, entries, nil, nil)
|
|
if err != nil || descriptor == nil {
|
|
return nil, nil, ErrUnsafeFile
|
|
}
|
|
acl, _, err := descriptor.DACL()
|
|
if err != nil || acl == nil {
|
|
return nil, nil, ErrUnsafeFile
|
|
}
|
|
if err := descriptor.SetControl(windows.SE_DACL_PROTECTED, windows.SE_DACL_PROTECTED); err != nil {
|
|
return nil, nil, ErrUnsafeFile
|
|
}
|
|
// BuildSecurityDescriptor returns a self-relative descriptor. Re-using its
|
|
// DACL as the creation descriptor is valid, and the explicit DACL has no
|
|
// inheritable ACEs; the protected flag is applied by the kernel on creation.
|
|
_ = acl
|
|
attrs := &windows.SecurityAttributes{
|
|
Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})),
|
|
SecurityDescriptor: descriptor,
|
|
}
|
|
return descriptor, attrs, nil
|
|
}
|