Skip to content

Security fix: forgot password and sign-up give one answer for every email - #471

Merged
nambok merged 1 commit into
mainfrom
forgot-password-one-answer
Nov 4, 2026
Merged

nambok merged 1 commit into
mainfrom
forgot-password-one-answer

Conversation

@nambok

@nambok nambok commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Security fix. #464 made POST /auth/forgot-password on a self-hosted install answer with a different status and sentence per case: has an account, on the team without one, invited, not on this install. Anyone could use it to learn which emails have accounts or are on the team. Self-hosted sign-up had the same leak: 409 for an existing account, 403 for someone not invited. Both endpoints now give one answer for every email, and so does the sign-in page (dashboard pinned to CoderHelm/coderhelm #314).

Changes

  • Forgot password (gateway):
    • Every email on every install gets the same answer: 200 {"status":"sent","message":"If that email can sign in here, we've sent a reset code or a set-up link."}, with the same headers.
    • The answer goes out 2.5 s after the ask (FORGOT_ANSWER_AFTER). The work runs in a task while the answer waits, so the case can't be timed. A send still running at 2.5 s carries on after the answer.
    • What gets sent is unchanged: an account gets a Cognito reset code; a team member with no account gets the set-up email (still at most once an hour and five a week); invited and unknown emails get nothing.
    • Send and lookup failures are logged, never answered, including Cognito's per-user LimitExceeded, which only accounts could hit.
    • The 10/h per IP and 5/h per email limits stay; they count every email alike.
  • Self-hosted sign-up (gateway):
    • Invited, already has an account, or not let in, all get 200 verification_required: "If that email can join here, we've sent a verification code." It is padded to the same 2.5 s.
    • The email shape and the pool's password rules are checked first, for every email. Before, Cognito checked the password only for emails it got, so a weak-password error showed the email was invited.
    • An email that signed up but never confirmed gets a fresh code (ResendConfirmationCode; Cognito sends nothing for a confirmed account).
    • The hosted service's open sign-up is unchanged.
  • Infra: the dashboard app client sets preventUserExistenceErrors: true, so Cognito's own errors (sign-in, reset, confirm) don't say whether an email exists.
  • Checked, no leak: /auth/methods takes no email and returns only the install's senders. Sign-in errors are 401 for a wrong password or an unknown email; not_invited comes only after a correct password. confirm-reset and verify-email return 400 for every failure.
  • Docs (docs/self-host/sign-in.md): the single answer, what is sent in each case, the even timing, and the sign-up answer.
  • Dashboard pin: c599a92 to d26225b. That adds only Self-host: one organization, closed sign-up, admin-required two-factor sign-in #314: one forgot-password message, both sender lines, the note that a set-up link may come instead of a code, and the sign-up answer.

Verification

  • cargo fmt --check -p gateway, cargo clippy -p gateway --all-targets -- -D warnings, cargo test -p gateway (712 passed).
  • New gateway test: has an account, on the team without one, invited, and not on the install, each with different work times (one past the floor), give byte-identical status, headers and body, all at or just after the floor.
  • Other new tests: the self-hosted sign-up answer is padded and identical; the password rules; the answer names no email and no case.
  • Removed the tests that asserted the per-case statuses and sentences. The tests for what each case sends stay.
  • Infra npm test (145 passed, typecheck clean).
  • scripts/bump-dashboard.sh d26225b... (pin check passes). Dashboard Self-host: one organization, closed sign-up, admin-required two-factor sign-in #314 CI was green before merge.

Security fix. Since #464 forgot-password on a self-hosted install answered
with a different status per case (has an account, on the team without
one, invited, not on this install), so anyone could learn which emails
have accounts or are on the team.

Forgot password now answers the same status, body and headers for every
email on every install, and no sooner than 2.5 seconds after the ask, so
the case can't be timed either: the work (the Cognito reset code, or the
set-up email for a team member with no account here) runs while the
answer waits, and a slow send carries on after it. Send failures are
logged, never answered. The set-up email keeps its once-an-hour limit and
the 10/h per IP and 5/h per email limits stay.

Self-hosted sign-up answers the same way for invited, existing and
refused emails, with the password rules checked for every email first; an
email that signed up but never confirmed gets a fresh code. The app
client now has preventUserExistenceErrors on.

Pins the dashboard to d26225b (CoderHelm/coderhelm #314): the sign-in
page shows one message for every email and names both senders.
@nambok
nambok merged commit 440702a into main Nov 4, 2026
10 checks passed
@nambok
nambok deleted the forgot-password-one-answer branch November 4, 2026 20:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant