feat: Add async spam check and decoy booking response (#24326)

* feat: Add async spam check integration and decoy booking response

- Integrate SpamCheckService with handleNewBooking workflow
- Implement parallel spam check execution for minimal performance impact
- Add decoy booking response with localStorage-based success page
- Extract organization ID from event type for org-specific blocking
- Add comprehensive test coverage for spam detection scenarios
- Create reusable components for booking success cards
- Implement fail-open behavior to never block legitimate bookings

This builds on the spam blocker DI infrastructure from PR #24040 by
adding the actual integration into the booking flow and implementing
the decoy response mechanism to avoid revealing spam detection to
malicious actors.

Related: #24040
Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Do checks in paralle

* Fix leaking host name in title

* Reduce expoiry time localstorage

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Hariom Balhara
2025-10-14 11:36:51 +00:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 09ee39a3d8
commit a0e5e03903
13 changed files with 1409 additions and 34 deletions
@@ -0,0 +1,50 @@
"use client";
import { useParams } from "next/navigation";
import dayjs from "@calcom/dayjs";
import { DecoyBookingSuccessCard } from "@calcom/features/bookings/Booker/components/DecoyBookingSuccessCard";
import { useDecoyBooking } from "@calcom/features/bookings/Booker/components/hooks/useDecoyBooking";
export default function BookingSuccessful() {
const params = useParams();
const uid = params?.uid as string;
const bookingData = useDecoyBooking(uid);
if (!bookingData) {
return null;
}
const { booking } = bookingData;
// Format the data for the BookingSuccessCard
const startTime = booking.startTime ? dayjs(booking.startTime) : null;
const endTime = booking.endTime ? dayjs(booking.endTime) : null;
const timeZone = booking.booker?.timeZone || booking.host?.timeZone || dayjs.tz.guess();
const formattedDate = startTime ? startTime.tz(timeZone).format("dddd, MMMM D, YYYY") : "";
const formattedTime = startTime ? startTime.tz(timeZone).format("h:mm A") : "";
const formattedEndTime = endTime ? endTime.tz(timeZone).format("h:mm A") : "";
const formattedTimeZone = timeZone;
const hostName = booking.host?.name || null;
const hostEmail = null; // Email not stored for spam decoy bookings
const attendeeName = booking.booker?.name || null;
const attendeeEmail = booking.booker?.email || null;
return (
<DecoyBookingSuccessCard
title={booking.title || "Booking"}
formattedDate={formattedDate}
formattedTime={formattedTime}
endTime={formattedEndTime}
formattedTimeZone={formattedTimeZone}
hostName={hostName}
hostEmail={hostEmail}
attendeeName={attendeeName}
attendeeEmail={attendeeEmail}
location={booking.location || null}
/>
);
}
@@ -2001,6 +2001,10 @@ export async function mockCalendarToHaveNoBusySlots(
return await mockCalendar(metadataLookupKey, { ...calendarData, busySlots: [] });
}
export async function mockCalendarToCrashOnGetAvailability(metadataLookupKey: keyof typeof appStoreMetadata) {
return await mockCalendar(metadataLookupKey, { getAvailabilityCrash: true });
}
export async function mockCalendarToCrashOnCreateEvent(metadataLookupKey: keyof typeof appStoreMetadata) {
return await mockCalendar(metadataLookupKey, { creationCrash: true });
}
@@ -0,0 +1,115 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/components/badge";
import { Icon } from "@calcom/ui/components/icon";
export interface DecoyBookingSuccessCardProps {
title: string;
formattedDate: string;
formattedTime: string;
endTime: string;
formattedTimeZone: string;
hostName: string | null;
hostEmail: string | null;
attendeeName: string | null;
attendeeEmail: string | null;
location: string | null;
}
export function DecoyBookingSuccessCard({
title,
formattedDate,
formattedTime,
endTime,
formattedTimeZone,
hostName,
hostEmail,
attendeeName,
attendeeEmail,
location,
}: DecoyBookingSuccessCardProps) {
const { t } = useLocale();
return (
<div className="h-screen">
<main className="mx-auto max-w-3xl">
<div className="overflow-y-auto">
<div className="flex items-end justify-center px-4 pb-20 pt-4 text-center sm:flex sm:p-0">
<div className="main inset-0 my-4 flex flex-col transition-opacity sm:my-0" aria-hidden="true">
<div
className="bg-default dark:bg-muted border-booker border-booker-width inline-block transform overflow-hidden rounded-lg px-8 pb-4 pt-5 text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-xl sm:py-8 sm:align-middle"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
<div>
<div className="bg-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<Icon name="check" className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
</div>
<div className="mb-8 mt-6 text-center last:mb-0">
<h3 className="text-emphasis text-2xl font-semibold leading-6" id="modal-headline">
{t("meeting_is_scheduled")}
</h3>
<div className="mt-3">
<p className="text-default">{t("emailed_you_and_any_other_attendees")}</p>
</div>
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left rtl:text-right sm:gap-x-0">
<div className="font-medium">{t("what")}</div>
<div className="col-span-2 mb-6 last:mb-0">{title}</div>
{formattedDate && (
<>
<div className="font-medium">{t("when")}</div>
<div className="col-span-2 mb-6 last:mb-0">
{formattedDate}
{formattedTime && (
<>
<br />
{formattedTime}
{endTime && ` - ${endTime}`}
{formattedTimeZone && (
<span className="text-bookinglight"> ({formattedTimeZone})</span>
)}
</>
)}
</div>
</>
)}
<div className="font-medium">{t("who")}</div>
<div className="col-span-2 last:mb-0">
{hostName && (
<div className="mb-3">
<div>
<span className="mr-2">{hostName}</span>
<Badge variant="blue">{t("Host")}</Badge>
</div>
{hostEmail && <p className="text-default">{hostEmail}</p>}
</div>
)}
{attendeeName && (
<div className="mb-3 last:mb-0">
<p>{attendeeName}</p>
{attendeeEmail && <p>{attendeeEmail}</p>}
</div>
)}
</div>
{location && (
<>
<div className="mt-3 font-medium">{t("where")}</div>
<div className="col-span-2 mt-3">{t("web_conferencing_details_to_follow")}</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
);
}
@@ -11,11 +11,14 @@ import dayjs from "@calcom/dayjs";
import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import { createBooking, createRecurringBooking, createInstantBooking } from "@calcom/features/bookings/lib";
import { storeDecoyBooking } from "@calcom/features/bookings/lib/client/decoyBookingStore";
import { createBooking } from "@calcom/features/bookings/lib/create-booking";
import { createInstantBooking } from "@calcom/features/bookings/lib/create-instant-booking";
import { createRecurringBooking } from "@calcom/features/bookings/lib/create-recurring-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import type { BookerEvent } from "@calcom/features/bookings/types";
import { getFullName } from "@calcom/features/form-builder/utils";
import { useBookingSuccessRedirect } from "@calcom/features/bookings/lib/bookingSuccessRedirect";
import { useBookingSuccessRedirect } from "../../../lib/bookingSuccessRedirect";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { localStorage } from "@calcom/lib/webstorage";
@@ -165,13 +168,7 @@ const storeInLocalStorage = ({
localStorage.setItem(STORAGE_KEY, value);
};
export const useBookings = ({
event,
hashedLink,
bookingForm,
metadata,
isBookingDryRun,
}: IUseBookings) => {
export const useBookings = ({ event, hashedLink, bookingForm, metadata, isBookingDryRun }: IUseBookings) => {
const router = useRouter();
const eventSlug = useBookerStoreContext((state) => state.eventSlug);
const eventTypeId = useBookerStoreContext((state) => state.eventId);
@@ -248,7 +245,7 @@ export const useBookings = ({
} else {
showToast(t("something_went_wrong_on_our_end"), "error");
}
} catch (err) {
} catch {
showToast(t("something_went_wrong_on_our_end"), "error");
}
},
@@ -259,12 +256,6 @@ export const useBookings = ({
mutationFn: createBooking,
onSuccess: (booking) => {
if (booking.isDryRun) {
const validDuration = event.data?.isDynamic
? duration || event.data?.length
: duration && event.data?.metadata?.multipleDuration?.includes(duration)
? duration
: event.data?.length;
if (isRescheduling) {
sdkActionManager?.fire(
"dryRunRescheduleBookingSuccessfulV2",
@@ -286,6 +277,28 @@ export const useBookings = ({
router.push("/booking/dry-run-successful");
return;
}
if ("isShortCircuitedBooking" in booking && booking.isShortCircuitedBooking) {
if (!booking.uid) {
console.error("Decoy booking missing uid");
return;
}
const bookingData = {
uid: booking.uid,
title: booking.title ?? null,
startTime: booking.startTime,
endTime: booking.endTime,
booker: booking.attendees?.[0] ?? null,
host: booking.user ?? null,
location: booking.location ?? null,
};
storeDecoyBooking(bookingData);
router.push(`/booking-successful/${booking.uid}`);
return;
}
const { uid, paymentUid } = booking;
const fullName = getFullName(bookingForm.getValues("responses.name"));
@@ -380,9 +393,10 @@ export const useBookings = ({
: event?.data?.forwardParamsSuccessRedirect,
});
},
onError: (err, _, ctx) => {
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- It is only called when user takes an action in embed
bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" });
onError: (err) => {
if (bookerFormErrorRef?.current) {
bookerFormErrorRef.current.scrollIntoView({ behavior: "smooth" });
}
const error = err as Error & {
data: { rescheduleUid: string; startTime: string; attendees: string[] };
@@ -414,10 +428,11 @@ export const useBookings = ({
updateQueryParam("bookingId", responseData.bookingId);
setExpiryTime(responseData.expires);
},
onError: (err, _, ctx) => {
onError: (err) => {
console.error("Error creating instant booking", err);
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- It is only called when user takes an action in embed
bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" });
if (bookerFormErrorRef?.current) {
bookerFormErrorRef.current.scrollIntoView({ behavior: "smooth" });
}
},
});
@@ -513,15 +528,10 @@ export const useBookings = ({
bookingForm,
hashedLink,
metadata,
handleInstantBooking: (
variables: Parameters<typeof createInstantBookingMutation.mutate>[0]
) => {
handleInstantBooking: (variables: Parameters<typeof createInstantBookingMutation.mutate>[0]) => {
const remaining = getInstantCooldownRemainingMs(eventTypeId);
if (remaining > 0) {
showToast(
t("please_try_again_later_or_book_another_slot"),
"error"
);
showToast(t("please_try_again_later_or_book_another_slot"), "error");
return;
}
createInstantBookingMutation.mutate(variables);
@@ -0,0 +1,31 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { getDecoyBooking } from "@calcom/features/bookings/lib/client/decoyBookingStore";
import type { DecoyBookingData } from "@calcom/features/bookings/lib/client/decoyBookingStore";
/**
* Hook to retrieve and manage decoy booking data from localStorage
* @param uid - The booking uid
* @returns The booking data or null if not found/expired
*/
export function useDecoyBooking(uid: string) {
const router = useRouter();
const [bookingData, setBookingData] = useState<DecoyBookingData | null>(null);
useEffect(() => {
const data = getDecoyBooking(uid);
if (!data) {
router.push("/404");
return;
}
setBookingData(data);
}, [uid, router]);
return bookingData;
}
@@ -0,0 +1,114 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/components/badge";
import { Icon } from "@calcom/ui/components/icon";
export interface BookingSuccessCardProps {
title: string;
formattedDate: string;
formattedTime: string;
endTime: string;
formattedTimeZone: string;
hostName: string | null;
hostEmail: string | null;
attendeeName: string | null;
attendeeEmail: string | null;
location: string | null;
}
export function BookingSuccessCard({
title,
formattedDate,
formattedTime,
endTime,
formattedTimeZone,
hostName,
hostEmail,
attendeeName,
attendeeEmail,
location,
}: BookingSuccessCardProps) {
const { t } = useLocale();
return (
<div className="h-screen">
<main className="mx-auto max-w-3xl">
<div className="overflow-y-auto">
<div className="flex items-end justify-center px-4 pb-20 pt-4 text-center sm:flex sm:p-0">
<div className="main inset-0 my-4 flex flex-col transition-opacity sm:my-0" aria-hidden="true">
<div
className="bg-default dark:bg-muted border-booker border-booker-width inline-block transform overflow-hidden rounded-lg px-8 pb-4 pt-5 text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-xl sm:py-8 sm:align-middle"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
<div>
<div className="bg-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<Icon name="check" className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
</div>
<div className="mb-8 mt-6 text-center last:mb-0">
<h3 className="text-emphasis text-2xl font-semibold leading-6" id="modal-headline">
{t("meeting_is_scheduled")}
</h3>
<div className="mt-3">
<p className="text-default">{t("emailed_you_and_any_other_attendees")}</p>
</div>
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left rtl:text-right sm:gap-x-0">
<div className="font-medium">{t("what")}</div>
<div className="col-span-2 mb-6 last:mb-0">{title}</div>
{formattedDate && (
<>
<div className="font-medium">{t("when")}</div>
<div className="col-span-2 mb-6 last:mb-0">
{formattedDate}
{formattedTime && (
<>
<br />
{formattedTime}
{endTime && ` - ${endTime}`}
{formattedTimeZone && (
<span className="text-bookinglight"> ({formattedTimeZone})</span>
)}
</>
)}
</div>
</>
)}
<div className="font-medium">{t("who")}</div>
<div className="col-span-2 last:mb-0">
{hostName && (
<div className="mb-3">
<div>
<span className="mr-2">{hostName}</span>
<Badge variant="blue">{t("Host")}</Badge>
</div>
{hostEmail && <p className="text-default">{hostEmail}</p>}
</div>
)}
{attendeeName && (
<div className="mb-3 last:mb-0">
<p>{attendeeName}</p>
{attendeeEmail && <p>{attendeeEmail}</p>}
</div>
)}
</div>
{location && (
<>
<div className="mt-3 font-medium">{t("where")}</div>
<div className="col-span-2 mt-3">{t("web_conferencing_details_to_follow")}</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
);
}
@@ -0,0 +1,86 @@
import { localStorage } from "@calcom/lib/webstorage";
const BOOKING_SUCCESS_STORAGE_KEY_PREFIX = "cal.successfulBooking";
interface DecoyBookingData {
booking: {
uid: string;
title: string | null;
startTime: string;
endTime: string;
booker: { name: string; timeZone?: string; email: string } | null;
host: { name: string; timeZone?: string } | null;
location: string | null;
};
timestamp: number;
}
function getStorageKey(uid: string): string {
return `${BOOKING_SUCCESS_STORAGE_KEY_PREFIX}.${uid}`;
}
/**
* Stores decoy booking data in localStorage using the booking's uid
* @param booking - The booking data to store (must include uid)
*/
export function storeDecoyBooking(booking: Record<string, unknown> & { uid: string }): void {
const bookingSuccessData = {
booking,
timestamp: Date.now(),
};
const storageKey = getStorageKey(booking.uid);
localStorage.setItem(storageKey, JSON.stringify(bookingSuccessData));
}
/**
* Retrieves decoy booking data from localStorage
* @param uid - The booking uid
* @returns The stored booking data or null if not found or expired
*/
export function getDecoyBooking(uid: string): DecoyBookingData | null {
if (!uid) {
return null;
}
const storageKey = getStorageKey(uid);
const dataStr = localStorage.getItem(storageKey);
if (!dataStr) {
return null;
}
try {
const data: DecoyBookingData = JSON.parse(dataStr);
// Check if the data is too old (5 min)
const dataAge = Date.now() - data.timestamp;
const maxAge = 5 * 60 * 1000; // 5 minutes in milliseconds
if (dataAge > maxAge) {
// Remove the data from localStorage if expired
localStorage.removeItem(storageKey);
return null;
}
return data;
} catch {
// If parsing fails, remove the corrupted data
localStorage.removeItem(storageKey);
return null;
}
}
/**
* Removes decoy booking data from localStorage
* @param uid - The booking uid
*/
export function removeDecoyBooking(uid: string): void {
if (!uid) {
return;
}
const storageKey = getStorageKey(uid);
localStorage.removeItem(storageKey);
}
export type { DecoyBookingData };
@@ -23,7 +23,6 @@ import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/sc
import getICalUID from "@calcom/emails/lib/getICalUID";
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
import EventManager, { placeholderCreatedEvent } from "@calcom/features/bookings/lib/EventManager";
import type { CheckBookingLimitsService } from "@calcom/features/bookings/lib/checkBookingLimits";
import type { BookingDataSchemaGetter } from "@calcom/features/bookings/lib/dto/types";
import type {
CreateRegularBookingData,
@@ -34,16 +33,14 @@ import type { CheckBookingAndDurationLimitsService } from "@calcom/features/book
import { handlePayment } from "@calcom/features/bookings/lib/handlePayment";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container";
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
import { getEventName, updateHostInEventName } from "@calcom/features/eventtypes/lib/eventNaming";
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getFullName } from "@calcom/features/form-builder/utils";
import { handleAnalyticsEvents } from "@calcom/features/tasker/tasks/analytics/handleAnalyticsEvents";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { UsersRepository } from "@calcom/features/users/users.repository";
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
@@ -63,13 +60,17 @@ import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBooke
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import type { CheckBookingLimitsService } from "@calcom/features/bookings/lib/checkBookingLimits";
import logger from "@calcom/lib/logger";
import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import type { PrismaAttributeRepository as AttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import type { BookingRepository } from "../repositories/BookingRepository";
import type { HostRepository } from "@calcom/lib/server/repository/host";
import type { PrismaOOORepository as OooRepository } from "@calcom/lib/server/repository/ooo";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
@@ -154,7 +155,9 @@ function getICalSequence(originalRescheduledBooking: BookingType | null) {
return originalRescheduledBooking.iCalSequence + 1;
}
type CreatedBooking = Booking & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number };
type CreatedBooking = Booking & {
isShortCircuitedBooking?: boolean;
} & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number };
type ReturnTypeCreateBooking = Awaited<ReturnType<typeof createBooking>>;
export const buildDryRunBooking = ({
eventTypeId,
@@ -505,6 +508,12 @@ async function handler(
await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail });
const spamCheckService = getSpamCheckService();
// Either it is a team event or a managed child event of a managed event
const team = eventType.team ?? eventType.parent?.team ?? null;
const eventOrganizationId = team?.parentId ?? null;
spamCheckService.startCheck({ email: bookerEmail, organizationId: eventOrganizationId });
if (!rawBookingData.rescheduleUid) {
await checkActiveBookingsLimitForBooker({
eventTypeId,
@@ -1404,6 +1413,88 @@ async function handler(
organizerUser.id
);
const spamCheckResult = await spamCheckService.waitForCheck();
if (spamCheckResult.isBlocked) {
const DECOY_ORGANIZER_NAMES = ["Alex Smith", "Jordan Taylor", "Sam Johnson", "Chris Morgan"];
const randomOrganizerName =
DECOY_ORGANIZER_NAMES[Math.floor(Math.random() * DECOY_ORGANIZER_NAMES.length)];
const eventName = getEventName({
...eventNameObject,
host: randomOrganizerName,
});
return {
id: 0,
uid,
iCalUID: "",
status: BookingStatus.ACCEPTED,
eventTypeId: eventType.id,
user: {
name: randomOrganizerName,
timeZone: "UTC",
email: null,
},
userId: null,
title: eventName,
startTime: new Date(reqBody.start),
endTime: new Date(reqBody.end),
createdAt: new Date(),
updatedAt: new Date(),
attendees: [
{
id: 0,
email: bookerEmail,
name: fullName,
timeZone: reqBody.timeZone,
locale: null,
phoneNumber: null,
bookingId: null,
noShow: null,
},
],
oneTimePassword: null,
smsReminderNumber: null,
metadata: {},
idempotencyKey: null,
userPrimaryEmail: null,
description: eventType.description || null,
customInputs: null,
responses: null,
location: bookingLocation,
paid: false,
cancellationReason: null,
rejectionReason: null,
dynamicEventSlugRef: null,
dynamicGroupSlugRef: null,
fromReschedule: null,
recurringEventId: null,
scheduledJobs: [],
rescheduledBy: null,
destinationCalendarId: null,
reassignReason: null,
reassignById: null,
rescheduled: false,
isRecorded: false,
iCalSequence: 0,
rating: null,
ratingFeedback: null,
noShowHost: null,
cancelledBy: null,
creationSource: CreationSource.WEBAPP,
references: [],
payment: [],
isDryRun: false,
paymentRequired: false,
paymentUid: undefined,
luckyUsers: [],
paymentId: undefined,
seatReferenceUid: undefined,
isShortCircuitedBooking: true, // Renamed from isSpamDecoy to avoid exposing spam detection to blocked users
}
}
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
if (eventType.seatsPerTimeSlot) {
const newBooking = await handleSeats({
@@ -97,6 +97,7 @@ const getEventTypesFromDBSelect = {
team: {
select: {
id: true,
parentId: true,
bookingLimits: true,
includeManagedEventsInLimits: true,
},
@@ -0,0 +1,789 @@
import {
createBookingScenario,
TestData,
getGoogleCalendarCredential,
getOrganizer,
getBooker,
getScenarioData,
mockCalendarToHaveNoBusySlots,
mockCalendarToCrashOnGetAvailability,
BookingLocations,
createOrganization,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { prisma } from "@calcom/prisma"
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe, expect, vi } from "vitest";
import { WatchlistType, BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
import { getNewBookingHandler } from "./getNewBookingHandler";
const timeout = process.env.CI ? 5000 : 20000;
const createTestWatchlistEntry = async (overrides: {
type: WatchlistType;
value: string;
organizationId: number | null;
action: "BLOCK" | "REPORT";
}) => {
return prisma.watchlist.create({
data: {
type: overrides.type,
value: overrides.value,
action: overrides.action,
createdById: 0,
organizationId: overrides.organizationId,
isGlobal: overrides.organizationId !== null ? false : true,
},
});
};
const createGlobalWatchlistEntry = async (overrides: {
type: WatchlistType;
value: string;
action: "BLOCK" | "REPORT";
}) => {
return createTestWatchlistEntry({
type: overrides.type,
value: overrides.value,
action: overrides.action,
organizationId: null,
});
};
const createOrganizationWatchlistEntry = async (
organizationId: number,
overrides: {
type: WatchlistType;
value: string;
action: "BLOCK" | "REPORT";
}
) => {
return createTestWatchlistEntry({
type: overrides.type,
value: overrides.value,
action: overrides.action,
organizationId,
});
};
const expectDecoyBookingResponse = (booking: Record<string, unknown>) => {
expect(booking).toHaveProperty("isShortCircuitedBooking", true);
expect(booking).toHaveProperty("uid");
expect(booking.uid).toBeTruthy();
expect(booking).toHaveProperty("status", BookingStatus.ACCEPTED);
expect(booking).toHaveProperty("id", 0);
};
const expectNoBookingInDatabase = async (bookerEmail: string) => {
const bookings = await prisma.booking.findMany({
where: {
attendees: {
some: {
email: bookerEmail,
},
},
},
});
expect(bookings).toHaveLength(0);
};
describe("handleNewBooking - Spam Detection", () => {
setupAndTeardown();
describe("Global Watchlist Blocking:", () => {
test(
"should block booking when email is in global watchlist and return decoy response",
async () => {
const handleNewBooking = getNewBookingHandler();
const blockedEmail = "spammer@example.com";
const booker = getBooker({
email: blockedEmail,
name: "Blocked Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
await createGlobalWatchlistEntry({
type: WatchlistType.EMAIL,
value: blockedEmail,
action: "BLOCK",
});
console.log("watchlists", await prisma.watchlist.findMany());
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
await mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const createdBooking = await handleNewBooking({
bookingData: mockBookingData,
});
expectDecoyBookingResponse(createdBooking);
expect(createdBooking.attendees[0].email).toBe(blockedEmail);
await expectNoBookingInDatabase(blockedEmail);
},
timeout
);
test(
"should fail with there are no available users instead of returning decoy response because spam check happens in parallel with availability check",
async () => {
const handleNewBooking = getNewBookingHandler();
const blockedEmail = "spammer@example.com";
const booker = getBooker({
email: blockedEmail,
name: "Blocked Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
await createGlobalWatchlistEntry({
type: WatchlistType.EMAIL,
value: blockedEmail,
action: "BLOCK",
});
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
await mockCalendarToCrashOnGetAvailability("googlecalendar");
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
await expect(async () => {
await handleNewBooking({
bookingData: mockBookingData,
});
}).rejects.toThrow("no_available_users_found_error");
},
timeout
);
test(
"should block booking when domain is in global watchlist and return decoy response",
async () => {
const handleNewBooking = getNewBookingHandler();
const blockedDomain = "@globalspammydomain.com";
const blockedEmail = `user${blockedDomain}`;
const booker = getBooker({
email: blockedEmail,
name: "Global Domain Blocked Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
await createGlobalWatchlistEntry({
type: WatchlistType.DOMAIN,
value: blockedDomain,
action: "BLOCK",
});
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
await mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const createdBooking = await handleNewBooking({
bookingData: mockBookingData,
});
expectDecoyBookingResponse(createdBooking);
expect(createdBooking.attendees[0].email).toBe(blockedEmail);
await expectNoBookingInDatabase(blockedEmail);
},
timeout
);
test(
"should allow booking when spamCheckService.startCheck throws error (fail-open behavior)",
async () => {
const handleNewBooking = getNewBookingHandler();
const bookerEmail = "failopen@example.com";
const booker = getBooker({
email: bookerEmail,
name: "Fail Open Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
await mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
// Mock the SpamCheckService to throw an error during isBlocked check
const { getSpamCheckService } = await import(
"@calcom/features/di/watchlist/containers/SpamCheckService.container"
);
const spamCheckService = getSpamCheckService();
const originalIsBlocked = spamCheckService["isBlocked"].bind(spamCheckService);
// Use vi.spyOn to mock the private method
const isBlockedSpy = vi.spyOn(spamCheckService as never, "isBlocked");
isBlockedSpy.mockRejectedValue(new Error("Database connection failed"));
try {
const createdBooking = await handleNewBooking({
bookingData: mockBookingData,
});
// Should NOT be a decoy response - booking should succeed due to fail-open behavior
expect(createdBooking).not.toHaveProperty("isShortCircuitedBooking");
expect(createdBooking.status).toBe(BookingStatus.ACCEPTED);
expect(createdBooking.id).not.toBe(0);
expect(createdBooking.attendees[0].email).toBe(bookerEmail);
} finally {
// Restore original implementation and clean up spy
isBlockedSpy.mockRestore();
spamCheckService["isBlocked"] = originalIsBlocked;
}
},
timeout
);
});
describe("Organization Watchlist Blocking:", () => {
test(
"should block booking when email is in organization watchlist and return decoy response",
async () => {
const handleNewBooking = getNewBookingHandler();
const blockedEmail = "org-spammer@example.com";
// Create organization with a team
const org = await createOrganization({
name: "Test Org",
slug: "test-org",
withTeam: true,
});
const booker = getBooker({
email: blockedEmail,
name: "Org Blocked Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
organizationId: org.id,
});
await createOrganizationWatchlistEntry(org.id, {
type: WatchlistType.EMAIL,
value: blockedEmail,
action: "BLOCK",
});
// Use the child team ID for the eventType
const teamId = org.children && org.children.length > 0 ? org.children[0].id : null;
await createBookingScenario(
getScenarioData(
{
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
teamId,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
},
{ id: org.id }
)
);
await mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const createdBooking = await handleNewBooking({
bookingData: mockBookingData,
});
expectDecoyBookingResponse(createdBooking);
expect(createdBooking.attendees[0].email).toBe(blockedEmail);
await expectNoBookingInDatabase(blockedEmail);
},
timeout
);
test(
"should block booking when domain is in organization watchlist and return decoy response",
async () => {
const handleNewBooking = getNewBookingHandler();
const blockedDomain = "@spammydomain.com";
const blockedEmail = `user${blockedDomain}`;
// Create organization with a team
const org = await createOrganization({
name: "Test Org",
slug: "test-org",
withTeam: true,
});
const booker = getBooker({
email: blockedEmail,
name: "Domain Blocked Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
organizationId: org.id,
});
await createOrganizationWatchlistEntry(org.id, {
type: WatchlistType.DOMAIN,
value: blockedDomain,
action: "BLOCK",
});
// Use the child team ID for the eventType
const teamId = org.children && org.children.length > 0 ? org.children[0].id : null;
await createBookingScenario(
getScenarioData(
{
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
teamId,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
},
{ id: org.id }
)
);
await mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const createdBooking = await handleNewBooking({
bookingData: mockBookingData,
});
expectDecoyBookingResponse(createdBooking);
expect(createdBooking.attendees[0].email).toBe(blockedEmail);
await expectNoBookingInDatabase(blockedEmail);
},
timeout
);
test(
"should NOT block booking when email is in a different organization's watchlist",
async () => {
const handleNewBooking = getNewBookingHandler();
const blockedEmail = "different-org-user@example.com";
// Create two different organizations with teams
const organizerOrg = await createOrganization({
name: "Organizer Org",
slug: "organizer-org",
withTeam: true,
});
const differentOrg = await createOrganization({
name: "Different Org",
slug: "different-org",
withTeam: true,
});
const booker = getBooker({
email: blockedEmail,
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
organizationId: organizerOrg.id,
});
// Block email in a DIFFERENT organization
await createOrganizationWatchlistEntry(differentOrg.id, {
type: WatchlistType.EMAIL,
value: blockedEmail,
action: "BLOCK",
});
// Use the child team ID for the eventType
const teamId =
organizerOrg.children && organizerOrg.children.length > 0 ? organizerOrg.children[0].id : null;
await createBookingScenario(
getScenarioData(
{
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
teamId,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
},
{ id: organizerOrg.id }
)
);
await mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const createdBooking = await handleNewBooking({
bookingData: mockBookingData,
});
// Should NOT be a decoy response - booking should succeed
expect(createdBooking).not.toHaveProperty("isShortCircuitedBooking");
expect(createdBooking.status).toBe(BookingStatus.ACCEPTED);
expect(createdBooking.id).not.toBe(0);
expect(createdBooking.attendees[0].email).toBe(blockedEmail);
},
timeout
);
test(
"should block booking for managed event when email is in organization watchlist",
async () => {
const handleNewBooking = getNewBookingHandler();
const blockedEmail = "managed-event-spammer@example.com";
// Create organization with a team
const org = await createOrganization({
name: "Managed Event Org",
slug: "managed-event-org",
withTeam: true,
});
const booker = getBooker({
email: blockedEmail,
name: "Managed Event Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
organizationId: org.id,
});
await createOrganizationWatchlistEntry(org.id, {
type: WatchlistType.EMAIL,
value: blockedEmail,
action: "BLOCK",
});
// Get the child team ID
const teamId = org.children && org.children.length > 0 ? org.children[0].id : null;
// Create a parent event type and a managed (child) event type
await createBookingScenario(
getScenarioData(
{
eventTypes: [
// Parent event type
{
id: 1,
slotInterval: 30,
length: 30,
teamId,
users: [
{
id: 101,
},
],
},
// Managed event type (child) - has parent but no teamId
{
id: 2,
slotInterval: 30,
length: 30,
parent: { id: 1 }, // References parent event type
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
},
{ id: org.id }
)
);
await mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
},
});
// Try to book the managed event (id: 2)
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 2, // Booking the managed event
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const createdBooking = await handleNewBooking({
bookingData: mockBookingData,
});
// Should return a decoy response since email is blocked in the organization
expectDecoyBookingResponse(createdBooking);
expect(createdBooking.attendees[0].email).toBe(blockedEmail);
await expectNoBookingInDatabase(blockedEmail);
},
timeout
);
});
});
@@ -0,0 +1,11 @@
import type { GlobalBlockingService } from "@calcom/features/watchlist/lib/service/GlobalBlockingService";
import type { OrganizationBlockingService } from "@calcom/features/watchlist/lib/service/OrganizationBlockingService";
import { SpamCheckService } from "@calcom/features/watchlist/lib/service/SpamCheckService";
import { getGlobalBlockingService, getOrganizationBlockingService } from "./watchlist";
export const getSpamCheckService = (): SpamCheckService => {
const globalBlockingService = getGlobalBlockingService() as GlobalBlockingService;
const organizationBlockingService = getOrganizationBlockingService() as OrganizationBlockingService;
return new SpamCheckService(globalBlockingService, organizationBlockingService);
};
@@ -6,9 +6,19 @@ import { OrganizationWatchlistRepository } from "@calcom/features/watchlist/lib/
import { PrismaWatchlistAuditRepository } from "@calcom/features/watchlist/lib/repository/PrismaWatchlistAuditRepository";
import { WATCHLIST_DI_TOKENS } from "../Watchlist.tokens";
import { GlobalBlockingService } from "@calcom/features/watchlist/lib/service/GlobalBlockingService";
import { OrganizationBlockingService } from "@calcom/features/watchlist/lib/service/OrganizationBlockingService";
export const watchlistModule = createModule();
watchlistModule.bind(WATCHLIST_DI_TOKENS.GLOBAL_BLOCKING_SERVICE).toClass(GlobalBlockingService, {
globalRepo: DI_TOKENS.GLOBAL_WATCHLIST_REPOSITORY,
});
watchlistModule.bind(WATCHLIST_DI_TOKENS.ORGANIZATION_BLOCKING_SERVICE).toClass(OrganizationBlockingService,{
orgRepo: DI_TOKENS.ORGANIZATION_WATCHLIST_REPOSITORY,
});
// Bind specialized repositories
watchlistModule
.bind(WATCHLIST_DI_TOKENS.GLOBAL_WATCHLIST_REPOSITORY)
@@ -0,0 +1,63 @@
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { BlockingResult } from "../interface/IBlockingService";
import type { GlobalBlockingService } from "./GlobalBlockingService";
import type { OrganizationBlockingService } from "./OrganizationBlockingService";
/**
* Spam Check Service - Orchestrates spam checking by coordinating blocking checks
*
* Checks both global watchlist entries (via GlobalBlockingService) and organization-specific
* watchlist entries (via OrganizationBlockingService) when an organizationId is provided.
*/
export class SpamCheckService {
private spamCheckPromise: Promise<BlockingResult> | null = null;
constructor(
private readonly globalBlockingService: GlobalBlockingService,
private readonly organizationBlockingService: OrganizationBlockingService
) { }
startCheck({ email, organizationId }: { email: string, organizationId: number | null }): void {
this.spamCheckPromise = this.isBlocked(email, organizationId ?? undefined).catch((error) => {
logger.error("Error starting spam check", safeStringify(error));
return { isBlocked: false };
});
}
async waitForCheck(): Promise<BlockingResult> {
if (!this.spamCheckPromise) {
throw new Error(
"waitForCheck() called before startCheck(). You must call startCheck() first to initialize spam checking."
);
}
return await this.spamCheckPromise;
}
/**
* Checks if an email is blocked by global or organization-specific watchlist rules
* Runs both checks in parallel for better performance
*/
private async isBlocked(email: string, organizationId?: number): Promise<BlockingResult> {
const checks = [this.globalBlockingService.isBlocked(email)];
if (organizationId) {
checks.push(this.organizationBlockingService.isBlocked(email, organizationId));
}
const [globalResult, orgResult] = await Promise.all(checks);
// Global blocking takes precedence
if (globalResult.isBlocked) {
return globalResult;
}
// Check organization blocking if it was performed
if (orgResult?.isBlocked) {
return orgResult;
}
return { isBlocked: false };
}
}