Skip to content

Release v1.38.20 - #958

Merged
MBombeck merged 10 commits into
mainfrom
fix/close-account-enumeration
Sep 11, 2026
Merged

MBombeck merged 10 commits into
mainfrom
fix/close-account-enumeration

Conversation

@MBombeck

Copy link
Copy Markdown
Owner

v1.38.20

Signing in no longer tells anybody which addresses have an account here.

Security

  • Signing in takes the same time whether or not the account exists. The screen always gave the same answer, but the server reached it much faster when there was no account to check, and the same for an account that signs in with a passkey and has no password. Somebody probing the sign-in page could read that difference. Every refusal now does the same work before it answers.
  • Changing your email address is limited to ten changes an hour. The server tells you when an address already belongs to another account, which is the only sensible thing to say to somebody who typed one; nothing stopped a signed-in caller from asking that over and over about addresses that were not theirs. A save that leaves the address alone does not count. Hitting the limit while saving other fields keeps those and holds back only the address.

Removed

  • POST /api/auth/check-user is gone. It said, to anybody and without signing in, whether an account existed and whether it had a passkey or a password. It was built to decide which field a sign-in screen shows first, which is not worth telling the world who has an account here. Neither the native client nor this application's own screens ever called it. The path answers 410 and says so in the API reference rather than disappearing, so anything still calling it gets a clear answer instead of a 404 that looks like a broken server.

Full entry in CHANGELOG.md.

`POST /api/auth/check-user` answered, for any typed identifier and with
no credential at all, whether an account exists and whether it carries a
passkey or a password. Its header called that the intended contract with
the native client; it is not one any more. The native client never calls
it, and no web surface in this repository does either.

Removed without a replacement: the route, its two test files, the
OpenAPI operation and its two schemas, the surface's mention in the
rate-limiter doc comment, the bucket expectation in the auth-surface
rate-limit test, and the login route's comment pointing at it. No stub,
no deprecation shim, no 410 arm — there is no caller to keep.
Sign-in returned the moment it found no account, or an account carrying
no password hash, before it reached the verifier. An account that did
have a password paid a full Argon2id verification at 19 MiB and t=2. The
three refusals answered with the identical 401 body and three very
different durations, so the clock told an anonymous caller whether an
address is registered and whether it carries a password.

`verifyPasswordOrDummy` verifies against a stand-in hash minted once per
process at the same cost parameters real hashes use, and returns false
whenever there is nothing to verify, so the passkey-only account takes
the same path as one with a password. The dummy arm discards its own
verdict and swallows a throw, so no outcome there can be read as a
success or answer with a different status.
Changing the profile address asks whether another account already holds
it, and the answer is 409 versus 200 — an existence check over every
address on the instance, open to any signed-in caller. Neither route
that reaches it had a ceiling of any kind and the API wrapper supplies
no default, so the only bound on a sweep was the network.

Both routes now share one per-account bucket, ten an hour, charged
before the question is asked so a refused caller learns nothing. Only a
request whose address actually differs from the one on file is counted,
so re-saving a settings form that carries the unchanged address stays
free. The refusal is the standard 429 with Retry-After and the
X-RateLimit triple, carries `profile.update.emailRateLimited`, and is
published on both operations.
With the discovery endpoint gone, this is the remaining anonymous path
that answers whether an address is registered. The 409 already gives
that away, so the work asymmetry ahead of it costs nothing today. It
would become the whole answer the moment somebody unified the status
code without moving the check after the hash, so the ordering now says
it is load-bearing.
The dummy hash was awaited as an argument to the verifier, so a rejected
mint threw before the verifier was reached and before the catch on it
could apply. The promise was created once at import, and a rejected
promise stays rejected, so one failed mint under memory pressure would
have made every unknown identifier answer 500 while a real account
answered 401 — a cleaner oracle than the timing gap this closes, and
passkey-only accounts locked out of the password form until restart.

The mint is now awaited inside the guarded region, is lazy rather than
an import-time cost in every process that touches this module, and is
dropped on failure so the next refusal mints again instead of inheriting
a cached rejection. A failure leaves a log line; the answer is false
either way. The tests cover a rejecting mint, not only a rejecting
verify.
The path stood in the published contract, so a caller outside this
repository can hold a reference to it whether or not we can name one. A
bare 404 reads as a broken deploy or a misrouted proxy, which is exactly
the reading that kept a client retrying a removed path for twelve days
once before. Registering the retirement makes the edge answer 410 with
the removing version and no replacement, and republishes the path as
gone rather than erasing it from the document.
A refused address change returned before the update was built, so every
other field in the same request went with it. The settings form posts
the whole profile at once, so somebody who had spent the budget could
not save a timezone or a height for an hour and was told it was about
email addresses. The address is now dropped and the rest of the save
lands, reported through the existing rejectedFields key with the code
`rate_limited`; the 429 is kept for a request that asked for nothing
else, where a partial success would be a lie.

The comment claiming an audit row beside each question was inverted:
both refusals returned before the row is written, so only the answers
that told the caller nothing were being recorded. The two answers worth
detecting write their own rows now.

The stored address is normalised before the "has it changed" comparison.
Registration writes it as typed, so an account created with capitals did
not match its own address and paid for its first ordinary save.
v1.38.19 was cut before this change and still answers on the path, so
naming it as the version that removed the route would be wrong in the
published contract from the day it appears.
…nt here

Bump the version, the service-worker fallback and the OpenAPI document,
and add the CHANGELOG entry for v1.38.20.
@MBombeck
MBombeck merged commit d1bab39 into main Sep 11, 2026
24 checks passed
@MBombeck
MBombeck deleted the fix/close-account-enumeration branch September 20, 2026 15:52
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