fix(auth): hand off privileges before state creation; fail closed on store errors
Fresh boots opened the owner/token/session stores before EnsureDataRoot dropped to the host user, leaving a root-owned maestro/ state dir whose EACCES surfaced as a bogus ERR_INVALID_CREDENTIALS on /api/auth/setup. main() now runs the handoff first; EnsureDataRoot heals pre-existing root-owned state dirs while still root (healOwnerState). Storage failures from the owner store now answer 500 ERR_INTERNAL everywhere (status/setup/login/change-password/RequireSession/ GateWebSocket) instead of blaming credentials or re-advertising setup mode; SetupRequired returns the store error instead of swallowing it, and FileOwnerStore.Verify propagates read errors rather than masking them as unknown-user.
This commit is contained in:
@@ -202,6 +202,9 @@ func EnsureDataRoot() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := healOwnerState(root, identity); err != nil {
|
||||
return fmt.Errorf("failed to hand owner state dir to uid %d: %w", identity.uid, err)
|
||||
}
|
||||
if err := os.Chown(root, identity.uid, identity.gid); err != nil {
|
||||
return fmt.Errorf("failed to chown data root %s to %d:%d: %w", root, identity.uid, identity.gid, err)
|
||||
}
|
||||
@@ -212,6 +215,33 @@ func EnsureDataRoot() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// healOwnerState gives the instance-owner gate's state directory to the
|
||||
// runtime user. Boots before the privilege drop opened the gate's stores as
|
||||
// root first, leaving a root-owned <root>/maestro behind; after the handoff
|
||||
// every write then failed with EACCES (surfacing as a bogus
|
||||
// ERR_INVALID_CREDENTIALS on /api/auth/setup). Repairs such installs here,
|
||||
// while the process can still chown. Missing dirs are fine — nothing to heal.
|
||||
func healOwnerState(root string, id runtimeIdentity) error {
|
||||
dir := filepath.Join(root, "maestro")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read owner state dir: %w", err)
|
||||
}
|
||||
if err := os.Chown(dir, id.uid, id.gid); err != nil {
|
||||
return fmt.Errorf("chown %s: %w", dir, err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
p := filepath.Join(dir, e.Name())
|
||||
if err := os.Chown(p, id.uid, id.gid); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("chown %s: %w", p, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runtimeIdentity is the user the container should run as, plus any
|
||||
// supplementary groups needed to keep working (e.g. the docker socket group).
|
||||
type runtimeIdentity struct {
|
||||
|
||||
+11
-5
@@ -27,6 +27,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -249,13 +250,18 @@ func (s *FileOwnerStore) Create(_ context.Context, username, password string, is
|
||||
|
||||
func (s *FileOwnerStore) Verify(_ context.Context, username, password string) (*Owner, error) {
|
||||
o, err := s.Get(context.Background(), username)
|
||||
if err != nil || o == nil {
|
||||
if err != nil {
|
||||
// Unknown user is a credentials miss; keep storage failures
|
||||
// distinguishable so callers can answer 500 instead of 401.
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !VerifyPassword(o.PasswordHash, password) {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
if VerifyPassword(o.PasswordHash, password) {
|
||||
return o, nil
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (s *FileOwnerStore) Get(_ context.Context, username string) (*Owner, error) {
|
||||
|
||||
@@ -166,3 +166,29 @@ func TestOwnerFilePermissions(t *testing.T) {
|
||||
t.Fatalf("owners.json mode = %o, want 600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyDistinguishesStorageFailureFromUnknownUser pins the contract the
|
||||
// auth handlers rely on: an unknown user or wrong password surfaces as
|
||||
// os.ErrNotExist (→ 401), while a broken store keeps its own error (→ 500).
|
||||
func TestVerifyDistinguishesStorageFailureFromUnknownUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
s, err := NewFileOwnerStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Create(ctx, "admin", "verify-check-pass", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Verify(ctx, "ghost", "verify-check-pass"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("unknown user: %v", err)
|
||||
}
|
||||
|
||||
broken := []byte("{not json")
|
||||
if err := os.WriteFile(filepath.Join(dir, "owners.json"), broken, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Verify(ctx, "admin", "verify-check-pass"); err == nil || errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("storage failure collapsed into ErrNotExist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user