fix(auth): make diagnostics bounded and portable

This commit is contained in:
2026-08-17 12:19:19 +02:00
parent 7cfbee36fa
commit be1724890a
23 changed files with 1088 additions and 116 deletions
+18 -3
View File
@@ -23,11 +23,12 @@ const maximumPrivateDirectoryPageScanEntries = 16384
// EnsurePrivateDirectory creates only the final canonical directory with the platform's
// owner-only protection, or validates an existing directory has that protection.
func EnsurePrivateDirectory(path string) error {
if err := ValidateCanonicalPath(path); err != nil {
exists, err := PreflightPrivateDirectory(path)
if err != nil {
return err
}
if err := requireCanonicalDirectory(filepath.Dir(path)); err != nil {
return err
if exists {
return ValidatePrivateDirectory(path)
}
if err := createPrivateDirectory(path); err != nil && !errors.Is(err, os.ErrExist) {
return ErrUnsafeFile
@@ -35,6 +36,20 @@ func EnsurePrivateDirectory(path string) error {
return ValidatePrivateDirectory(path)
}
// PreflightPrivateDirectory validates every existing path component without following links or
// reparse points. A missing final component is safe to create later; missing intermediates are not.
// This function never creates, removes, chmods, or changes an ACL.
func PreflightPrivateDirectory(path string) (bool, error) {
if err := ValidateCanonicalPath(path); err != nil {
return false, ErrUnsafeFile
}
exists, err := preflightPrivateDirectory(path)
if err != nil {
return false, ErrUnsafeFile
}
return exists, nil
}
// ValidateCanonicalPath rejects relative or lexically non-canonical paths before they are opened.
func ValidateCanonicalPath(path string) error {
if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.Contains(path, string(filepath.Separator)+".."+string(filepath.Separator)) {
+42
View File
@@ -97,6 +97,48 @@ func TestWriteCanonicalNewFileRejectsExistingTargets(t *testing.T) {
}
}
func TestPreflightPrivateDirectoryAllowsOnlyAMissingFinalComponentWithoutMutation(t *testing.T) {
temporaryRoot, err := filepath.EvalSymlinks(os.TempDir())
if err != nil {
t.Fatal(err)
}
root, err := os.MkdirTemp(temporaryRoot, "tht-safeio-preflight-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(root) })
missing := filepath.Join(root, "auth")
exists, err := PreflightPrivateDirectory(missing)
if err != nil || exists {
t.Fatalf("missing final preflight exists=%v error=%v, want false/nil", exists, err)
}
if _, err := os.Lstat(missing); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("preflight created missing final: %v", err)
}
nested := filepath.Join(root, "missing-parent", "auth")
if _, err := PreflightPrivateDirectory(nested); !errors.Is(err, ErrUnsafeFile) {
t.Fatalf("missing intermediate preflight error=%v, want ErrUnsafeFile", err)
}
if _, err := os.Lstat(filepath.Join(root, "missing-parent")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("preflight created missing intermediate: %v", err)
}
realParent := filepath.Join(root, "real-parent")
if err := os.Mkdir(realParent, 0o700); err != nil {
t.Fatal(err)
}
linkedParent := filepath.Join(root, "linked-parent")
testsupport.SymlinkOrSkip(t, realParent, linkedParent)
if _, err := PreflightPrivateDirectory(filepath.Join(linkedParent, "auth")); !errors.Is(err, ErrUnsafeFile) {
t.Fatalf("symlink ancestor preflight error=%v, want ErrUnsafeFile", err)
}
if _, err := os.Lstat(filepath.Join(realParent, "auth")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("preflight mutated symlink target: %v", err)
}
}
func TestListCanonicalPrivateDirectoryBoundsAndSortsValidatedEntries(t *testing.T) {
temporaryRoot, err := filepath.EvalSymlinks(os.TempDir())
if err != nil {
@@ -0,0 +1,75 @@
//go:build !windows
package safeio
import (
"os"
"strings"
"golang.org/x/sys/unix"
)
type unixParentHandles struct {
descriptors []int
parent int
target string
}
func (parents *unixParentHandles) Close() {
closeUnixDescriptors(parents.descriptors)
}
func openCanonicalUnixParent(path string) (*unixParentHandles, error) {
if err := ValidateCanonicalPath(path); err != nil {
return nil, ErrUnsafeFile
}
components := strings.Split(strings.TrimPrefix(path, string(os.PathSeparator)), string(os.PathSeparator))
if len(components) == 0 || components[0] == "" {
return nil, ErrUnsafeFile
}
directory, err := unix.Open(string(os.PathSeparator), unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY, 0)
if err != nil {
return nil, ErrUnsafeFile
}
parents := &unixParentHandles{descriptors: []int{directory}, parent: directory, target: components[len(components)-1]}
for _, component := range components[:len(components)-1] {
next, err := unix.Openat(directory, component, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0)
if err != nil {
parents.Close()
return nil, ErrUnsafeFile
}
directory = next
parents.descriptors = append(parents.descriptors, directory)
parents.parent = directory
}
return parents, nil
}
func privateUnixDirectoryStat(stat *unix.Stat_t) bool {
return stat != nil && stat.Mode&unix.S_IFMT == unix.S_IFDIR && stat.Uid == uint32(os.Geteuid()) &&
stat.Mode&0o7777 == 0o700
}
func preflightPrivateDirectory(path string) (bool, error) {
parents, err := openCanonicalUnixParent(path)
if err != nil {
return false, ErrUnsafeFile
}
defer parents.Close()
descriptor, err := unix.Openat(parents.parent, parents.target, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0)
if err != nil {
if err == unix.ENOENT {
if unix.Faccessat(parents.parent, ".", unix.W_OK|unix.X_OK, unix.AT_EACCESS) != nil {
return false, ErrUnsafeFile
}
return false, nil
}
return false, ErrUnsafeFile
}
defer unix.Close(descriptor)
var stat unix.Stat_t
if unix.Fstat(descriptor, &stat) != nil || !privateUnixDirectoryStat(&stat) {
return false, ErrUnsafeFile
}
return true, nil
}
@@ -0,0 +1,39 @@
//go:build windows
package safeio
import (
"errors"
"path/filepath"
"golang.org/x/sys/windows"
)
func preflightPrivateDirectory(path string) (bool, error) {
parents, target, err := openCanonicalWindowsParent(path)
if err != nil {
return false, ErrUnsafeFile
}
defer parents.Close()
handle, err := openWindowsComponent(filepath.Join(parents.directory, target), true)
if err != nil {
if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
writableParent, accessErr := openWindowsComponentWithAccess(
parents.directory,
true,
windows.FILE_APPEND_DATA, // FILE_ADD_SUBDIRECTORY for a directory handle
)
if accessErr != nil {
return false, ErrUnsafeFile
}
_ = windows.CloseHandle(writableParent)
return false, nil
}
return false, ErrUnsafeFile
}
defer windows.CloseHandle(handle)
if err := validateOwnerOnlyDACL(handle); err != nil {
return false, ErrUnsafeFile
}
return true, nil
}
+23 -4
View File
@@ -6,16 +6,35 @@ import (
"errors"
"os"
"path/filepath"
"golang.org/x/sys/unix"
)
func createPrivateDirectory(path string) error {
if err := os.Mkdir(path, 0o700); err != nil {
if errors.Is(err, os.ErrExist) {
parents, err := openCanonicalUnixParent(path)
if err != nil {
return ErrUnsafeFile
}
defer parents.Close()
if err := unix.Mkdirat(parents.parent, parents.target, 0o700); err != nil {
if errors.Is(err, unix.EEXIST) {
return os.ErrExist
}
return err
return ErrUnsafeFile
}
return ProtectPrivateDirectory(path)
descriptor, err := unix.Openat(parents.parent, parents.target, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0)
if err != nil {
return ErrUnsafeFile
}
defer unix.Close(descriptor)
if unix.Fchmod(descriptor, 0o700) != nil {
return ErrUnsafeFile
}
var stat unix.Stat_t
if unix.Fstat(descriptor, &stat) != nil || !privateUnixDirectoryStat(&stat) {
return ErrUnsafeFile
}
return nil
}
// ProtectPrivateDirectory sets the private directory mode used for local authentication state.