feat: Prevent watchlisted entities from creating bookings (#19107)

* Add `searchForAllBlockedRecords` to watchlist repository

* Create `checkIfUsersAreBlocked`

* Call `checkIfUsersAreBlocked` in `loadAndValidateUsers`

* Draft test

* Add tests

* Type fix

* Type fix
This commit is contained in:
Joe Au-Yeung
2025-03-28 01:11:10 +00:00
committed by GitHub
parent fc8ff2d4ae
commit c0aa97178c
8 changed files with 366 additions and 1 deletions
@@ -1433,6 +1433,7 @@ export function getOrganizer({
smsLockState,
completedOnboarding,
username,
locked,
}: {
name: string;
email: string;
@@ -1449,6 +1450,7 @@ export function getOrganizer({
smsLockState?: SMSLockState;
completedOnboarding?: boolean;
username?: string;
locked?: boolean;
}) {
username = username ?? TestData.users.example.username;
return {
@@ -1469,6 +1471,7 @@ export function getOrganizer({
metadata,
smsLockState,
completedOnboarding,
locked,
};
}
@@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client";
import type { IncomingMessage } from "http";
import type { Logger } from "tslog";
import { checkIfUsersAreBlocked } from "@calcom/features/watchlist/operations/check-if-users-are-blocked.controller";
import { findQualifiedHostsWithDelegationCredentials } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials";
import { enrichUsersWithDelegationCredentials } from "@calcom/lib/delegationCredential/server";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
@@ -120,6 +121,12 @@ export async function loadAndValidateUsers({
}
if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
// Determine if users are locked
const containsBlockedUser = await checkIfUsersAreBlocked(users);
if (containsBlockedUser) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
// map fixed users
users = users.map((user) => ({
...user,
@@ -54,10 +54,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { describe, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { createWatchlistEntry } from "@calcom/features/watchlist/lib/testUtils";
import { WEBSITE_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { resetTestEmails } from "@calcom/lib/testEmails";
import { CreationSource } from "@calcom/prisma/enums";
import { CreationSource, WatchlistSeverity, WatchlistType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
@@ -3333,5 +3334,248 @@ describe("handleNewBooking", () => {
});
});
describe("Blocked users", () => {
test("user is locked", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "user@lockeduser.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
locked: true,
});
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const scenarioData = getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
},
],
organizer,
});
await createBookingScenario(scenarioData);
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
await expect(async () => await handleNewBooking(req)).rejects.toThrowError("eventTypeUser.notFound");
});
test("username is critical in watchlist", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
username: "spammer",
email: "spam@spammer.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
locked: true,
});
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const scenarioData = getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
},
],
organizer,
});
await createBookingScenario(scenarioData);
await createWatchlistEntry({
type: WatchlistType.USERNAME,
severity: WatchlistSeverity.CRITICAL,
value: organizer.username,
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
await expect(async () => await handleNewBooking(req)).rejects.toThrowError("eventTypeUser.notFound");
});
test("domain is critical in watchlist", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
username: "spammer",
email: "spam@spammer.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
locked: true,
});
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const scenarioData = getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
},
],
organizer,
});
await createBookingScenario(scenarioData);
await createWatchlistEntry({
type: WatchlistType.DOMAIN,
severity: WatchlistSeverity.CRITICAL,
value: "spammer.com",
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
await expect(async () => await handleNewBooking(req)).rejects.toThrowError("eventTypeUser.notFound");
});
test("domain is critical in watchlist", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
username: "spammer",
email: "spam@spammer.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
locked: true,
});
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const scenarioData = getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
},
],
organizer,
});
await createBookingScenario(scenarioData);
await createWatchlistEntry({
type: WatchlistType.EMAIL,
severity: WatchlistSeverity.CRITICAL,
value: "spam@spammer.com",
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
await expect(async () => await handleNewBooking(req)).rejects.toThrowError("eventTypeUser.notFound");
});
});
test.todo("CRM calendar events creation verification");
});
@@ -0,0 +1,18 @@
import prismock from "../../../../tests/libs/__mocks__/prisma";
import type { WatchlistSeverity, WatchlistType } from "@calcom/prisma/enums";
interface WatchlistInput {
severity: WatchlistSeverity;
type: WatchlistType;
value: string;
}
export const createWatchlistEntry = async (input: WatchlistInput) => {
await prismock.watchlist.create({
data: {
...input,
createdById: 0,
},
});
};
@@ -0,0 +1,35 @@
import { startSpan } from "@sentry/nextjs";
import { WatchlistRepository } from "../watchlist.repository";
function presenter(containsBlockedUser: boolean) {
return startSpan({ name: "checkIfUsersAreBlocked Presenter", op: "serialize" }, () => {
return !!containsBlockedUser;
});
}
export async function checkIfUsersAreBlocked(
users: { email: string; username: string | null; locked: boolean }[]
): Promise<ReturnType<typeof presenter>> {
const usernamesToCheck = [],
emailsToCheck = [],
domainsToCheck = [];
for (const user of users) {
// If any user is locked, then return true
if (user.locked) return true;
const emailDomain = user.email.split("@")[1];
if (user.username) usernamesToCheck.push(user.username);
emailsToCheck.push(user.email);
domainsToCheck.push(emailDomain);
}
const watchlistRepository = new WatchlistRepository();
const blockedRecords = await watchlistRepository.searchForAllBlockedRecords({
usernames: usernamesToCheck,
emails: emailsToCheck,
domains: domainsToCheck,
});
return presenter(blockedRecords.length > 0);
}
@@ -39,4 +39,60 @@ export class WatchlistRepository implements IWatchlistRepository {
throw err;
}
}
/** Returns a boolean if any of the users passed are blocked */
async searchForAllBlockedRecords({
usernames,
emails,
domains,
}: {
usernames: string[];
emails: string[];
domains: string[];
}) {
try {
const blockedRecords = await db.watchlist.findMany({
where: {
severity: WatchlistSeverity.CRITICAL,
OR: [
...(usernames.length > 0
? [
{
type: WatchlistType.USERNAME,
value: {
in: usernames,
},
},
]
: []),
...(emails.length > 0
? [
{
type: WatchlistType.EMAIL,
value: {
in: emails,
},
},
]
: []),
...(domains.length > 0
? [
{
type: WatchlistType.DOMAIN,
value: {
in: domains,
},
},
]
: []),
],
},
});
return blockedRecords;
} catch (err) {
const captureException = (await import("@sentry/nextjs")).captureException;
captureException(err);
throw err;
}
}
}
+1
View File
@@ -55,6 +55,7 @@ const user: User & { credentials: CredentialPayload[] } = {
allowDynamicBooking: true,
timeFormat: 12,
travelSchedules: [],
locked: false,
};
const customInputs: CustomInputSchema[] = [];
+1
View File
@@ -52,6 +52,7 @@ export const userSelect = Prisma.validator<Prisma.UserArgs>()({
brandColor: true,
darkBrandColor: true,
metadata: true,
locked: true,
...availabilityUserSelect,
},
});