From 5dd657fe16b048f602480814d124abd2b180d963 Mon Sep 17 00:00:00 2001 From: Henry Fontanier Date: Thu, 4 Apr 2024 15:19:34 +0200 Subject: [PATCH] fix: prevent potential email abuse on invitations endpoint --- front/lib/invitations.ts | 5 +++ front/pages/api/w/[wId]/invitations/index.ts | 47 +++++++++++++++++++- front/pages/w/[wId]/members/index.tsx | 10 +++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 front/lib/invitations.ts diff --git a/front/lib/invitations.ts b/front/lib/invitations.ts new file mode 100644 index 0000000000000..0b37df9096986 --- /dev/null +++ b/front/lib/invitations.ts @@ -0,0 +1,5 @@ +// Maxmimum allowed number of unconsumed invitations per workspace. +export const MAX_UNCONSUMED_INVITATIONS = 50; +// If the user already received an invitation from this workspace and hasn't consumed it yet, we won't send another one +// before this cooldown period. +export const UNCONSUMED_INVITATION_COOLDOWN_PER_EMAIL_MS = 1000 * 60 * 60 * 24; // 1 day diff --git a/front/pages/api/w/[wId]/invitations/index.ts b/front/pages/api/w/[wId]/invitations/index.ts index 2c9a1a0081044..08ca4fe8f735e 100644 --- a/front/pages/api/w/[wId]/invitations/index.ts +++ b/front/pages/api/w/[wId]/invitations/index.ts @@ -7,6 +7,7 @@ import { isLeft } from "fp-ts/lib/Either"; import * as t from "io-ts"; import * as reporter from "io-ts-reporters"; import type { NextApiRequest, NextApiResponse } from "next"; +import { Op } from "sequelize"; import { sendWorkspaceInvitationEmail, @@ -15,6 +16,11 @@ import { import { getPendingInvitations } from "@app/lib/api/invitation"; import { getMembers } from "@app/lib/api/workspace"; import { Authenticator, getSession } from "@app/lib/auth"; +import { + MAX_UNCONSUMED_INVITATIONS, + UNCONSUMED_INVITATION_COOLDOWN_PER_EMAIL_MS, +} from "@app/lib/invitations"; +import { MembershipInvitation } from "@app/lib/models"; import { MembershipResource } from "@app/lib/resources/membership_resource"; import { isEmailValid } from "@app/lib/utils"; import logger from "@app/logger/logger"; @@ -152,7 +158,46 @@ async function handler( }); } const existingMembers = await getMembers(auth); - + const unconsumedInvitations = await MembershipInvitation.findAll({ + where: { + workspaceId: owner.id, + status: ["pending", "revoked"], + createdAt: { + [Op.gte]: new Date(new Date().setHours(0, 0, 0, 0)), + }, + }, + }); + if (unconsumedInvitations.length > MAX_UNCONSUMED_INVITATIONS) { + return apiError(req, res, { + status_code: 400, + api_error: { + type: "invalid_request_error", + message: `Too many unconsumed invitations. Please ask your members to consume their invitations before sending more.`, + }, + }); + } + const emailsWithRecentUncosumedInvitations = new Set( + unconsumedInvitations + .filter( + (i) => + i.createdAt.getTime() > + new Date().getTime() - UNCONSUMED_INVITATION_COOLDOWN_PER_EMAIL_MS + ) + .map((i) => i.inviteEmail.toLowerCase().trim()) + ); + if ( + invitationRequests.some((r) => + emailsWithRecentUncosumedInvitations.has(r.email.toLowerCase().trim()) + ) + ) { + return apiError(req, res, { + status_code: 400, + api_error: { + type: "invalid_request_error", + message: `Some of the emails have already received an invitation in the last 24 hours. Please wait before sending another invitation.`, + }, + }); + } const invitationResults = await Promise.all( invitationRequests.map(async ({ email, role }) => { if (existingMembers.find((m) => m.email === email)) { diff --git a/front/pages/w/[wId]/members/index.tsx b/front/pages/w/[wId]/members/index.tsx index 81d8462c2c8b7..29b314b231f2c 100644 --- a/front/pages/w/[wId]/members/index.tsx +++ b/front/pages/w/[wId]/members/index.tsx @@ -53,6 +53,7 @@ import { PRO_PLAN_29_COST, } from "@app/lib/client/subscription"; import { withDefaultUserAuthRequirements } from "@app/lib/iam/session"; +import { MAX_UNCONSUMED_INVITATIONS } from "@app/lib/invitations"; import { isUpgraded, PRO_PLAN_SEAT_29_CODE } from "@app/lib/plans/plan_codes"; import { useMembers, useWorkspaceInvitations } from "@app/lib/swr"; import { classNames, isEmailValid } from "@app/lib/utils"; @@ -484,6 +485,15 @@ function InviteEmailModal({ async function handleSendInvitations( inviteEmailsList: string[] ): Promise { + if (inviteEmailsList.length > MAX_UNCONSUMED_INVITATIONS) { + sendNotification({ + type: "error", + title: "Too many invitations", + description: `Your cannot send more than ${MAX_UNCONSUMED_INVITATIONS} invitations.`, + }); + return; + } + const invitesByCase = { activeSameRole: members.filter((m) => inviteEmailsList.find(