diff --git a/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx b/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx index f303e86579..7d95292fc6 100644 --- a/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx +++ b/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx @@ -1,5 +1,5 @@ import { teamMembershipsCrudHandlers } from "@/app/api/latest/team-memberships/crud"; -import { normalizeEmail, sendEmailFromDefaultTemplate } from "@/lib/emails"; +import { sendEmailFromDefaultTemplate } from "@/lib/emails"; import { getItemQuantityForCustomer } from "@/lib/payments/customer-data"; import { arePlanLimitsEnforced } from "@/lib/plan-entitlements"; import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; @@ -69,34 +69,7 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({ return codeObj; }, - // 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) { + async handler(tenancy, {}, data, body, user) { if (!user) throw new KnownErrors.UserAuthenticationRequired; if (user.restricted_reason) { throw new KnownErrors.TeamInvitationRestrictedUserNotAllowed(user.restricted_reason); diff --git a/apps/backend/src/app/api/latest/team-invitations/send-code/route.tsx b/apps/backend/src/app/api/latest/team-invitations/send-code/route.tsx index b5ead2927f..fddea5c7c1 100644 --- a/apps/backend/src/app/api/latest/team-invitations/send-code/route.tsx +++ b/apps/backend/src/app/api/latest/team-invitations/send-code/route.tsx @@ -1,4 +1,3 @@ -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"; @@ -49,15 +48,13 @@ 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: normalizeEmail(body.email), + email: body.email, }, callbackUrl: body.callback_url, }, {}); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts index f7bbddfbea..7aa811e62c 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts @@ -386,10 +386,7 @@ 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({ - primary_email: mailbox.emailAddress, - primary_email_verified: true, - }); + await Auth.fastSignUp(); const invitationMessages = await mailbox.waitForMessagesWithSubject("join"); const acceptResponse = await niceBackendFetch("/api/v1/team-invitations/accept", { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts index 3ec4103345..1ce357a06f 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts @@ -1140,174 +1140,3 @@ 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(); -});