Problem
The login handler's catch-all error path forwards raw service errors verbatim to the client:
// internal/api/handler_auth.go (login)
return nil, NewClientError(401, err.Error())
Forwarding err.Error() means any current or future service-layer error message (account state, internal failures, etc.) is exposed in the 401 response body. This is a defense-in-depth / information-disclosure hygiene gap: the specific MFA-enrollment leak was already fixed by #830 (#391) collapsing the missing-secret case into the generic message, but the catch-all itself still forwards whatever string the service returns.
Fix
Replace the catch-all with an opaque, hardcoded response so raw service errors are never forwarded:
// All other auth failures (wrong password, account not found, locked, etc.)
// collapse to a single opaque 401. Never forward err.Error() verbatim - it
// may reveal internal account state.
return nil, NewClientError(401, "invalid credentials")
Keep the existing typed-sentinel arms above it (ErrMFARequired -> mfa_required, ErrInvalidMFACode -> invalid_mfa_code) unchanged - only the final fallthrough changes.
Acceptance criteria
- The login catch-all returns a fixed opaque message, not
err.Error().
- A regression test asserts that a service error with a distinctive internal message does NOT appear in the login 401 response body (the response is the opaque string).
- Existing MFA-required / invalid-code response codes are unchanged.
Provenance
Salvaged from closed PR #886 (the rest of which was superseded by the merged #830/#391). This is the one non-conflicting hardening from that PR worth keeping on its own.
Problem
The login handler's catch-all error path forwards raw service errors verbatim to the client:
Forwarding
err.Error()means any current or future service-layer error message (account state, internal failures, etc.) is exposed in the 401 response body. This is a defense-in-depth / information-disclosure hygiene gap: the specific MFA-enrollment leak was already fixed by #830 (#391) collapsing the missing-secret case into the generic message, but the catch-all itself still forwards whatever string the service returns.Fix
Replace the catch-all with an opaque, hardcoded response so raw service errors are never forwarded:
Keep the existing typed-sentinel arms above it (
ErrMFARequired -> mfa_required,ErrInvalidMFACode -> invalid_mfa_code) unchanged - only the final fallthrough changes.Acceptance criteria
err.Error().Provenance
Salvaged from closed PR #886 (the rest of which was superseded by the merged #830/#391). This is the one non-conflicting hardening from that PR worth keeping on its own.