Found during the cross-model pre-deploy review of #20 (Codex, signup-security dimension).
requireSessionOrIdToken() returns only the uid; the route then validates and persists the client-supplied body.email (web/app/api/users/bootstrap/route.ts — email validation + isDisposableEmailDomain(body.email) + bootstrapUser({ email: body.email })). Because the server write uses the Admin SDK, it bypasses the email == request.auth.token.email pin in firestore.rules.
Impact (medium, pre-existing — not introduced by #20, but the new disposable control inherits it):
- A bot can sign up via Firebase with a disposable address (e.g.
bot@mailinator.com), then POST bootstrap with body.email = clean@gmail.com → passes the disposable filter and stores a falsified email.
- The stored
users/{uid}.email is attacker-controlled (admin user-list shows a spoofable address; any logic keyed on it reads an unverified value). No priv-esc (uid/role/sites are pinned).
Fix: use the verified email from the decoded ID token (or getAdminAuth().getUser(uid).email) for both the disposable check and the persisted value; ignore body.email (or reject when it doesn't match the verified email).
Found during the cross-model pre-deploy review of #20 (Codex, signup-security dimension).
requireSessionOrIdToken()returns only theuid; the route then validates and persists the client-suppliedbody.email(web/app/api/users/bootstrap/route.ts — email validation +isDisposableEmailDomain(body.email)+bootstrapUser({ email: body.email })). Because the server write uses the Admin SDK, it bypasses theemail == request.auth.token.emailpin infirestore.rules.Impact (medium, pre-existing — not introduced by #20, but the new disposable control inherits it):
bot@mailinator.com), then POST bootstrap withbody.email = clean@gmail.com→ passes the disposable filter and stores a falsified email.users/{uid}.emailis attacker-controlled (admin user-list shows a spoofable address; any logic keyed on it reads an unverified value). No priv-esc (uid/role/sites are pinned).Fix: use the verified email from the decoded ID token (or
getAdminAuth().getUser(uid).email) for both the disposable check and the persisted value; ignorebody.email(or reject when it doesn't match the verified email).