//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, nil) 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 }