trusted proxy, TOTP replay protection, new tests
- Trusted proxy config option for proxy-aware IP extraction used by rate limiting and audit logs; validates proxy IP before trusting X-Forwarded-For / X-Real-IP headers - TOTP replay protection via counter-based validation to reject reused codes within the same time step (±30s) - RateLimit middleware updated to extract client IP from proxy headers without IP spoofing risk - New tests for ClientIP proxy logic (spoofed headers, fallback) and extended rate-limit proxy coverage - HTMX error banner script integrated into web UI base - .gitignore updated for mciasdb build artifact Security: resolves CRIT-01 (TOTP replay attack) and DEF-03 (proxy-unaware rate limiting); gRPC TOTP enrollment aligned with REST via StorePendingTOTP Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -70,7 +70,10 @@ func (db *DB) GetAccountByID(id int64) (*model.Account, error) {
|
||||
`, id))
|
||||
}
|
||||
|
||||
// GetAccountByUsername retrieves an account by username (case-insensitive).
|
||||
// GetAccountByUsername retrieves an account by username.
|
||||
// Matching is case-sensitive: SQLite uses BINARY collation by default, so
|
||||
// "admin" and "Admin" are distinct usernames. This is intentional for an
|
||||
// SSO system where usernames should be treated as opaque identifiers.
|
||||
// Returns ErrNotFound if no matching account exists.
|
||||
func (db *DB) GetAccountByUsername(username string) (*model.Account, error) {
|
||||
return db.scanAccount(db.sql.QueryRow(`
|
||||
@@ -184,6 +187,46 @@ func (db *DB) SetTOTP(accountID int64, secretEnc, secretNonce []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAndUpdateTOTPCounter atomically verifies that counter is strictly
|
||||
// greater than the last accepted TOTP counter for the account, and if so,
|
||||
// stores counter as the new last accepted value.
|
||||
//
|
||||
// Returns ErrTOTPReplay if counter ≤ the stored value, preventing a replay
|
||||
// of a previously accepted code within the ±1 time-step validity window.
|
||||
// On the first successful TOTP login (stored value NULL) any counter is
|
||||
// accepted.
|
||||
//
|
||||
// Security (CRIT-01): RFC 6238 §5.2 recommends recording the last OTP
|
||||
// counter used and rejecting any code that does not advance it. Without
|
||||
// this, an intercepted code remains valid for up to 90 seconds. The update
|
||||
// is performed in a single parameterized SQL statement, so there is no
|
||||
// TOCTOU window between the check and the write.
|
||||
func (db *DB) CheckAndUpdateTOTPCounter(accountID int64, counter int64) error {
|
||||
result, err := db.sql.Exec(`
|
||||
UPDATE accounts
|
||||
SET last_totp_counter = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
AND (last_totp_counter IS NULL OR last_totp_counter < ?)
|
||||
`, counter, now(), accountID, counter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: check-and-update TOTP counter: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: check-and-update TOTP counter rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
// Security: the counter was not advanced — this code has already been
|
||||
// used within its validity window. Treat as authentication failure.
|
||||
return ErrTOTPReplay
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrTOTPReplay is returned by CheckAndUpdateTOTPCounter when the submitted
|
||||
// TOTP code corresponds to a counter value that has already been accepted.
|
||||
var ErrTOTPReplay = errors.New("db: TOTP code already used (replay)")
|
||||
|
||||
// ClearTOTP removes the TOTP secret and disables TOTP requirement.
|
||||
func (db *DB) ClearTOTP(accountID int64) error {
|
||||
_, err := db.sql.Exec(`
|
||||
@@ -300,6 +343,12 @@ func (db *DB) GetRoles(accountID int64) ([]string, error) {
|
||||
|
||||
// GrantRole adds a role to an account. If the role already exists, it is a no-op.
|
||||
func (db *DB) GrantRole(accountID int64, role string, grantedBy *int64) error {
|
||||
// Security (DEF-10): reject unknown roles before writing to the DB so
|
||||
// that typos (e.g. "admim") are caught immediately rather than silently
|
||||
// creating an unmatchable role.
|
||||
if err := model.ValidateRole(role); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.sql.Exec(`
|
||||
INSERT OR IGNORE INTO account_roles (account_id, role, granted_by, granted_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
@@ -323,6 +372,14 @@ func (db *DB) RevokeRole(accountID int64, role string) error {
|
||||
|
||||
// SetRoles replaces the full role set for an account atomically.
|
||||
func (db *DB) SetRoles(accountID int64, roles []string, grantedBy *int64) error {
|
||||
// Security (DEF-10): validate all roles before opening the transaction so
|
||||
// we fail fast without touching the database on an invalid input.
|
||||
for _, role := range roles {
|
||||
if err := model.ValidateRole(role); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.sql.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: set roles begin tx: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user