fix(auth): make diagnostics bounded and portable
This commit is contained in:
@@ -13,31 +13,34 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||
)
|
||||
|
||||
const (
|
||||
protocolVersion = 1
|
||||
maximumProtocolBytes = 64 * 1024
|
||||
maximumSessionBytes = 16 * 1024
|
||||
maximumOIDCStateBytes = 8 * 1024
|
||||
defaultMaximumEntries = 256
|
||||
maximumEntries = 512
|
||||
protocolVersion = 1
|
||||
maximumProtocolBytes = 64 * 1024
|
||||
maximumSessionBytes = 16 * 1024
|
||||
maximumOIDCStateBytes = 8 * 1024
|
||||
maximumAuthConfigBytes = 1024 * 1024
|
||||
defaultMaximumEntries = 256
|
||||
maximumEntries = 512
|
||||
)
|
||||
|
||||
var (
|
||||
digestFilename = regexp.MustCompile(`^[a-f0-9]{64}\.json$`)
|
||||
claimFilename = regexp.MustCompile(`^[a-f0-9]{64}\.claim$`)
|
||||
oidcSlotFilename = regexp.MustCompile(`^slot-(?:[0-5][0-9]|6[0-3])\.json$`)
|
||||
errInvalid = errors.New("auth storage request invalid")
|
||||
digestFilename = regexp.MustCompile(`^[a-f0-9]{64}\.json$`)
|
||||
claimFilename = regexp.MustCompile(`^[a-f0-9]{64}\.claim$`)
|
||||
oidcSlotFilename = regexp.MustCompile(`^slot-(?:[0-5][0-9]|6[0-3])\.json$`)
|
||||
authConfigFilename = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,249}\.yaml$`)
|
||||
errInvalid = errors.New("auth storage request invalid")
|
||||
)
|
||||
|
||||
type request struct {
|
||||
Version int `json:"version"`
|
||||
Operation string `json:"operation"`
|
||||
Root string `json:"root"`
|
||||
Directory string `json:"directory"`
|
||||
Directory string `json:"directory,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
ContentBase64 string `json:"contentBase64,omitempty"`
|
||||
MaximumEntries int `json:"maximumEntries,omitempty"`
|
||||
@@ -56,6 +59,7 @@ type response struct {
|
||||
ContentBase64 string `json:"contentBase64,omitempty"`
|
||||
Entries *[]safeio.PrivateDirectoryEntry `json:"entries,omitempty"`
|
||||
More *bool `json:"more,omitempty"`
|
||||
Validated bool `json:"validated,omitempty"`
|
||||
}
|
||||
|
||||
// Run accepts exactly one strict JSON request on stdin and emits exactly one JSON response on
|
||||
@@ -102,7 +106,27 @@ func fail(stderr io.Writer) int {
|
||||
}
|
||||
|
||||
func execute(input request) (response, error) {
|
||||
if input.Version != protocolVersion || !validDirectory(input.Directory) || !validOperationShape(input) {
|
||||
if input.Version != protocolVersion || !validOperationShape(input) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
if input.Operation == "validate-root" {
|
||||
if _, err := preflightRoot(input.Root); err != nil {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
return response{Version: protocolVersion, OK: true, Validated: true}, nil
|
||||
}
|
||||
if input.Operation == "read-auth-config" {
|
||||
root, err := existingPrivateRoot(input.Root)
|
||||
if err != nil {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
contents, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(root, input.Filename), maximumAuthConfigBytes)
|
||||
if err != nil {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
return contentResponse(true, contents), nil
|
||||
}
|
||||
if !validDirectory(input.Directory) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
directory, err := storageDirectory(input.Root, input.Directory)
|
||||
@@ -201,6 +225,10 @@ func validOperationShape(input request) bool {
|
||||
noAfterName := input.AfterName == ""
|
||||
noContinuation := !input.Continuation
|
||||
switch input.Operation {
|
||||
case "validate-root":
|
||||
return input.Directory == "" && input.Filename == "" && noContents && noMaximumEntries && noAfterName && noContinuation
|
||||
case "read-auth-config":
|
||||
return input.Directory == "" && authConfigFilename.MatchString(input.Filename) && noContents && noMaximumEntries && noAfterName && noContinuation
|
||||
case "create", "replace":
|
||||
return noMaximumEntries && noAfterName && noContinuation && (digestFilename.MatchString(input.Filename) || (input.Operation == "create" && input.Directory == "oidc" && oidcSlotFilename.MatchString(input.Filename)))
|
||||
case "read":
|
||||
@@ -217,6 +245,21 @@ func validOperationShape(input request) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func preflightRoot(root string) (bool, error) {
|
||||
if !filepath.IsAbs(root) || filepath.Clean(root) != root || strings.IndexFunc(root, unicode.IsControl) >= 0 {
|
||||
return false, errInvalid
|
||||
}
|
||||
return safeio.PreflightPrivateDirectory(root)
|
||||
}
|
||||
|
||||
func existingPrivateRoot(root string) (string, error) {
|
||||
exists, err := preflightRoot(root)
|
||||
if err != nil || !exists || safeio.ValidatePrivateDirectory(root) != nil {
|
||||
return "", errInvalid
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func contentResponse(found bool, contents []byte) response {
|
||||
if !found {
|
||||
return response{Version: protocolVersion, OK: true}
|
||||
@@ -225,7 +268,7 @@ func contentResponse(found bool, contents []byte) response {
|
||||
}
|
||||
|
||||
func storageDirectory(root, directory string) (string, error) {
|
||||
if !filepath.IsAbs(root) || filepath.Clean(root) != root || strings.ContainsRune(root, '\x00') || safeio.EnsurePrivateDirectory(root) != nil {
|
||||
if _, err := preflightRoot(root); err != nil || safeio.EnsurePrivateDirectory(root) != nil {
|
||||
return "", errInvalid
|
||||
}
|
||||
path := filepath.Join(root, directory)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -91,6 +92,65 @@ func TestProtocolCreatesReadsReplacesListsAndRemovesPrivateRecord(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolPreflightsRootWithoutCreatingOrFollowingLinks(t *testing.T) {
|
||||
parent := privateTestRoot(t)
|
||||
missing := filepath.Join(parent, "auth")
|
||||
validated := runRequest(t, request{Version: 1, Operation: "validate-root", Root: missing})
|
||||
if !validated.Validated {
|
||||
t.Fatal("missing final root was not validated")
|
||||
}
|
||||
if _, err := os.Lstat(missing); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("validate-root mutated missing root: %v", err)
|
||||
}
|
||||
|
||||
realRoot := filepath.Join(parent, "real-auth")
|
||||
if err := safeio.EnsurePrivateDirectory(realRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linkedRoot := filepath.Join(parent, "linked-auth")
|
||||
testsupport.SymlinkOrSkip(t, realRoot, linkedRoot)
|
||||
runRejected(t, request{Version: 1, Operation: "validate-root", Root: linkedRoot})
|
||||
if entries, err := os.ReadDir(realRoot); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("linked target was mutated: entries=%v error=%v", entries, err)
|
||||
}
|
||||
|
||||
runRejected(t, request{Version: 1, Operation: "validate-root", Root: filepath.Join(parent, "missing", "auth")})
|
||||
runRejected(t, request{Version: 1, Operation: "validate-root", Root: missing, Directory: "sessions"})
|
||||
runRejected(t, request{Version: 1, Operation: "validate-root", Root: filepath.Join(parent, "auth\n")})
|
||||
}
|
||||
|
||||
func TestProtocolReadsOnlyBoundedPrivateAuthConfig(t *testing.T) {
|
||||
root := privateTestRoot(t)
|
||||
filename := "auth.yaml"
|
||||
path := filepath.Join(root, filename)
|
||||
contents := []byte("version: 1\nmode: local\n")
|
||||
if err := safeio.WriteCanonicalNewPrivateFile(path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
read := runRequest(t, request{Version: 1, Operation: "read-auth-config", Root: root, Filename: filename})
|
||||
if !read.Found || decodeContent(t, read) != string(contents) {
|
||||
t.Fatalf("read-auth-config = %#v", read)
|
||||
}
|
||||
|
||||
hardLink := filepath.Join(root, "auth-copy.yaml")
|
||||
if err := os.Link(path, hardLink); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runRejected(t, request{Version: 1, Operation: "read-auth-config", Root: root, Filename: filename})
|
||||
if err := os.Remove(hardLink); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := safeio.WriteCanonicalNewPrivateFile(path, bytes.Repeat([]byte("x"), maximumAuthConfigBytes+1), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runRejected(t, request{Version: 1, Operation: "read-auth-config", Root: root, Filename: filename})
|
||||
runRejected(t, request{Version: 1, Operation: "read-auth-config", Root: root, Filename: "../auth.yaml"})
|
||||
runRejected(t, request{Version: 1, Operation: "read-auth-config", Root: root, Directory: "sessions", Filename: filename})
|
||||
}
|
||||
|
||||
func TestProtocolPermitsBoundedReservationSlotsOnlyForOIDCRecords(t *testing.T) {
|
||||
root := filepath.Join(privateTestRoot(t), "auth")
|
||||
slot := "slot-00.json"
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user