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
+56 -13
View File
@@ -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"