fix(thothctl): harden workspace result and candidate publication

This commit is contained in:
2026-08-11 03:39:48 +02:00
parent ad80180381
commit fcc45520ad
8 changed files with 459 additions and 53 deletions
@@ -186,8 +186,11 @@ func writeCanonicalExclusive(path string, contents []byte, mode fs.FileMode) err
if err := stageFile.Sync(); err != nil {
return fail()
}
// The stage name is visible on Win32. If a same-user actor hard-links it,
// the bytes are already public; do not turn that observation into a failure
// whose cleanup could not remove the attacker's link.
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)) {
if err := windows.GetFileInformationByHandle(stageHandle, &afterWrite); err != nil || afterWrite.NumberOfLinks == 0 || afterWrite.FileSizeHigh != uint32(uint64(len(contents))>>32) || afterWrite.FileSizeLow != uint32(len(contents)) {
return fail()
}
if err := stageFile.Close(); err != nil {
@@ -203,16 +206,15 @@ func writeCanonicalExclusive(path string, contents []byte, mode fs.FileMode) err
published = true
publishedIdentity = staged
check, identityErr := windowsFileIdentity(finalPath)
if identityErr != nil || check.NumberOfLinks != 2 || !sameWindowsFile(staged, check) {
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) {
if identityErr != nil || finalIdentity.NumberOfLinks == 0 || !sameWindowsFile(staged, finalIdentity) {
return fail()
}
return nil
@@ -292,13 +294,39 @@ func validatePlatformPathSyntax(path string) error {
}
volume := filepath.VolumeName(path)
for _, component := range strings.Split(strings.TrimPrefix(path, volume+string(filepath.Separator)), string(filepath.Separator)) {
if strings.Contains(component, ":") {
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
}