Cap the profile email-address conflict answer

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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 14:05:25 +02:00
1 parent 6a5fb338b1
commit fe420f76d8
17 files changed
+596 -181

No files matched your search

+1
View File
@@ -250,6 +250,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
practitioner: ["practitioner.invalid"],
profile: [
"profile.update.emailInUse",
"profile.update.emailRateLimited",
"profile.update.invalidBody",
"profile.update.nothingSaved",
],
+2
View File
@@ -38,6 +38,7 @@ import {
} from "@/lib/validations/step-up";
import {
dataEnvelope,
profileEmailRateLimitResponse,
stdResponses,
errorEnvelope,
loginPasswordSchema,
@@ -1766,6 +1767,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...profileEmailRateLimitResponse,
"422": {
description:
"Either the body was not a usable object (`meta.errorCode` = `profile.update.invalidBody`) or every supplied field was rejected (`profile.update.nothingSaved`). Both carry the per-field issue list. A body where SOME fields validate never reaches this response — it succeeds with `rejectedFields`.",
+7
View File
@@ -46,6 +46,7 @@ import {
errorEnvelope,
invalidBaseTokenResponse,
malformedJsonResponse,
profileEmailRateLimitResponse,
recordRefusal,
stdResponses,
updatedAtTokenField,
@@ -1476,7 +1477,13 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"409": {
description:
"The requested email belongs to another account (`meta.errorCode` = `profile.update.emailInUse`). Nothing was written. Shared with PUT /api/auth/profile, which runs the same handler.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...profileEmailRateLimitResponse,
},
},
},
+26
View File
@@ -318,6 +318,32 @@ export const recordWriteRateLimitResponse = {
},
};
/**
* The 429 the profile email-address bucket answers with.
*
* Changing the address asks whether another account already holds it, and the
* answer is a 409 versus a 200 — an existence check over every address on the
* instance, available to any signed-in caller. Neither route that reaches it
* had a ceiling of any kind, and `apiHandler` supplies no default, so the
* sweep was bounded only by the network. This is the response that names the
* bucket now capping it.
*
* Spread AFTER `...stdResponses` so it replaces the generic 429 on those two
* operations. The headers block is the one the standard 429 already declares.
*
* `profile-email-rate-limit-contract.test.ts` holds the numbers in this
* sentence to the constants in `src/lib/rate-limit.ts`, so the paragraph
* cannot drift away from the bucket it describes.
*/
export const profileEmailRateLimitResponse = {
"429": {
description:
"Too many email-address changes. The address change shares one per-account bucket across both profile routes — `profile-email:<accountId>`, 10 requests per 3600 seconds — keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address. Nothing was written. `meta.errorCode` is `profile.update.emailRateLimited`. Only a request whose `email` actually differs from the address on file is counted: re-saving a form that carries the unchanged address is free, and so is any update that does not touch `email` at all. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing.",
content: { "application/json": { schema: errorEnvelope } },
headers: stdResponses["429"].headers,
},
};
// ── Idempotent writes ────────────────────────────────────────────────
/**