feat(signup): find an account by email when re-issuing, and re-key the rate limiter #173
Inga granskare
Etiketter
Inga etiketter
area/backups
area/ci
area/control-panel
area/identity
area/infra
area/observability
area/payments
area/security
area/storage
area/web
blocked
needs-info
needs-triage
ready-for-implementation
type
bug
type
chore
type
docs
type
epic
type
feature
type
task
wontfix
Ingen milstolpe
Inget projekt
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Inget förfallodatum satt.
Beroenden
Inga beroenden satta
Referens
bitborg/bitborg-web!173
Läser in…
Hänvisa till i nytt ärende
Ingen beskrivning angiven.
Ta bort grenen "feat/resend-lookup-and-rate-limit"
Borttagning av en gren är permanent. Även om den borttagna grenen kan fortsätta existera en kort tid innan den faktiskt tas bort, kan det INTE ångras i de flesta fall. Vill du fortsätta?
Closes #127, closes #142.
#127 — the re-issue form now accepts an email address as well as a username.
The security constraint, because getting it wrong is an account-takeover primitive: the typed
address is a lookup key only. The link is still sent to the address on file, never to one the
caller supplies. Accepting a typed address as the destination would let anyone have a stranger's
credential-reset link delivered to themselves.
Non-enumerability is verified against the real route handler (only captcha and mailer stubbed),
comparing status, every header sorted, and body across: username hit and miss, address hit and miss,
username versus address on the same account, an account with no address on file, a mail-provider
refusal, and the lookup being unavailable. All identical —
303,Location: …?status=sent, emptybody. Also tested: an account found by one address but registered under another is mailed at the
registered one.
#142 — the in-process limiter is re-keyed and re-sized against the edge.
It keyed on the full address with no IPv6 prefixing, so one user with a /64 could rotate freely while
everyone behind a shared IPv4 egress shared a single budget. Now grouped to /56, matching the edge
zone. IPv4-mapped IPv6 (
::ffff:a.b.c.d, how Node reports IPv4 peers) is unwrapped first — maskingthose would collapse every IPv4 client into one bucket. There is a test for that.
signupresendcaptchaemail-changeAll budgets now sit next to a description of the edge zone they pair with.
Verification
pnpm lint,pnpm check,pnpm lang-check,pnpm test— 252 passing. Both changes writtentest-first.
Two things an operator should close
existing account costs more round-trips than a miss. That channel pre-dates this change; no
artificial floor was added, since it delays every legitimate user and is a judgement call.
source rather than executed, and the code fails closed. One live call with the portal's token
before merge would confirm it.
The resend form asked for a username — the one thing a person who never received their set-up email is least likely to remember, since the username is written down in the email that never arrived. An address is now accepted as well, but ONLY AS A LOOKUP KEY. The link still goes to the address on file, re-read from the person entry after the account is found, never to the string the caller typed. That distinction is the whole security argument, and it is why the form was username-only to begin with: a typed address used as the DESTINATION would let anyone have a stranger's credential-reset link delivered to themselves. Used only to find the account, it grants a caller nothing they did not already have, because the message still goes where the account already points. A test asserts the two cases apart — an account found by one address and registered under another is mailed at the registered one. The API question the issue asked to settle, settled: /v1/person/_search/{id} is a substring match on `name` alone (f_sub(Attribute::Name, ...) in Kanidm 1.10.4's v1 handlers), so it can never match an address, and GET /v1/person cannot be filtered at all. POST /v1/raw/search is the only endpoint that can. Despite its "be warned this can be dangerous" summary it is a READ, served from the read query server, and the filter is built here — the caller's input appears in it only as the string value of a single `eq` term, so an anonymous visitor cannot reshape the query. No schema change, no stored derived personal data, and no new privilege: Kanidm's built-in idm_acp_people_pii_read names idm_people_admins as a receiver with `mail` among its searchable attributes, which is the same grant that already lets this service account read `mail` off GET /v1/person/{name}. Non-enumerability is preserved and now has tests that assert it rather than comments that assert it. The route answers identically for: a username that exists and one that does not, an address that exists and one that does not, a username and the address on the same account, an account with no address on file, a refused mail send, and a lookup that cannot run at all. That last one is what makes this fail closed — if the read grant is ever lost the endpoint behaves exactly as it does for an unknown address, while logging the HTTP status and naming the likely cause. Kanidm's mail values compare exactly, so the lookup key is lowercased to match how every write path stores an address. A resolved name is re-validated against the Kanidm name charset before it goes anywhere near an API path, even though it came back from Kanidm rather than from the caller. Closes #12758d144affc48238ca2d4