Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions internal/api/handler_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,17 @@ func (h *Handler) login(ctx context.Context, req *events.LambdaFunctionURLReques
if errors.Is(err, auth.ErrInvalidMFACode) {
return nil, NewClientError(401, "invalid_mfa_code")
}
return nil, NewClientError(401, err.Error())
// ErrMFANotConfigured (MFA enabled but secret missing) maps to
// "mfa_required" rather than a distinct message so an attacker
// cannot distinguish "MFA enrolled + working" from "MFA enrolled
// + broken secret" by probing the error response (issue #388).
if errors.Is(err, auth.ErrMFANotConfigured) {
return nil, NewClientError(401, "mfa_required")
}
// 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")
Comment on lines +60 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't collapse unexpected login failures into 401s.

auth.Login can still return non-auth errors like invalid email format and failed to create session: ... from internal/auth/service.go. Mapping all of those to 401 invalid credentials will hide real outages and misclassify bad requests as auth failures. Keep the opaque 401 only for the normalized credential-failure path, and let everything else bubble or map separately.

Suggested fix
-		// 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")
+		// Only the normalized credential-failure path should collapse to
+		// the opaque 401. Let validation/infrastructure errors preserve
+		// their semantics upstream.
+		switch err.Error() {
+		case "invalid email or password":
+			return nil, NewClientError(401, "invalid credentials")
+		case "invalid email format":
+			return nil, NewClientError(400, "invalid email format")
+		default:
+			return nil, err
+		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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")
// Only the normalized credential-failure path should collapse to
// the opaque 401. Let validation/infrastructure errors preserve
// their semantics upstream.
switch err.Error() {
case "invalid email or password":
return nil, NewClientError(401, "invalid credentials")
case "invalid email format":
return nil, NewClientError(400, "invalid email format")
default:
return nil, err
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_auth.go` around lines 60 - 63, The current
handler_auth.go maps every error returned from auth.Login to NewClientError(401,
"invalid credentials"), hiding non-auth issues; change the logic in the code
that calls auth.Login to only return the opaque 401 for the explicit normalized
credential-failure sentinel (e.g., auth.ErrInvalidCredentials or whatever
specific value/type the auth package uses), and for any other error returned by
auth.Login let it propagate or map to an appropriate code (e.g., return the
original error, convert validation errors to 400, and server-side errors to 500)
instead of collapsing them into 401; update the conditional that currently does
return nil, NewClientError(401, "invalid credentials") to check the concrete
error identity/type first and handle other errors separately.

}

return response, nil
Expand Down Expand Up @@ -498,8 +508,9 @@ func (h *Handler) changePassword(ctx context.Context, req *events.LambdaFunction
// package. The login handler maps these via errors.Is() to the
// machine-readable response codes "mfa_required" / "invalid_mfa_code".
var (
mfaRequiredSentinel = auth.ErrMFARequired
mfaInvalidSentinel = auth.ErrInvalidMFACode
mfaRequiredSentinel = auth.ErrMFARequired
mfaInvalidSentinel = auth.ErrInvalidMFACode
mfaNotConfiguredSentinel = auth.ErrMFANotConfigured
)

// mapMFAServiceError maps a service-layer MFA error to the right
Expand Down
117 changes: 115 additions & 2 deletions internal/api/handler_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1250,8 +1250,9 @@ func TestHandler_mfaRegenerateRecoveryCodes_HappyPath(t *testing.T) {
// auth package (errors.go); the api package's login handler maps
// them via errors.Is(). Here we just wrap them so the mocked Login
// returns the same value the real service would.
func ErrMFARequired_test() error { return mfaRequiredSentinel }
func ErrInvalidMFACode_test() error { return mfaInvalidSentinel }
func ErrMFARequired_test() error { return mfaRequiredSentinel }
func ErrInvalidMFACode_test() error { return mfaInvalidSentinel }
func ErrMFANotConfigured_test() error { return mfaNotConfiguredSentinel }

// Tests for GET /api/auth/me/permissions (issue #917).

Expand Down Expand Up @@ -1483,3 +1484,115 @@ func TestHandler_login_ErrorEquivalence(t *testing.T) {
assert.Equal(t, ceNotFound.Error(), ceWrongPass.Error(),
"error body must be identical for unknown-user and wrong-password paths to prevent enumeration")
}

// ---------------------------------------------------------------
// Issue #388 — MFA enrollment status must not be leaked via login
// error responses.
//
// An attacker who observes distinct error codes for
// (a) wrong credentials + no MFA enrolled
// (b) wrong credentials + MFA enrolled
// can confirm whether a target account has MFA enabled without the
// correct password. All failed-login paths must produce identical
// response code and message.
//
// The tests below also verify that ErrMFANotConfigured (MFA flagged
// on but secret missing) maps to "mfa_required" rather than a
// distinct message — so "MFA enrolled + working" is
// indistinguishable from "MFA enrolled + broken secret".
// ---------------------------------------------------------------

// TestLogin_FailedAuth_ResponseEquivalence asserts that two
// failed-login attempts — one for a user without MFA (generic 401)
// and one for a user with MFA enrolled (any non-mfa_required path)
// — produce the exact same HTTP status code and error message.
func TestLogin_FailedAuth_ResponseEquivalence(t *testing.T) {
ctx := context.Background()

genericErr := errors.New("Check your email address and password and try again")

for _, tc := range []struct {
name string
authErr error
}{
{"no-MFA user wrong password", genericErr},
{"MFA-enrolled user wrong password", genericErr},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
mockAuth := new(MockAuthService)
mockAuth.On("Login", ctx, mock.Anything).
Return((*LoginResponse)(nil), tc.authErr).Once()
t.Cleanup(func() { mockAuth.AssertExpectations(t) })

handler := &Handler{auth: mockAuth}
req := &events.LambdaFunctionURLRequest{
Body: `{"email":"u@x.com","password":"` + b64("wrong") + `"}`,
}
_, err := handler.login(ctx, req)
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok, "%s: expected ClientError, got %T: %v", tc.name, err, err)
assert.Equal(t, 401, ce.code, "%s: status code mismatch", tc.name)
assert.Equal(t, "invalid credentials", ce.Error(), "%s: message mismatch", tc.name)
})
}
}

// TestLogin_MFANotConfigured_ReturnsMFARequired asserts that
// ErrMFANotConfigured (MFA enabled + secret missing) produces the
// same "mfa_required" response as ErrMFARequired, so an attacker
// cannot distinguish a correctly-enrolled account from a
// partially-enrolled one (issue #388).
func TestLogin_MFANotConfigured_ReturnsMFARequired(t *testing.T) {
ctx := context.Background()
mockAuth := new(MockAuthService)
mockAuth.On("Login", ctx, mock.Anything).
Return((*LoginResponse)(nil), ErrMFANotConfigured_test()).Once()
t.Cleanup(func() { mockAuth.AssertExpectations(t) })

handler := &Handler{auth: mockAuth}
req := &events.LambdaFunctionURLRequest{
Body: `{"email":"u@x.com","password":"` + b64("correct") + `","mfa_code":""}`,
}
_, err := handler.login(ctx, req)
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok, "expected ClientError")
assert.Equal(t, 401, ce.code)
// Must be identical to the ErrMFARequired response — both paths must
// be indistinguishable to an external observer (issue #388).
assert.Equal(t, "mfa_required", ce.Error(),
"ErrMFANotConfigured must produce 'mfa_required', identical to ErrMFARequired")
}

// TestLogin_MFARequired_And_NotConfigured_ProduceSameResponse asserts
// that the two MFA enrollment states (working vs broken secret) map
// to IDENTICAL response bodies and status codes (issue #388).
func TestLogin_MFARequired_And_NotConfigured_ProduceSameResponse(t *testing.T) {
ctx := context.Background()
req := &events.LambdaFunctionURLRequest{
Body: `{"email":"u@x.com","password":"` + b64("correct") + `"}`,
}

getResponse := func(authErr error) (int, string) {
mockAuth := new(MockAuthService)
mockAuth.On("Login", ctx, mock.Anything).
Return((*LoginResponse)(nil), authErr).Once()
t.Cleanup(func() { mockAuth.AssertExpectations(t) })
handler := &Handler{auth: mockAuth}
_, err := handler.login(ctx, req)
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok)
return ce.code, ce.Error()
}

codeA, msgA := getResponse(ErrMFARequired_test())
codeB, msgB := getResponse(ErrMFANotConfigured_test())

assert.Equal(t, codeA, codeB,
"ErrMFARequired and ErrMFANotConfigured must return the same HTTP status")
assert.Equal(t, msgA, msgB,
"ErrMFARequired and ErrMFANotConfigured must return identical response bodies (issue #388)")
}
8 changes: 8 additions & 0 deletions internal/auth/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,12 @@ var (
// without substring-matching the human message. See issue #497.
ErrMFARequired = errors.New("mfa_required")
ErrInvalidMFACode = errors.New("invalid_mfa_code")

// ErrMFANotConfigured is returned when a user has MFA flagged as
// enabled but the MFA secret is missing (e.g. enrollment was
// interrupted). The login handler maps this to "mfa_required" — the
// same response as ErrMFARequired — so an attacker cannot distinguish
// "MFA enrolled and working" from "MFA enrolled but secret missing"
// by observing the error message. See issue #388.
ErrMFANotConfigured = errors.New("MFA is enabled but not configured")
)
2 changes: 1 addition & 1 deletion internal/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ func (s *Service) verifyPasswordAndMFA(ctx context.Context, user *User, req Logi
return ErrMFARequired
}
if user.MFASecret == "" {
return fmt.Errorf("MFA is enabled but not configured")
return fmt.Errorf("%w", ErrMFANotConfigured)
}
// verifyTOTP is panic-safe: a malformed secret causes generateTOTP to return ""
// (base32 decode error), resulting in a comparison miss rather than a panic.
Expand Down
3 changes: 2 additions & 1 deletion internal/auth/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,8 @@ func TestLogin_WithMFA_NoSecret(t *testing.T) {
resp, err := service.Login(ctx, req)
assert.Error(t, err)
assert.Nil(t, resp)
assert.Contains(t, err.Error(), "MFA is enabled but not configured")
assert.ErrorIs(t, err, ErrMFANotConfigured,
"Login with MFA enabled but no secret must return ErrMFANotConfigured")
}

// Test UpdateUserProfile
Expand Down
Loading