Skip to content

Commit 904fe51

Browse files
committed
sec(auth): stop leaking MFA enrollment via login error responses (closes #388)
The login handler previously forwarded err.Error() verbatim for any auth failure not matching ErrMFARequired or ErrInvalidMFACode. This included "MFA is enabled but not configured", which revealed to an attacker that credentials were correct and MFA was enrolled (but broken). Two attack vectors were closed: 1. Add ErrMFANotConfigured sentinel to internal/auth/errors.go and return it (wrapped via %w) from verifyPasswordAndMFA. Map it to "mfa_required" in the login handler -- identical to ErrMFARequired -- so "MFA enrolled + working" is indistinguishable from "MFA enrolled + broken secret" in the HTTP response. 2. Replace the bare err.Error() fallthrough in the login handler with a fixed "invalid credentials" string so no internal error message ever reaches the client. Tests positively assert response equivalence: ErrMFARequired and ErrMFANotConfigured produce the same 401 + "mfa_required" body; all wrong-password paths produce the same 401 + "invalid credentials" body.
1 parent 4956d66 commit 904fe51

5 files changed

Lines changed: 140 additions & 7 deletions

File tree

‎internal/api/handler_auth.go‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,17 @@ func (h *Handler) login(ctx context.Context, req *events.LambdaFunctionURLReques
5050
if errors.Is(err, auth.ErrInvalidMFACode) {
5151
return nil, NewClientError(401, "invalid_mfa_code")
5252
}
53-
return nil, NewClientError(401, err.Error())
53+
// ErrMFANotConfigured (MFA enabled but secret missing) maps to
54+
// "mfa_required" rather than a distinct message so an attacker
55+
// cannot distinguish "MFA enrolled + working" from "MFA enrolled
56+
// + broken secret" by probing the error response (issue #388).
57+
if errors.Is(err, auth.ErrMFANotConfigured) {
58+
return nil, NewClientError(401, "mfa_required")
59+
}
60+
// All other auth failures (wrong password, account not found,
61+
// locked, etc.) collapse to a single opaque 401. Never forward
62+
// err.Error() verbatim — it may reveal internal account state.
63+
return nil, NewClientError(401, "invalid credentials")
5464
}
5565

5666
return response, nil
@@ -364,8 +374,9 @@ func (h *Handler) changePassword(ctx context.Context, req *events.LambdaFunction
364374
// package. The login handler maps these via errors.Is() to the
365375
// machine-readable response codes "mfa_required" / "invalid_mfa_code".
366376
var (
367-
mfaRequiredSentinel = auth.ErrMFARequired
368-
mfaInvalidSentinel = auth.ErrInvalidMFACode
377+
mfaRequiredSentinel = auth.ErrMFARequired
378+
mfaInvalidSentinel = auth.ErrInvalidMFACode
379+
mfaNotConfiguredSentinel = auth.ErrMFANotConfigured
369380
)
370381

371382
// mapMFAServiceError maps a service-layer MFA error to the right

‎internal/api/handler_auth_test.go‎

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1140,5 +1140,118 @@ func TestHandler_mfaRegenerateRecoveryCodes_HappyPath(t *testing.T) {
11401140
// auth package (errors.go); the api package's login handler maps
11411141
// them via errors.Is(). Here we just wrap them so the mocked Login
11421142
// returns the same value the real service would.
1143-
func ErrMFARequired_test() error { return mfaRequiredSentinel }
1144-
func ErrInvalidMFACode_test() error { return mfaInvalidSentinel }
1143+
func ErrMFARequired_test() error { return mfaRequiredSentinel }
1144+
func ErrInvalidMFACode_test() error { return mfaInvalidSentinel }
1145+
func ErrMFANotConfigured_test() error { return mfaNotConfiguredSentinel }
1146+
1147+
// ---------------------------------------------------------------
1148+
// Issue #388 — MFA enrollment status must not be leaked via login
1149+
// error responses.
1150+
//
1151+
// An attacker who observes distinct error codes for
1152+
// (a) wrong credentials + no MFA enrolled
1153+
// (b) wrong credentials + MFA enrolled
1154+
// can confirm whether a target account has MFA enabled without the
1155+
// correct password. All failed-login paths must produce identical
1156+
// response code and message.
1157+
//
1158+
// The tests below also verify that ErrMFANotConfigured (MFA flagged
1159+
// on but secret missing) maps to "mfa_required" rather than a
1160+
// distinct message — so "MFA enrolled + working" is
1161+
// indistinguishable from "MFA enrolled + broken secret".
1162+
// ---------------------------------------------------------------
1163+
1164+
// TestLogin_FailedAuth_ResponseEquivalence asserts that two
1165+
// failed-login attempts — one for a user without MFA (generic 401)
1166+
// and one for a user with MFA enrolled (any non-mfa_required path)
1167+
// — produce the exact same HTTP status code and error message.
1168+
func TestLogin_FailedAuth_ResponseEquivalence(t *testing.T) {
1169+
ctx := context.Background()
1170+
1171+
genericErr := errors.New("Check your email address and password and try again")
1172+
1173+
for _, tc := range []struct {
1174+
name string
1175+
authErr error
1176+
}{
1177+
{"no-MFA user wrong password", genericErr},
1178+
{"MFA-enrolled user wrong password", genericErr},
1179+
} {
1180+
tc := tc
1181+
t.Run(tc.name, func(t *testing.T) {
1182+
mockAuth := new(MockAuthService)
1183+
mockAuth.On("Login", ctx, mock.Anything).
1184+
Return((*LoginResponse)(nil), tc.authErr).Once()
1185+
t.Cleanup(func() { mockAuth.AssertExpectations(t) })
1186+
1187+
handler := &Handler{auth: mockAuth}
1188+
req := &events.LambdaFunctionURLRequest{
1189+
Body: `{"email":"u@x.com","password":"` + b64("wrong") + `"}`,
1190+
}
1191+
_, err := handler.login(ctx, req)
1192+
require.Error(t, err)
1193+
ce, ok := IsClientError(err)
1194+
require.True(t, ok, "%s: expected ClientError, got %T: %v", tc.name, err, err)
1195+
assert.Equal(t, 401, ce.code, "%s: status code mismatch", tc.name)
1196+
assert.Equal(t, "invalid credentials", ce.Error(), "%s: message mismatch", tc.name)
1197+
})
1198+
}
1199+
}
1200+
1201+
// TestLogin_MFANotConfigured_ReturnsMFARequired asserts that
1202+
// ErrMFANotConfigured (MFA enabled + secret missing) produces the
1203+
// same "mfa_required" response as ErrMFARequired, so an attacker
1204+
// cannot distinguish a correctly-enrolled account from a
1205+
// partially-enrolled one (issue #388).
1206+
func TestLogin_MFANotConfigured_ReturnsMFARequired(t *testing.T) {
1207+
ctx := context.Background()
1208+
mockAuth := new(MockAuthService)
1209+
mockAuth.On("Login", ctx, mock.Anything).
1210+
Return((*LoginResponse)(nil), ErrMFANotConfigured_test()).Once()
1211+
t.Cleanup(func() { mockAuth.AssertExpectations(t) })
1212+
1213+
handler := &Handler{auth: mockAuth}
1214+
req := &events.LambdaFunctionURLRequest{
1215+
Body: `{"email":"u@x.com","password":"` + b64("correct") + `","mfa_code":""}`,
1216+
}
1217+
_, err := handler.login(ctx, req)
1218+
require.Error(t, err)
1219+
ce, ok := IsClientError(err)
1220+
require.True(t, ok, "expected ClientError")
1221+
assert.Equal(t, 401, ce.code)
1222+
// Must be identical to the ErrMFARequired response — both paths must
1223+
// be indistinguishable to an external observer (issue #388).
1224+
assert.Equal(t, "mfa_required", ce.Error(),
1225+
"ErrMFANotConfigured must produce 'mfa_required', identical to ErrMFARequired")
1226+
}
1227+
1228+
// TestLogin_MFARequired_And_NotConfigured_ProduceSameResponse asserts
1229+
// that the two MFA enrollment states (working vs broken secret) map
1230+
// to IDENTICAL response bodies and status codes (issue #388).
1231+
func TestLogin_MFARequired_And_NotConfigured_ProduceSameResponse(t *testing.T) {
1232+
ctx := context.Background()
1233+
req := &events.LambdaFunctionURLRequest{
1234+
Body: `{"email":"u@x.com","password":"` + b64("correct") + `"}`,
1235+
}
1236+
1237+
getResponse := func(authErr error) (int, string) {
1238+
mockAuth := new(MockAuthService)
1239+
mockAuth.On("Login", ctx, mock.Anything).
1240+
Return((*LoginResponse)(nil), authErr).Once()
1241+
t.Cleanup(func() { mockAuth.AssertExpectations(t) })
1242+
handler := &Handler{auth: mockAuth}
1243+
_, err := handler.login(ctx, req)
1244+
require.Error(t, err)
1245+
ce, ok := IsClientError(err)
1246+
require.True(t, ok)
1247+
return ce.code, ce.Error()
1248+
}
1249+
1250+
codeA, msgA := getResponse(ErrMFARequired_test())
1251+
codeB, msgB := getResponse(ErrMFANotConfigured_test())
1252+
1253+
assert.Equal(t, codeA, codeB,
1254+
"ErrMFARequired and ErrMFANotConfigured must return the same HTTP status")
1255+
assert.Equal(t, msgA, msgB,
1256+
"ErrMFARequired and ErrMFANotConfigured must return identical response bodies (issue #388)")
1257+
}

‎internal/auth/errors.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,12 @@ var (
2525
// without substring-matching the human message. See issue #497.
2626
ErrMFARequired = errors.New("mfa_required")
2727
ErrInvalidMFACode = errors.New("invalid_mfa_code")
28+
29+
// ErrMFANotConfigured is returned when a user has MFA flagged as
30+
// enabled but the MFA secret is missing (e.g. enrollment was
31+
// interrupted). The login handler maps this to "mfa_required" — the
32+
// same response as ErrMFARequired — so an attacker cannot distinguish
33+
// "MFA enrolled and working" from "MFA enrolled but secret missing"
34+
// by observing the error message. See issue #388.
35+
ErrMFANotConfigured = errors.New("MFA is enabled but not configured")
2836
)

‎internal/auth/service.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ func (s *Service) verifyPasswordAndMFA(ctx context.Context, user *User, req Logi
195195
return ErrMFARequired
196196
}
197197
if user.MFASecret == "" {
198-
return fmt.Errorf("MFA is enabled but not configured")
198+
return fmt.Errorf("%w", ErrMFANotConfigured)
199199
}
200200
// verifyTOTP is panic-safe: a malformed secret causes generateTOTP to return ""
201201
// (base32 decode error), resulting in a comparison miss rather than a panic.

‎internal/auth/service_test.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,8 @@ func TestLogin_WithMFA_NoSecret(t *testing.T) {
477477
resp, err := service.Login(ctx, req)
478478
assert.Error(t, err)
479479
assert.Nil(t, resp)
480-
assert.Contains(t, err.Error(), "MFA is enabled but not configured")
480+
assert.ErrorIs(t, err, ErrMFANotConfigured,
481+
"Login with MFA enabled but no secret must return ErrMFANotConfigured")
481482
}
482483

483484
// Test UpdateUserProfile

0 commit comments

Comments
 (0)