Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) {
Comment thread
BilalG1 marked this conversation as resolved.
if (!user) throw new KnownErrors.UserAuthenticationRequired;
if (user.restricted_reason) {
throw new KnownErrors.TeamInvitationRestrictedUserNotAllowed(user.restricted_reason);
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Comment thread
BilalG1 marked this conversation as resolved.
},
callbackUrl: body.callback_url,
}, {});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
171 changes: 0 additions & 171 deletions apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Loading