fix(PROJ-64): Session-Invalidation bei Passwort-Change + Datei-Permissions gehärtet
Security-Audit deckte zwei Medium-Findings auf: JWTs blieben bis zu 8h nach Passwort-Change/-Reset oder Admin-TOTP-Reset gültig (kein Session-Invalidation), und archivierte Mails/Anhänge wurden mit 0644/0755 statt 0600/0700 geschrieben. - users.tokens_valid_after (neue Spalte) wird bei SetPassword() und InvalidateTokensBefore() gesetzt; ValidateToken() lehnt JWTs mit iat davor ab. - Admin-TOTP-Reset revoked jetzt aktive Sessions des Zielnutzers. - Mail-/Attachment-Dateien und ihre Verzeichnisse nur noch für den archivmail-Service-Account lesbar. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0ccbd5bafb
commit
b286352d07
@@ -254,6 +254,12 @@ func (s *Server) handleTOTPReset(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// PROJ-64: invalidate any already-issued JWTs for the target user — an admin
|
||||
// resetting TOTP is an account-takeover response and must revoke live sessions.
|
||||
if err := s.users.InvalidateTokensBefore(r.Context(), id); err != nil {
|
||||
s.logger.Error("totp reset: failed to invalidate tokens", "err", err, "target_user", id)
|
||||
}
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventUserMgmt,
|
||||
Username: sess.Username,
|
||||
|
||||
@@ -375,6 +375,21 @@ func (m *Manager) ValidateToken(tokenStr string) (*Session, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// PROJ-64: reject tokens issued before a password change / admin TOTP reset,
|
||||
// closing the session-hijack window that a stateless-only JWT leaves open.
|
||||
var iat time.Time
|
||||
switch v := claims["iat"].(type) {
|
||||
case float64:
|
||||
iat = time.Unix(int64(v), 0)
|
||||
case int64:
|
||||
iat = time.Unix(v, 0)
|
||||
}
|
||||
if validAfter, err := m.store.TokensValidAfter(context.Background(), userID); err == nil && validAfter != nil {
|
||||
if iat.Before(*validAfter) {
|
||||
return nil, errors.New("auth: token revoked (credentials changed)")
|
||||
}
|
||||
}
|
||||
|
||||
return &Session{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
|
||||
@@ -42,11 +42,11 @@ func (s *Store) saveAttachments(ctx context.Context, emailID string, pm *mailpar
|
||||
}
|
||||
|
||||
attPath := s.attachmentPath(hash)
|
||||
if err := os.MkdirAll(filepath.Dir(attPath), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(attPath), 0o700); err != nil {
|
||||
return fmt.Errorf("storage: attachment mkdir: %w", err)
|
||||
}
|
||||
if _, statErr := os.Stat(attPath); os.IsNotExist(statErr) {
|
||||
if err := os.WriteFile(attPath, toWrite, 0o644); err != nil {
|
||||
if err := os.WriteFile(attPath, toWrite, 0o600); err != nil {
|
||||
return fmt.Errorf("storage: attachment write: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ type MailWithUID struct {
|
||||
// and connects to PostgreSQL.
|
||||
func New(cfg Config) (*Store, error) {
|
||||
for _, sub := range []string{"store", "attachments", "meta"} {
|
||||
if err := os.MkdirAll(filepath.Join(cfg.Dir, sub), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(cfg.Dir, sub), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("storage: mkdir %s: %w", sub, err)
|
||||
}
|
||||
}
|
||||
@@ -410,7 +410,7 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int
|
||||
id := fmt.Sprintf("%x", sum[:]) // 64 hex chars
|
||||
|
||||
path := s.filePath(id)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return "", fmt.Errorf("storage: mkdir shard: %w", err)
|
||||
}
|
||||
|
||||
@@ -443,7 +443,7 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int
|
||||
toWrite = toStore
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, toWrite, 0o644); err != nil {
|
||||
if err := os.WriteFile(path, toWrite, 0o600); err != nil {
|
||||
return "", fmt.Errorf("storage: write: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,14 @@ func (s *Store) initSchema(ctx context.Context) error {
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS list_page_size INT NOT NULL DEFAULT 25;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// PROJ-64: tokens_valid_after invalidiert alle vor diesem Zeitpunkt ausgestellten JWTs
|
||||
// (Passwort-Change/Reset, Admin-TOTP-Reset) — schließt Session-Hijack-Fenster.
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS tokens_valid_after TIMESTAMPTZ;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -207,10 +215,27 @@ func (s *Store) SetPassword(ctx context.Context, id int64, newPassword string) e
|
||||
if err != nil {
|
||||
return fmt.Errorf("userstore: bcrypt: %w", err)
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `UPDATE users SET password_hash=$1 WHERE id=$2`, string(hash), id)
|
||||
_, err = s.pool.Exec(ctx, `UPDATE users SET password_hash=$1, tokens_valid_after=NOW() WHERE id=$2`, string(hash), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// InvalidateTokensBefore sets tokens_valid_after=NOW() so all JWTs issued before
|
||||
// this call are rejected on next use (PROJ-64). Used e.g. after admin TOTP reset.
|
||||
func (s *Store) InvalidateTokensBefore(ctx context.Context, id int64) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE users SET tokens_valid_after=NOW() WHERE id=$1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// TokensValidAfter returns the tokens_valid_after timestamp for a user, or nil if unset.
|
||||
func (s *Store) TokensValidAfter(ctx context.Context, id int64) (*time.Time, error) {
|
||||
var t *time.Time
|
||||
err := s.pool.QueryRow(ctx, `SELECT tokens_valid_after FROM users WHERE id=$1`, id).Scan(&t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// GetByID retrieves a user by their numeric ID.
|
||||
func (s *Store) GetByID(id int64) (*User, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
Reference in New Issue
Block a user