fix(auth): harden Windows storage bridge
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -81,8 +82,8 @@ func ReadCanonicalPrivateRegular(path string, maximum int64) ([]byte, error) {
|
||||
// PrivateDirectoryEntry is a bounded, untrusted directory listing item. Callers must still
|
||||
// validate each filename and record before using it.
|
||||
type PrivateDirectoryEntry struct {
|
||||
Name string
|
||||
ModifiedUnixMs int64
|
||||
Name string `json:"name"`
|
||||
ModifiedUnixMs int64 `json:"modifiedUnixMs"`
|
||||
}
|
||||
|
||||
// ListCanonicalPrivateDirectory lists regular, non-symlinked direct children from an owner-only
|
||||
@@ -94,8 +95,16 @@ func ListCanonicalPrivateDirectory(path string, maximumEntries int) ([]PrivateDi
|
||||
if err := ValidatePrivateDirectory(path); err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil || len(entries) > maximumEntries {
|
||||
directory, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
defer directory.Close()
|
||||
entries, err := directory.ReadDir(maximumEntries + 1)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
if len(entries) > maximumEntries {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
result := make([]PrivateDirectoryEntry, 0, len(entries))
|
||||
@@ -109,6 +118,7 @@ func ListCanonicalPrivateDirectory(path string, maximumEntries int) ([]PrivateDi
|
||||
}
|
||||
result = append(result, PrivateDirectoryEntry{Name: entry.Name(), ModifiedUnixMs: info.ModTime().UnixMilli()})
|
||||
}
|
||||
sort.Slice(result, func(left, right int) bool { return result[left].Name < result[right].Name })
|
||||
if err := ValidatePrivateDirectory(path); err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
@@ -116,6 +126,17 @@ func ListCanonicalPrivateDirectory(path string, maximumEntries int) ([]PrivateDi
|
||||
}
|
||||
|
||||
func WriteCanonicalNewFile(path string, contents []byte, mode os.FileMode) error {
|
||||
return writeCanonicalNewFile(path, contents, mode, false)
|
||||
}
|
||||
|
||||
// WriteCanonicalNewPrivateFile is the authentication-storage variant of exclusive file
|
||||
// creation. It requires the final parent directory to already have platform-specific owner-only
|
||||
// protection and preserves that check while the platform primitive opens the parent.
|
||||
func WriteCanonicalNewPrivateFile(path string, contents []byte, mode os.FileMode) error {
|
||||
return writeCanonicalNewFile(path, contents, mode, true)
|
||||
}
|
||||
|
||||
func writeCanonicalNewFile(path string, contents []byte, mode os.FileMode, requirePrivateParent bool) error {
|
||||
if err := ValidateCanonicalPath(path); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -123,6 +144,9 @@ func WriteCanonicalNewFile(path string, contents []byte, mode os.FileMode) error
|
||||
if err := requireCanonicalDirectory(parent); err != nil {
|
||||
return err
|
||||
}
|
||||
if requirePrivateParent && ValidatePrivateDirectory(parent) != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
if info, err := os.Lstat(path); err == nil {
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode()&os.ModeType != 0 {
|
||||
return ErrUnsafeFile
|
||||
@@ -131,7 +155,15 @@ func WriteCanonicalNewFile(path string, contents []byte, mode os.FileMode) error
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
file, err := createCanonicalNewPrivateFile(path, mode)
|
||||
var (
|
||||
file *os.File
|
||||
err error
|
||||
)
|
||||
if requirePrivateParent {
|
||||
file, err = createCanonicalNewPrivateParentFile(path, mode)
|
||||
} else {
|
||||
file, err = createCanonicalNewPrivateFile(path, mode)
|
||||
}
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package safeio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -93,3 +94,34 @@ func TestWriteCanonicalNewFileRejectsExistingTargets(t *testing.T) {
|
||||
t.Fatalf("WriteCanonicalNewFile(existing) error = %v, want ErrUnsafeFile", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCanonicalPrivateDirectoryBoundsAndSortsValidatedEntries(t *testing.T) {
|
||||
temporaryRoot, err := filepath.EvalSymlinks(os.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := os.MkdirTemp(temporaryRoot, "tht-safeio-list-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
||||
if err := ProtectPrivateDirectory(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := 255; index >= 0; index-- {
|
||||
path := filepath.Join(root, fmt.Sprintf("%064x.json", index))
|
||||
if err := WriteCanonicalNewFile(path, []byte("record"), 0o600); err != nil {
|
||||
t.Fatalf("WriteCanonicalNewFile(%d) error = %v", index, err)
|
||||
}
|
||||
}
|
||||
entries, err := ListCanonicalPrivateDirectory(root, 256)
|
||||
if err != nil || len(entries) != 256 || entries[0].Name != fmt.Sprintf("%064x.json", 0) || entries[255].Name != fmt.Sprintf("%064x.json", 255) {
|
||||
t.Fatalf("bounded ordered entries = %#v error = %v", entries, err)
|
||||
}
|
||||
if err := WriteCanonicalNewFile(filepath.Join(root, fmt.Sprintf("%064x.json", 256)), []byte("record"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ListCanonicalPrivateDirectory(root, 256); !errors.Is(err, ErrUnsafeFile) {
|
||||
t.Fatalf("257-entry listing error = %v, want ErrUnsafeFile", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ func createCanonicalNewPrivateFile(path string, mode os.FileMode) (*os.File, err
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func createCanonicalNewPrivateParentFile(path string, mode os.FileMode) (*os.File, error) {
|
||||
if err := ValidatePrivateDirectory(filepath.Dir(path)); err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
return createCanonicalNewPrivateFile(path, mode)
|
||||
}
|
||||
|
||||
// ValidatePrivateRegular requires a canonical, single-link private regular file.
|
||||
func ValidatePrivateRegular(path string) error {
|
||||
if err := ValidateCanonicalPath(path); err != nil {
|
||||
|
||||
@@ -96,8 +96,16 @@ func ProtectPrivateRegular(path string) error {
|
||||
// createCanonicalNewPrivateFile installs the owner-only protected DACL in the CreateFile call, so
|
||||
// another mutation can never observe a newly-created lock with an inherited/default DACL.
|
||||
func createCanonicalNewPrivateFile(path string, mode os.FileMode) (*os.File, error) {
|
||||
return createCanonicalNewFile(path, mode, false)
|
||||
}
|
||||
|
||||
func createCanonicalNewPrivateParentFile(path string, mode os.FileMode) (*os.File, error) {
|
||||
return createCanonicalNewFile(path, mode, true)
|
||||
}
|
||||
|
||||
func createCanonicalNewFile(path string, mode os.FileMode, requirePrivateParent bool) (*os.File, error) {
|
||||
parents, target, err := openCanonicalWindowsParent(path)
|
||||
if err != nil || len(parents.handles) == 0 || validateOwnerOnlyDACL(parents.handles[len(parents.handles)-1]) != nil {
|
||||
if err != nil || len(parents.handles) == 0 || (requirePrivateParent && validateOwnerOnlyDACL(parents.handles[len(parents.handles)-1]) != nil) {
|
||||
if parents != nil {
|
||||
parents.Close()
|
||||
}
|
||||
|
||||
@@ -73,6 +73,22 @@ func TestCreateCanonicalNewPrivateFileInstallsOwnerOnlyDACLAtCreation(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericNewFileAllowsInheritedOperatorParentButAuthNewFileRequiresPrivateParent(t *testing.T) {
|
||||
directory := filepath.Join(t.TempDir(), "operator-output")
|
||||
if err := os.Mkdir(directory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setPermissiveDACL(directory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteCanonicalNewFile(filepath.Join(directory, "candidates.yaml"), []byte("reviewed: []\n"), 0o600); err != nil {
|
||||
t.Fatalf("generic operator output error = %v", err)
|
||||
}
|
||||
if err := WriteCanonicalNewPrivateFile(filepath.Join(directory, "auth.json"), []byte("record"), 0o600); !errors.Is(err, ErrUnsafeFile) {
|
||||
t.Fatalf("auth record in inherited directory error = %v, want ErrUnsafeFile", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithWindowsSecurityDescriptorKeepsOwnedDescriptorValidDuringInspection(t *testing.T) {
|
||||
directory := filepath.Join(t.TempDir(), "auth")
|
||||
if err := os.Mkdir(directory, 0o700); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user