From 3076aca457dfd95f60d15a84e6701bcb2004b817 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Wed, 15 Oct 2025 16:52:07 +0530 Subject: [PATCH] fix: Organization User Events' Spam (#24468) * fix: Organization User Events' Spam * fix: derive organizationId from hostname for spam check Instead of only checking the team's parentId or eventType.profile.organizationId, the spam check now first attempts to derive the organization ID from the hostname. This ensures that organization-level spam blocking works correctly based on the domain/subdomain the booker is visiting, which is especially important for multi-tenant deployments where the same event type might be accessible via different organization domains. Changes: - Extract org slug from hostname using getOrgSlug() - Fetch organization by slug using OrganizationRepository - Use hostname-derived orgId for spam check, falling back to team/profile orgId - Maintains backward compatibility when hostname is not available Co-Authored-By: hariom@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../features/bookings/lib/handleNewBooking.ts | 60 ++++++++++--- .../test/spam-booking.integration-test.ts | 86 ++++++++++++++++++- .../ee/organizations/lib/orgDomains.ts | 4 +- .../profile/repositories/ProfileRepository.ts | 20 +++++ 4 files changed, 157 insertions(+), 13 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 7a33982a92..2a9a6d4144 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1,6 +1,6 @@ import short, { uuid } from "short-uuid"; import { v5 as uuidv5 } from "uuid"; - +import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; import processExternalId from "@calcom/app-store/_utils/calendars/processExternalId"; import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData"; import { @@ -23,6 +23,7 @@ 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, @@ -35,12 +36,15 @@ import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhoo import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache"; import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container"; +import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; 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"; @@ -56,21 +60,16 @@ import { DEFAULT_GROUP_ID } from "@calcom/lib/constants"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import { extractBaseEmail } from "@calcom/lib/extract-base-email"; -import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; 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"; @@ -96,6 +95,7 @@ import type { CredentialForCalendarService } from "@calcom/types/Credential"; import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import type { EventPayloadType, EventTypeInfo } from "../../webhooks/lib/sendPayload"; +import type { BookingRepository } from "../repositories/BookingRepository"; import { BookingActionMap, BookingEmailSmsHandler } from "./BookingEmailSmsHandler"; import { getAllCredentialsIncludeServiceAccountKey } from "./getAllCredentialsForUsersOnEvent/getAllCredentials"; import { refreshCredentials } from "./getAllCredentialsForUsersOnEvent/refreshCredentials"; @@ -427,6 +427,45 @@ export interface IBookingServiceDependencies { attributeRepository: AttributeRepository; } +/** + * TODO: Ideally we should send organizationId directly to handleNewBooking. + * webapp can derive from domain and API V2 knows it already through its endpoint URL + */ +async function getEventOrganizationId({ + eventType, +}: { + eventType: { + userId: number | null; + team: { + parentId: number | null; + } | null; + parent: { + team: { + parentId: number | null; + } | null; + } | null; + }; +}) { + let eventOrganizationId: number | null = null; + const team = eventType.team ?? eventType.parent?.team ?? null; + eventOrganizationId = team?.parentId ?? null; + + if (eventOrganizationId) { + return eventOrganizationId; + } + + if (eventType.userId) { + // TODO: Moving it to instance based access through DI in a followup + const profile = await ProfileRepository.findFirstForUserId({ + userId: eventType.userId, + }); + eventOrganizationId = profile?.organizationId ?? null; + return eventOrganizationId; + } + + return eventOrganizationId; +} + async function handler( input: BookingHandlerInput, deps: IBookingServiceDependencies, @@ -509,9 +548,10 @@ 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; + const eventOrganizationId = await getEventOrganizationId({ + eventType, + }); + spamCheckService.startCheck({ email: bookerEmail, organizationId: eventOrganizationId }); if (!rawBookingData.rescheduleUid) { @@ -1492,7 +1532,7 @@ async function handler( 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 diff --git a/packages/features/bookings/lib/handleNewBooking/test/spam-booking.integration-test.ts b/packages/features/bookings/lib/handleNewBooking/test/spam-booking.integration-test.ts index 9f33cf0f08..94787b2d93 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/spam-booking.integration-test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/spam-booking.integration-test.ts @@ -10,12 +10,12 @@ import { 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 { prisma } from "@calcom/prisma"; import { WatchlistType, BookingStatus } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; @@ -785,5 +785,89 @@ describe("handleNewBooking - Spam Detection", () => { }, timeout ); + + test( + "should block booking for user event in organization when email is in organization watchlist", + async () => { + const handleNewBooking = getNewBookingHandler(); + const blockedEmail = "user-event-spammer@example.com"; + + // Create organization + const org = await createOrganization({ + name: "User Event Org", + slug: "user-event-org", + withTeam: false, + }); + + const booker = getBooker({ + email: blockedEmail, + name: "User 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", + }); + + // Create a user event (no teamId) but with a profile linking to the organization + await createBookingScenario( + getScenarioData( + { + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + // User Event Type has userId set + userId: 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, + }); + + // 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 + ); }); }); diff --git a/packages/features/ee/organizations/lib/orgDomains.ts b/packages/features/ee/organizations/lib/orgDomains.ts index 8bbb925be9..483c120db6 100644 --- a/packages/features/ee/organizations/lib/orgDomains.ts +++ b/packages/features/ee/organizations/lib/orgDomains.ts @@ -15,8 +15,8 @@ const log = logger.getSubLogger({ */ export function getOrgSlug(hostname: string, forcedSlug?: string) { if (forcedSlug) { - if (process.env.NEXT_PUBLIC_IS_E2E) { - log.debug("Using provided forcedSlug in E2E", { + if (process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE) { + log.debug("Using provided forcedSlug in E2E/Integration Test mode", { forcedSlug, }); return forcedSlug; diff --git a/packages/features/profile/repositories/ProfileRepository.ts b/packages/features/profile/repositories/ProfileRepository.ts index 9d12052399..f7193091be 100644 --- a/packages/features/profile/repositories/ProfileRepository.ts +++ b/packages/features/profile/repositories/ProfileRepository.ts @@ -58,6 +58,17 @@ const organizationWithSettingsSelect = { }, }; +const profileSelect = { + id: true, + uid: true, + userId: true, + organizationId: true, + username: true, + createdAt: true, + updatedAt: true, +}; + + export enum LookupTarget { User, Profile, @@ -627,6 +638,15 @@ export class ProfileRepository { return profiles; } + static async findFirstForUserId({ userId }: { userId: number }) { + return prisma.profile.findFirst({ + where: { + userId: userId, + }, + select: profileSelect, + }); + } + static async findManyForOrg({ organizationId }: { organizationId: number }) { return await prisma.profile.findMany({ where: {