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:
2026-03-12 17:44:01 -07:00
parent f262ca7b4e
commit ec7c966ad2
31 changed files with 799 additions and 250 deletions

View File

@@ -72,8 +72,14 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
if acct.TOTPRequired {
if req.TotpCode == "" {
// Security (DEF-08): password was already verified, so a missing
// TOTP code means the gRPC client needs to re-prompt the user —
// it is not a credential failure. Do NOT increment the lockout
// counter here; doing so would lock out well-behaved clients that
// call Login in two steps (password first, TOTP second) and would
// also let an attacker trigger account lockout by omitting the
// code after a successful password guess.
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"totp_missing"}`) //nolint:errcheck
_ = a.s.db.RecordLoginFailure(acct.ID)
return nil, status.Error(codes.Unauthenticated, "TOTP code required")
}
secret, err := crypto.OpenAESGCM(a.s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
@@ -81,12 +87,19 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
a.s.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
return nil, status.Error(codes.Internal, "internal error")
}
valid, err := auth.ValidateTOTP(secret, req.TotpCode)
valid, counter, err := auth.ValidateTOTP(secret, req.TotpCode)
if err != nil || !valid {
a.s.db.WriteAuditEvent(model.EventLoginTOTPFail, &acct.ID, nil, ip, `{"reason":"wrong_totp"}`) //nolint:errcheck
_ = a.s.db.RecordLoginFailure(acct.ID)
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
}
// Security (CRIT-01): reject replay of a code already used within
// its ±30-second validity window.
if err := a.s.db.CheckAndUpdateTOTPCounter(acct.ID, counter); err != nil {
a.s.db.WriteAuditEvent(model.EventLoginTOTPFail, &acct.ID, nil, ip, `{"reason":"totp_replay"}`) //nolint:errcheck
_ = a.s.db.RecordLoginFailure(acct.ID)
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
}
}
// Login succeeded: clear any outstanding failure counter.
@@ -199,7 +212,12 @@ func (a *authServiceServer) EnrollTOTP(ctx context.Context, _ *mciasv1.EnrollTOT
return nil, status.Error(codes.Internal, "internal error")
}
if err := a.s.db.SetTOTP(acct.ID, secretEnc, secretNonce); err != nil {
// Security: use StorePendingTOTP (not SetTOTP) so that totp_required is
// not set to 1 until the user confirms the code via ConfirmTOTP. Calling
// SetTOTP here would immediately lock the account behind TOTP before the
// user has had a chance to configure their authenticator app — matching the
// behaviour of the REST EnrollTOTP handler at internal/server/server.go.
if err := a.s.db.StorePendingTOTP(acct.ID, secretEnc, secretNonce); err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
@@ -232,10 +250,15 @@ func (a *authServiceServer) ConfirmTOTP(ctx context.Context, req *mciasv1.Confir
return nil, status.Error(codes.Internal, "internal error")
}
valid, err := auth.ValidateTOTP(secret, req.Code)
valid, counter, err := auth.ValidateTOTP(secret, req.Code)
if err != nil || !valid {
return nil, status.Error(codes.Unauthenticated, "invalid TOTP code")
}
// Security (CRIT-01): record the counter even during enrollment confirmation
// so the same code cannot be replayed immediately after confirming.
if err := a.s.db.CheckAndUpdateTOTPCounter(acct.ID, counter); err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid TOTP code")
}
// SetTOTP with existing enc/nonce sets totp_required=1, confirming enrollment.
if err := a.s.db.SetTOTP(acct.ID, acct.TOTPSecretEnc, acct.TOTPSecretNonce); err != nil {

View File

@@ -542,7 +542,7 @@ func TestSetAndGetRoles(t *testing.T) {
_, err = cl.SetRoles(authCtx(adminTok), &mciasv1.SetRolesRequest{
Id: id,
Roles: []string{"editor", "viewer"},
Roles: []string{"admin", "user"},
})
if err != nil {
t.Fatalf("SetRoles: %v", err)