From 28622c708771c91f68275eadf544f9edca2a871d Mon Sep 17 00:00:00 2001 From: Henry Fontanier Date: Thu, 4 Apr 2024 15:19:34 +0200 Subject: [PATCH 1/4] fix: prevent potential email abuse on invitations endpoint --- front/lib/invitations.ts | 5 ++ front/pages/api/w/[wId]/invitations/index.ts | 49 +++++++++++++++++++- front/pages/w/[wId]/members/index.tsx | 10 ++++ 3 files changed, 63 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 000000000000..0b37df909698 --- /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 2c9a1a008104..c0b3b13c29b9 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,48 @@ 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 emailsWithRecentUnconsumedInvitations = 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) => + emailsWithRecentUnconsumedInvitations.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 81d8462c2c8b..29b314b231f2 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( From d172fb10095f42273dbc034b8b24f6058689eb05 Mon Sep 17 00:00:00 2001 From: Henry Fontanier Date: Thu, 4 Apr 2024 15:37:13 +0200 Subject: [PATCH 2/4] nit --- front/pages/api/w/[wId]/invitations/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/front/pages/api/w/[wId]/invitations/index.ts b/front/pages/api/w/[wId]/invitations/index.ts index c0b3b13c29b9..e523a3e7b591 100644 --- a/front/pages/api/w/[wId]/invitations/index.ts +++ b/front/pages/api/w/[wId]/invitations/index.ts @@ -158,12 +158,14 @@ async function handler( }); } const existingMembers = await getMembers(auth); + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); const unconsumedInvitations = await MembershipInvitation.findAll({ where: { workspaceId: owner.id, status: ["pending", "revoked"], createdAt: { - [Op.gte]: new Date(new Date().setHours(0, 0, 0, 0)), + [Op.gte]: startOfDay, }, }, }); From e120d41d4796ad1c5fa9702093383715f1152310 Mon Sep 17 00:00:00 2001 From: Henry Fontanier Date: Thu, 4 Apr 2024 15:46:34 +0200 Subject: [PATCH 3/4] simplify --- front/lib/api/invitation.ts | 38 ++++++++++++++++++++ front/lib/invitations.ts | 7 ++-- front/pages/api/w/[wId]/invitations/index.ts | 35 ++++++------------ front/pages/w/[wId]/members/index.tsx | 8 +++-- 4 files changed, 55 insertions(+), 33 deletions(-) diff --git a/front/lib/api/invitation.ts b/front/lib/api/invitation.ts index 45f15be03967..53cce9f78bcf 100644 --- a/front/lib/api/invitation.ts +++ b/front/lib/api/invitation.ts @@ -7,6 +7,7 @@ import type { import { Err, sanitizeString } from "@dust-tt/types"; import sgMail from "@sendgrid/mail"; import { sign } from "jsonwebtoken"; +import { Op } from "sequelize"; import config from "@app/lib/api/config"; import type { Authenticator } from "@app/lib/auth"; @@ -178,3 +179,40 @@ export async function getPendingInvitations( }; }); } + +/** + * Returns the pending or revoked inviations that were created today + * associated with the authenticator's owner workspace. + * @param auth Authenticator + * @returns MenbershipInvitation[] members of the workspace + */ + +export async function getRecentPendingOrRevokedInvitations( + auth: Authenticator +): Promise { + const owner = auth.workspace(); + if (!owner) { + return []; + } + const oneDayAgo = new Date(); + oneDayAgo.setDate(oneDayAgo.getDate() - 1); + const invitations = await MembershipInvitation.findAll({ + where: { + workspaceId: owner.id, + status: ["pending", "revoked"], + createdAt: { + [Op.gt]: oneDayAgo, + }, + }, + }); + + return invitations.map((i) => { + return { + sId: i.sId, + id: i.id, + status: i.status, + inviteEmail: i.inviteEmail, + initialRole: i.initialRole, + }; + }); +} diff --git a/front/lib/invitations.ts b/front/lib/invitations.ts index 0b37df909698..2b72b9c39261 100644 --- a/front/lib/invitations.ts +++ b/front/lib/invitations.ts @@ -1,5 +1,2 @@ -// 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 +// Maxmimum allowed number of unconsumed invitations per workspace per day. +export const MAX_UNCONSUMED_INVITATIONS_PER_WORKSPACE_PER_DAY = 50; diff --git a/front/pages/api/w/[wId]/invitations/index.ts b/front/pages/api/w/[wId]/invitations/index.ts index e523a3e7b591..dc07d75ab0bb 100644 --- a/front/pages/api/w/[wId]/invitations/index.ts +++ b/front/pages/api/w/[wId]/invitations/index.ts @@ -7,20 +7,16 @@ 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 { + getRecentPendingOrRevokedInvitations, sendWorkspaceInvitationEmail, updateOrCreateInvitation, } from "@app/lib/api/invitation"; 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 { MAX_UNCONSUMED_INVITATIONS_PER_WORKSPACE_PER_DAY } from "@app/lib/invitations"; import { MembershipResource } from "@app/lib/resources/membership_resource"; import { isEmailValid } from "@app/lib/utils"; import logger from "@app/logger/logger"; @@ -158,18 +154,13 @@ async function handler( }); } const existingMembers = await getMembers(auth); - const startOfDay = new Date(); - startOfDay.setHours(0, 0, 0, 0); - const unconsumedInvitations = await MembershipInvitation.findAll({ - where: { - workspaceId: owner.id, - status: ["pending", "revoked"], - createdAt: { - [Op.gte]: startOfDay, - }, - }, - }); - if (unconsumedInvitations.length > MAX_UNCONSUMED_INVITATIONS) { + const unconsumedInvitations = await getRecentPendingOrRevokedInvitations( + auth + ); + if ( + unconsumedInvitations.length > + MAX_UNCONSUMED_INVITATIONS_PER_WORKSPACE_PER_DAY + ) { return apiError(req, res, { status_code: 400, api_error: { @@ -179,13 +170,7 @@ async function handler( }); } const emailsWithRecentUnconsumedInvitations = new Set( - unconsumedInvitations - .filter( - (i) => - i.createdAt.getTime() > - new Date().getTime() - UNCONSUMED_INVITATION_COOLDOWN_PER_EMAIL_MS - ) - .map((i) => i.inviteEmail.toLowerCase().trim()) + unconsumedInvitations.map((i) => i.inviteEmail.toLowerCase().trim()) ); if ( invitationRequests.some((r) => diff --git a/front/pages/w/[wId]/members/index.tsx b/front/pages/w/[wId]/members/index.tsx index 29b314b231f2..77a07cebfe8e 100644 --- a/front/pages/w/[wId]/members/index.tsx +++ b/front/pages/w/[wId]/members/index.tsx @@ -53,7 +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 { MAX_UNCONSUMED_INVITATIONS_PER_WORKSPACE_PER_DAY } 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"; @@ -485,11 +485,13 @@ function InviteEmailModal({ async function handleSendInvitations( inviteEmailsList: string[] ): Promise { - if (inviteEmailsList.length > MAX_UNCONSUMED_INVITATIONS) { + if ( + inviteEmailsList.length > MAX_UNCONSUMED_INVITATIONS_PER_WORKSPACE_PER_DAY + ) { sendNotification({ type: "error", title: "Too many invitations", - description: `Your cannot send more than ${MAX_UNCONSUMED_INVITATIONS} invitations.`, + description: `Your cannot send more than ${MAX_UNCONSUMED_INVITATIONS_PER_WORKSPACE_PER_DAY} invitations per day.`, }); return; } From 23192c92ce9fa6ca452aaa966102bddb00dc394e Mon Sep 17 00:00:00 2001 From: Henry Fontanier Date: Thu, 4 Apr 2024 15:55:35 +0200 Subject: [PATCH 4/4] nits --- front/lib/api/invitation.ts | 10 ++++++++++ front/pages/api/w/[wId]/invitations/index.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/front/lib/api/invitation.ts b/front/lib/api/invitation.ts index 53cce9f78bcf..477be1cf15bd 100644 --- a/front/lib/api/invitation.ts +++ b/front/lib/api/invitation.ts @@ -161,6 +161,11 @@ export async function getPendingInvitations( if (!owner) { return []; } + if (!auth.isAdmin()) { + throw new Error( + "Only users that are `admins` for the current workspace can see membership invitations or modify it." + ); + } const invitations = await MembershipInvitation.findAll({ where: { @@ -194,6 +199,11 @@ export async function getRecentPendingOrRevokedInvitations( if (!owner) { return []; } + if (!auth.isAdmin()) { + throw new Error( + "Only users that are `admins` for the current workspace can see membership invitations or modify it." + ); + } const oneDayAgo = new Date(); oneDayAgo.setDate(oneDayAgo.getDate() - 1); const invitations = await MembershipInvitation.findAll({ diff --git a/front/pages/api/w/[wId]/invitations/index.ts b/front/pages/api/w/[wId]/invitations/index.ts index dc07d75ab0bb..bf325b40d4f2 100644 --- a/front/pages/api/w/[wId]/invitations/index.ts +++ b/front/pages/api/w/[wId]/invitations/index.ts @@ -158,7 +158,7 @@ async function handler( auth ); if ( - unconsumedInvitations.length > + unconsumedInvitations.length >= MAX_UNCONSUMED_INVITATIONS_PER_WORKSPACE_PER_DAY ) { return apiError(req, res, {