fix: harden task1 workspace output and publication
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
package safeio
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -137,50 +139,113 @@ func writeCanonicalExclusive(path string, contents []byte, mode fs.FileMode) err
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
defer closeWindowsHandles(retainedParents)
|
||||
// Parent handles stay open with delete sharing denied until publication and
|
||||
// identity recheck complete; this is the Windows equivalent of retained dirfds.
|
||||
securityDescriptor, securityAttributes, err := ownerOnlySecurityAttributes()
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
h, err := windows.CreateFile(windows.StringToUTF16Ptr(filepath.Join(parent, filepath.Base(path))), windows.GENERIC_WRITE, windowsOutputHandleShareMode, securityAttributes, windows.CREATE_NEW, windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0)
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
f := os.NewFile(uintptr(h), "thothctl-safeio-output")
|
||||
if f == nil {
|
||||
windows.CloseHandle(h)
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
defer f.Close()
|
||||
// Go's Windows Chmod only toggles the read-only attribute; it cannot enforce
|
||||
// owner-only permissions. The restrictive DACL is installed at creation time
|
||||
// through SECURITY_ATTRIBUTES above.
|
||||
_ = securityDescriptor
|
||||
if mode.Perm() != 0o600 {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
if _, err := f.Write(contents); err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
var opened, named windows.ByHandleFileInformation
|
||||
if windows.GetFileInformationByHandle(h, &opened) != nil || opened.NumberOfLinks != 1 {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
check, err := openWindowsComponent(path, false)
|
||||
stageName, err := privateWindowsStageName()
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
defer windows.CloseHandle(check)
|
||||
if windows.GetFileInformationByHandle(check, &named) != nil || named.NumberOfLinks != 1 || !sameWindowsFile(opened, named) {
|
||||
stagePath := filepath.Join(parent, stageName)
|
||||
stageHandle, err := windows.CreateFile(windows.StringToUTF16Ptr(stagePath), windows.GENERIC_WRITE, 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 {
|
||||
windows.CloseHandle(stageHandle)
|
||||
_ = windows.DeleteFile(windows.StringToUTF16Ptr(stagePath))
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
stageCreated := true
|
||||
published := false
|
||||
var staged, publishedIdentity windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(stageHandle, &staged); err != nil || staged.NumberOfLinks != 1 {
|
||||
_ = stageFile.Close()
|
||||
_ = windows.DeleteFile(windows.StringToUTF16Ptr(stagePath))
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
cleanup := func() {
|
||||
if published {
|
||||
removeWindowsIfIdentity(filepath.Join(parent, filepath.Base(path)), publishedIdentity)
|
||||
}
|
||||
if stageCreated {
|
||||
removeWindowsIfIdentity(stagePath, staged)
|
||||
}
|
||||
}
|
||||
fail := func() error {
|
||||
_ = stageFile.Close()
|
||||
cleanup()
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
if n, err := stageFile.Write(contents); err != nil || n != len(contents) {
|
||||
return fail()
|
||||
}
|
||||
if err := stageFile.Sync(); err != nil {
|
||||
return fail()
|
||||
}
|
||||
var afterWrite windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(stageHandle, &afterWrite); err != nil || afterWrite.NumberOfLinks != 1 || afterWrite.FileSizeHigh != uint32(uint64(len(contents))>>32) || afterWrite.FileSizeLow != uint32(len(contents)) {
|
||||
return fail()
|
||||
}
|
||||
if err := stageFile.Close(); err != nil {
|
||||
cleanup()
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
// CreateHardLink is an atomic, same-volume, no-replace publication. The final
|
||||
// pathname can never refer to a partially written candidate.
|
||||
finalPath := filepath.Join(parent, filepath.Base(path))
|
||||
if err := windows.CreateHardLink(windows.StringToUTF16Ptr(finalPath), windows.StringToUTF16Ptr(stagePath), 0); err != nil {
|
||||
return fail()
|
||||
}
|
||||
published = true
|
||||
publishedIdentity = staged
|
||||
check, identityErr := windowsFileIdentity(finalPath)
|
||||
if identityErr != nil || check.NumberOfLinks != 2 || !sameWindowsFile(staged, check) {
|
||||
return fail()
|
||||
}
|
||||
publishedIdentity = check
|
||||
if err := windows.DeleteFile(windows.StringToUTF16Ptr(stagePath)); err != nil {
|
||||
return fail()
|
||||
}
|
||||
stageCreated = false
|
||||
finalIdentity, identityErr := windowsFileIdentity(finalPath)
|
||||
if identityErr != nil || finalIdentity.NumberOfLinks != 1 || !sameWindowsFile(staged, finalIdentity) {
|
||||
return fail()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 windowsFileIdentity(path string) (windows.ByHandleFileInformation, error) {
|
||||
h, err := windows.CreateFile(windows.StringToUTF16Ptr(path), windows.GENERIC_READ, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0)
|
||||
if err != nil {
|
||||
return windows.ByHandleFileInformation{}, err
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
var information windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(h, &information); err != nil || information.NumberOfLinks == 0 || information.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 || information.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0 {
|
||||
return windows.ByHandleFileInformation{}, ErrUnsafeFile
|
||||
}
|
||||
return information, nil
|
||||
}
|
||||
|
||||
func removeWindowsIfIdentity(path string, expected windows.ByHandleFileInformation) {
|
||||
got, err := windowsFileIdentity(path)
|
||||
if err == nil && sameWindowsFile(got, expected) {
|
||||
_ = windows.DeleteFile(windows.StringToUTF16Ptr(path))
|
||||
}
|
||||
}
|
||||
|
||||
func openWindowsParents(path string) (string, []windows.Handle, error) {
|
||||
volume := filepath.VolumeName(path)
|
||||
root := volume + string(filepath.Separator)
|
||||
@@ -217,6 +282,23 @@ func validateCanonicalOutputPath(path string) error {
|
||||
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, ":") {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameWindowsFile(a, b windows.ByHandleFileInformation) bool {
|
||||
return a.VolumeSerialNumber == b.VolumeSerialNumber && a.FileIndexHigh == b.FileIndexHigh && a.FileIndexLow == b.FileIndexLow
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user