fix team invitation email check + verification code TOCTOU (#1365)

## Summary

Two authorization fixes in the backend. Both are pre-existing in `dev`
and were found during a security audit of `apps/backend/src`.

### 1. Team invitation accept — email not validated


[`team-invitations/accept/verification-code-handler.tsx`](https://github.com/stack-auth/stack-auth/blob/dev/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx)
destructured the invited email as `{}` and only used `data.team_id` +
the accepting `user`. Any signed-in user in the tenancy who possessed
the 45-char code could join the team as themselves — the invitation was
not actually bound to the email it was addressed to.

**Attack scenarios that work without this fix**
- Forwarded invitation email (shared inbox, assistant inbox,
auto-forward rules).
- Screenshot of the invitation link pasted into Slack / Notion.
- Insider with server-access reading the email outbox (`GET
/api/latest/emails/outbox` returns rendered `html` +
`variables.teamInvitationLink`).
- Stale invite still sitting in spam after the invitee forwarded it
elsewhere.

**Fix.** The accept handler now requires that the accepting user owns
the invited email as a *verified* contact channel on their account.
Matches the invariant already used by the "list invitations for me"
endpoint
([`team-invitations/crud.tsx:41-66`](https://github.com/stack-auth/stack-auth/blob/dev/apps/backend/src/app/api/latest/team-invitations/crud.tsx#L41-L66)).
Rejections return a new `TEAM_INVITATION_EMAIL_MISMATCH` (403) error.

### 2. Verification-code handler TOCTOU


[`route-handlers/verification-code-handler.tsx`](https://github.com/stack-auth/stack-auth/blob/dev/apps/backend/src/route-handlers/verification-code-handler.tsx)
had a classic read-then-write TOCTOU:

```ts
const verificationCode = await prisma.verificationCode.findUnique(...);
if (verificationCode.usedAt) throw new KnownErrors.VerificationCodeAlreadyUsed();
// ... validation ...
await prisma.verificationCode.update({ data: { usedAt: new Date() } });  // unconditional
return await options.handler(...);
```

Five concurrent requests with the same code all pass the `if (usedAt)`
gate, all mark the code used, all run the post-handler. For OTP sign-in
the handler calls `createAuthTokens` which writes a fresh
`projectUserRefreshToken` row per call — so **one OTP → N refresh
tokens**. `auth/sessions/current` only revokes by `id: refreshTokenId`
and there is no bulk-revoke for passwordless users (only password change
in
[`users/crud.tsx:1210`](https://github.com/stack-auth/stack-auth/blob/dev/apps/backend/src/app/api/latest/users/crud.tsx#L1210)
does `deleteMany`). A phished OTP therefore becomes a
session-persistence primitive.

**Fix.** Replace the unconditional `update` with a conditional
`updateMany({ where: { …, usedAt: null } })` executed before
`options.handler`; if `count === 0` the race was already lost and we
throw `VERIFICATION_CODE_ALREADY_USED` (409). This also benefits MFA
sign-in and passkey sign-in, which share the same handler.

## Changes

| File | Change |
|---|---|
| `team-invitations/accept/verification-code-handler.tsx` | Require
verified contact channel matching `method.email` |
| `route-handlers/verification-code-handler.tsx` | Atomic `updateMany`
claim gated on `usedAt: null` |
| `stack-shared/src/known-errors.tsx` | New
`TeamInvitationEmailMismatch` (403) |
| `e2e/.../team-invitations.test.ts` | Two new tests (mismatch + happy
path) |
| `e2e/.../auth/otp/sign-in.test.ts` | One new test: 5 parallel
redemptions of one OTP → 1× 200 + 4× 409 |

## Test plan

- [x] `pnpm test run
apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts` —
27/27 pass
- [x] `pnpm test run
apps/e2e/tests/backend/endpoints/api/v1/auth/otp/sign-in.test.ts` —
12/12 (+ 4 pre-existing `it.todo`)
- [x] `pnpm test run
apps/e2e/tests/backend/endpoints/api/v1/auth/password` — 33/33 (+ 7
pre-existing todos)
- [x] `pnpm test run
apps/e2e/tests/backend/endpoints/api/v1/contact-channels` — 24/24
- [x] `pnpm test run
apps/e2e/tests/backend/endpoints/api/v1/auth/passkey
apps/e2e/tests/backend/endpoints/api/v1/auth/mfa` — 16/16
- [x] `pnpm --filter @stackframe/backend typecheck` — clean
- [x] `pnpm --filter @stackframe/backend lint` + `pnpm --filter
@stackframe/stack-shared lint` — clean

## Notes

- The broader "plaintext credentials in DB + Sentry logs every header"
finding from the same audit is **not** in this PR — a scrubber for
`Sentry.setContext` request headers + unit tests is prepared on a local
stash and will go out as a separate PR.
- The team-invitation fix does not require any config change; fresh
signups via the OTP / password flows that set `primary_email_verified:
true` during creation already land the user with a verified channel
matching the invited email, so the happy path is unaffected.

### Follow-up review (Codex)

Addressed in follow-up commit `954cddb`:
- **Finding 1 (High)**: mismatched invite acceptance was consuming the
invitation before rejecting. Moved the email-ownership check into the
pre-claim `options.validate` hook so a wrong-email attempt leaves
`usedAt` untouched and the real recipient can still redeem. New test
asserts this end-to-end.
- **Finding 3 (Medium)**: invitation stored `body.email` raw but contact
channels are stored via `normalizeEmail`, so case-varied invites (e.g.
`Alice@Example.com`) wouldn't match a `alice@example.com` channel.
`send-code` now normalizes on storage and `accept` normalizes on compare
for back-compat with already-issued invites. New test covers the
mixed-case path.
- **Finding 2 (partial)**: added `expiresAt > now` to the atomic claim
predicate for the boundary case where a code expires between the read
and the claim. The reviewer's broader point about the `attemptCount`
rate-limit check being non-atomic with its own increment **pre-dates
this PR** (it reads the in-memory `verificationCode.attemptCount` from
line 150, not a fresh read) and exists independently of the `usedAt`
TOCTOU I'm fixing here. Tracking that as a separate follow-up so this PR
stays scoped to the two originally-flagged issues.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Invite acceptance now requires the invitee’s verified, normalized
(case‑insensitive) email; mismatches return HTTP 403
(TEAM_INVITATION_EMAIL_MISMATCH).
* Client APIs now surface the new email-mismatch error alongside
verification errors.

* **Bug Fixes**
* OTP verification codes are now guarded against parallel double‑redeem
so only one request succeeds.

* **Tests**
* Added E2E tests for invitation email validation, non‑consuming
rejection, case‑insensitive matching, and OTP concurrency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
BilalG1 2026-05-04 17:13:03 -07:00 committed by GitHub
parent 9f79bfbe5c
commit c69a27017b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 264 additions and 18 deletions

View File

@ -1,5 +1,5 @@
import { teamMembershipsCrudHandlers } from "@/app/api/latest/team-memberships/crud";
import { sendEmailFromDefaultTemplate } from "@/lib/emails";
import { normalizeEmail, sendEmailFromDefaultTemplate } from "@/lib/emails";
import { getItemQuantityForCustomer } from "@/lib/payments/customer-data";
import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
import { getPrismaClientForTenancy } from "@/prisma-client";
@ -68,7 +68,34 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({
return codeObj;
},
async handler(tenancy, {}, data, body, user) {
// Runs before the code is claimed (marked used). Must live here, not in `handler`,
// so a mismatched attempt doesn't burn the invitation for the real recipient.
async validate(tenancy, { email: invitedEmail }, data, body, user) {
if (!user) throw new KnownErrors.UserAuthenticationRequired;
if (user.restricted_reason) {
throw new KnownErrors.TeamInvitationRestrictedUserNotAllowed(user.restricted_reason);
}
const prisma = await getPrismaClientForTenancy(tenancy);
// Contact channels are stored normalized; normalize the invited email to match.
// Legacy invitations created before send-code normalized may still hold a non-
// normalized `method.email`, so do it at compare time too.
const normalized = normalizeEmail(invitedEmail);
const invitedChannel = await prisma.contactChannel.findFirst({
where: {
tenancyId: tenancy.id,
projectUserId: user.id,
type: "EMAIL",
value: normalized,
isVerified: true,
},
select: { id: true },
});
if (!invitedChannel) {
throw new KnownErrors.TeamInvitationEmailMismatch();
}
},
async handler(tenancy, { email: invitedEmail }, data, body, user) {
if (!user) throw new KnownErrors.UserAuthenticationRequired;
if (user.restricted_reason) {
throw new KnownErrors.TeamInvitationRestrictedUserNotAllowed(user.restricted_reason);

View File

@ -1,3 +1,4 @@
import { normalizeEmail } from "@/lib/emails";
import { ensureUserTeamPermissionExists } from "@/lib/request-checks";
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
@ -48,13 +49,15 @@ export const POST = createSmartRouteHandler({
}
});
// Normalize the invited email so accept can compare against stored contact channels,
// which are themselves normalized on creation.
const codeObj = await teamInvitationCodeHandler.sendCode({
tenancy: auth.tenancy,
data: {
team_id: body.team_id,
},
method: {
email: body.email,
email: normalizeEmail(body.email),
},
callbackUrl: body.callback_url,
}, {});

View File

@ -190,19 +190,23 @@ export function createVerificationCodeHandler<
switch (handlerType) {
case 'post': {
await globalPrismaClient.verificationCode.update({
// Atomic claim — conditional WHERE closes the TOCTOU against the checks above.
const claimResult = await globalPrismaClient.verificationCode.updateMany({
where: {
projectId_branchId_code: {
projectId: auth.project.id,
branchId: auth.tenancy.branchId,
code,
},
projectId: auth.project.id,
branchId: auth.tenancy.branchId,
code,
type: options.type,
usedAt: null,
expiresAt: { gt: new Date() },
},
data: {
usedAt: new Date(),
},
});
if (claimResult.count === 0) {
throw new KnownErrors.VerificationCodeAlreadyUsed();
}
return await options.handler(auth.tenancy, validatedMethod, validatedData, requestBody as any, auth.user);
}

View File

@ -382,6 +382,33 @@ it("should use the send-sign-in-code request context when creating a new OTP use
expect(userResponse.body.country_code).toBe("CA");
});
it("should mint exactly one refresh token when the same code is redeemed in parallel", async ({ expect }) => {
// Guards the verification-code TOCTOU fix. Before the fix, the read-then-write pattern
// in verification-code-handler.tsx let N concurrent requests with the same OTP each pass
// the `if (usedAt) throw` check and each call createAuthTokens, minting N independent
// refresh tokens from one code. That enabled session-persistence: revoking one token
// didn't kill the others (no bulk-revoke exists for passwordless users short of a
// password change). The fix claims the code with a conditional updateMany and errors all
// losing racers with VERIFICATION_CODE_ALREADY_USED.
const sendSignInCodeRes = await Auth.Otp.sendSignInCode();
const signInCode = await Auth.Otp.getSignInCodeFromMailbox(sendSignInCodeRes.sendSignInCodeResponse.body.nonce);
const parallelCount = 5;
const responses = await Promise.all(
Array.from({ length: parallelCount }, () => niceBackendFetch("/api/v1/auth/otp/sign-in", {
method: "POST",
accessType: "client",
body: { code: signInCode },
})),
);
const successes = responses.filter(r => r.status === 200);
const alreadyUsed = responses.filter(r => r.status === 409 && (r.body as any)?.code === "VERIFICATION_CODE_ALREADY_USED");
expect(successes).toHaveLength(1);
expect(successes.length + alreadyUsed.length).toBe(parallelCount);
});
it.todo("should not sign in if e-mail's usedForAuth status has changed since sign-in code was sent");
it.todo("should not sign in if account's otpEnabled status has changed since sign-in code was sent");

View File

@ -386,7 +386,10 @@ it("allows team admins to be added when item quantity is increased", async ({ ex
for (let i = 0; i < mailboxes.length; i++) {
const mailbox = mailboxes[i];
backendContext.set({ mailbox: mailbox });
await Auth.fastSignUp();
await Auth.fastSignUp({
primary_email: mailbox.emailAddress,
primary_email_verified: true,
});
const invitationMessages = await mailbox.waitForMessagesWithSubject("join");
const acceptResponse = await niceBackendFetch("/api/v1/team-invitations/accept", {

View File

@ -1140,3 +1140,174 @@ it("can accept invitation by ID", async ({ expect }) => {
});
expect(listResponse.body.items).toHaveLength(0);
});
it("rejects accept when the signed-in user's email does not match the invited email", async ({ expect }) => {
// Without this check, anyone holding the 45-char code (forwarded email, insider with
// outbox access, leaked share) could accept the invitation as themselves. The handler
// must require that the accepting user actually owns the invited email.
await Project.createAndSwitch();
await Auth.fastSignUp();
const { teamId } = await Team.create();
const receiveMailbox = createMailbox();
backendContext.set({ userAuth: null });
await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: receiveMailbox.emailAddress,
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
const invitationMessages = await receiveMailbox.waitForMessagesWithSubject("join");
const code = invitationMessages
.findLast((m) => m.subject.includes("join"))
?.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9]+)/)?.[1];
expect(code).toBeTruthy();
// A different user (different verified email) signs in and tries to redeem the code.
// This simulates an attacker who obtained the invitation link out of band.
await Auth.fastSignUp();
const acceptResponse = await niceBackendFetch("/api/v1/team-invitations/accept", {
method: "POST",
accessType: "client",
body: { code },
});
expect(acceptResponse.status).toBe(403);
expect(acceptResponse.body.code).toBe("TEAM_INVITATION_EMAIL_MISMATCH");
// The attacker should not have been added to the team.
const teamsResponse = await niceBackendFetch(`/api/v1/teams?user_id=me`, {
accessType: "client",
method: "GET",
});
expect(teamsResponse.body.items.find((item: any) => item.id === teamId)).toBeUndefined();
});
it("does not burn the invitation when a wrong-email user attempts to accept", async ({ expect }) => {
// Regression test for the griefing vector a reviewer flagged: if the email-match
// check runs after the atomic claim, any attacker with the link can burn the code,
// leaving the real recipient with VERIFICATION_CODE_ALREADY_USED. The email check
// must run in the pre-claim validate hook so a mismatched attempt leaves usedAt=null.
await Project.createAndSwitch();
await Auth.fastSignUp();
const { teamId } = await Team.create();
const receiveMailbox = createMailbox();
backendContext.set({ userAuth: null });
await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: receiveMailbox.emailAddress,
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
const invitationMessages = await receiveMailbox.waitForMessagesWithSubject("join");
const code = invitationMessages
.findLast((m) => m.subject.includes("join"))
?.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9]+)/)?.[1];
expect(code).toBeTruthy();
// Attacker (different verified email) tries first — must be rejected with mismatch.
await Auth.fastSignUp();
const attackerResponse = await niceBackendFetch("/api/v1/team-invitations/accept", {
method: "POST",
accessType: "client",
body: { code },
});
expect(attackerResponse.status).toBe(403);
expect(attackerResponse.body.code).toBe("TEAM_INVITATION_EMAIL_MISMATCH");
// Legitimate recipient signs up and redeems the same code — must still succeed.
backendContext.set({ mailbox: receiveMailbox });
await Auth.fastSignUp({
primary_email: receiveMailbox.emailAddress,
primary_email_verified: true,
});
const legitimateResponse = await niceBackendFetch("/api/v1/team-invitations/accept", {
method: "POST",
accessType: "client",
body: { code },
});
expect(legitimateResponse.status).toBe(200);
const teamsResponse = await niceBackendFetch(`/api/v1/teams?user_id=me`, {
accessType: "client",
method: "GET",
});
expect(teamsResponse.body.items.find((item: any) => item.id === teamId)).toBeDefined();
});
it("accepts an invitation sent to a differently-cased email against a normalized channel", async ({ expect }) => {
// Regression test for the reviewer's Finding 3: contact channels are stored
// lowercased via normalizeEmail, but send-code used to store body.email raw.
// Sending to Alice@Example.com must match a channel stored as alice@example.com.
await Project.createAndSwitch();
await Auth.fastSignUp();
const { teamId } = await Team.create();
const receiveMailbox = createMailbox();
// Deliberately send to an uppercased variant of the recipient's address.
const uppercasedEmail = receiveMailbox.emailAddress.replace(/^(.)/, c => c.toUpperCase());
backendContext.set({ userAuth: null });
await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: uppercasedEmail,
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
backendContext.set({ mailbox: receiveMailbox });
await Auth.fastSignUp({
primary_email: receiveMailbox.emailAddress,
primary_email_verified: true,
});
await Team.acceptInvitation();
const teamsResponse = await niceBackendFetch(`/api/v1/teams?user_id=me`, {
accessType: "client",
method: "GET",
});
expect(teamsResponse.body.items.find((item: any) => item.id === teamId)).toBeDefined();
});
it("still allows the legitimate invitee with the matching verified email to accept", async ({ expect }) => {
// Complements the mismatch test: the new email-match check must not break the happy path.
await Project.createAndSwitch();
await Auth.fastSignUp();
const { teamId } = await Team.create();
const receiveMailbox = createMailbox();
backendContext.set({ userAuth: null });
await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: receiveMailbox.emailAddress,
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
backendContext.set({ mailbox: receiveMailbox });
await Auth.fastSignUp({
primary_email: receiveMailbox.emailAddress,
primary_email_verified: true,
});
await Team.acceptInvitation();
const teamsResponse = await niceBackendFetch(`/api/v1/teams?user_id=me`, {
accessType: "client",
method: "GET",
});
expect(teamsResponse.body.items.find((item: any) => item.id === teamId)).toBeDefined();
});

View File

@ -1080,7 +1080,7 @@ export class StackClientInterface {
code: string,
session: InternalSession,
type: T,
}): Promise<Result<T extends 'details' ? { team_display_name: string } : undefined, KnownErrors["VerificationCodeError"]>> {
}): Promise<Result<T extends 'details' ? { team_display_name: string } : undefined, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>> {
const res = await this.sendClientRequestAndCatchKnownError(
options.type === 'check' ?
"/team-invitations/accept/check-code" :
@ -1097,7 +1097,7 @@ export class StackClientInterface {
}),
},
options.session,
[KnownErrors.VerificationCodeError]
[KnownErrors.VerificationCodeError, KnownErrors.TeamInvitationEmailMismatch]
);
if (res.status === "error") {

View File

@ -1132,6 +1132,16 @@ const TeamInvitationRestrictedUserNotAllowed = createKnownErrorConstructor(
(json: any) => [json.restricted_reason ?? { type: "anonymous" }] as const,
);
const TeamInvitationEmailMismatch = createKnownErrorConstructor(
KnownError,
"TEAM_INVITATION_EMAIL_MISMATCH",
() => [
403,
"This team invitation was sent to a different email address. Sign in with the invited email, or add and verify that email on your account, then try again.",
] as const,
() => [] as const,
);
const EmailTemplateAlreadyExists = createKnownErrorConstructor(
KnownError,
@ -1942,6 +1952,7 @@ export const KnownErrors = {
TeamNotFound,
TeamMembershipNotFound,
TeamInvitationRestrictedUserNotAllowed,
TeamInvitationEmailMismatch,
EmailTemplateAlreadyExists,
OAuthConnectionNotConnectedToUser,
OAuthConnectionAlreadyConnectedToAnotherUser,

View File

@ -2540,7 +2540,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
return await this._interface.verifyPasswordResetCode(code);
}
async verifyTeamInvitationCode(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>> {
async verifyTeamInvitationCode(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>> {
return await this._interface.acceptTeamInvitation({
type: 'check',
code,
@ -2548,7 +2548,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
});
}
async acceptTeamInvitation(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>> {
async acceptTeamInvitation(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>> {
const result = await this._interface.acceptTeamInvitation({
type: 'use',
code,
@ -2562,7 +2562,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
}
}
async getTeamInvitationDetails(code: string): Promise<Result<{ teamDisplayName: string }, KnownErrors["VerificationCodeError"]>> {
async getTeamInvitationDetails(code: string): Promise<Result<{ teamDisplayName: string }, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>> {
const result = await this._interface.acceptTeamInvitation({
type: 'details',
code,

View File

@ -73,9 +73,9 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
sendMagicLinkEmail(email: string, options?: { callbackUrl?: string }): Promise<Result<{ nonce: string }, KnownErrors["RedirectUrlNotWhitelisted"] | KnownErrors["BotChallengeFailed"]>>,
resetPassword(options: { code: string, password: string }): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,
verifyPasswordResetCode(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,
verifyTeamInvitationCode(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,
acceptTeamInvitation(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,
getTeamInvitationDetails(code: string): Promise<Result<{ teamDisplayName: string }, KnownErrors["VerificationCodeError"]>>,
verifyTeamInvitationCode(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>>,
acceptTeamInvitation(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>>,
getTeamInvitationDetails(code: string): Promise<Result<{ teamDisplayName: string }, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>>,
verifyEmail(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,
signInWithMagicLink(code: string, options?: { noRedirect?: boolean }): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["InvalidTotpCode"]>>,
signInWithMfa(otp: string, code: string, options?: { noRedirect?: boolean }): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["InvalidTotpCode"]>>,