Skip to content

Commit 3076aca

Browse files
fix: Organization User Events' Spam (calcom#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 <hariombalhara@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 5a59bb8 commit 3076aca

4 files changed

Lines changed: 157 additions & 13 deletions

File tree

packages/features/bookings/lib/handleNewBooking.ts

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import short, { uuid } from "short-uuid";
22
import { v5 as uuidv5 } from "uuid";
3-
3+
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
44
import processExternalId from "@calcom/app-store/_utils/calendars/processExternalId";
55
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
66
import {
@@ -23,6 +23,7 @@ import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/sc
2323
import getICalUID from "@calcom/emails/lib/getICalUID";
2424
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
2525
import EventManager, { placeholderCreatedEvent } from "@calcom/features/bookings/lib/EventManager";
26+
import type { CheckBookingLimitsService } from "@calcom/features/bookings/lib/checkBookingLimits";
2627
import type { BookingDataSchemaGetter } from "@calcom/features/bookings/lib/dto/types";
2728
import type {
2829
CreateRegularBookingData,
@@ -35,12 +36,15 @@ import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhoo
3536
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
3637
import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
3738
import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container";
39+
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
3840
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
41+
import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
3942
import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
4043
import { getEventName, updateHostInEventName } from "@calcom/features/eventtypes/lib/eventNaming";
4144
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
4245
import { getFullName } from "@calcom/features/form-builder/utils";
4346
import { handleAnalyticsEvents } from "@calcom/features/tasker/tasks/analytics/handleAnalyticsEvents";
47+
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
4448
import { UsersRepository } from "@calcom/features/users/users.repository";
4549
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
4650
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
@@ -56,21 +60,16 @@ import { DEFAULT_GROUP_ID } from "@calcom/lib/constants";
5660
import { ErrorCode } from "@calcom/lib/errorCodes";
5761
import { getErrorFromUnknown } from "@calcom/lib/errors";
5862
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
59-
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
6063
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
6164
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
6265
import { HttpError } from "@calcom/lib/http-error";
63-
import type { CheckBookingLimitsService } from "@calcom/features/bookings/lib/checkBookingLimits";
6466
import logger from "@calcom/lib/logger";
6567
import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFreeData";
6668
import { safeStringify } from "@calcom/lib/safeStringify";
6769
import { getTranslation } from "@calcom/lib/server/i18n";
6870
import type { PrismaAttributeRepository as AttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
69-
import type { BookingRepository } from "../repositories/BookingRepository";
7071
import type { HostRepository } from "@calcom/lib/server/repository/host";
7172
import type { PrismaOOORepository as OooRepository } from "@calcom/lib/server/repository/ooo";
72-
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
73-
import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
7473
import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
7574
import { WorkflowService } from "@calcom/lib/server/service/workflows";
7675
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
@@ -96,6 +95,7 @@ import type { CredentialForCalendarService } from "@calcom/types/Credential";
9695
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
9796

9897
import type { EventPayloadType, EventTypeInfo } from "../../webhooks/lib/sendPayload";
98+
import type { BookingRepository } from "../repositories/BookingRepository";
9999
import { BookingActionMap, BookingEmailSmsHandler } from "./BookingEmailSmsHandler";
100100
import { getAllCredentialsIncludeServiceAccountKey } from "./getAllCredentialsForUsersOnEvent/getAllCredentials";
101101
import { refreshCredentials } from "./getAllCredentialsForUsersOnEvent/refreshCredentials";
@@ -427,6 +427,45 @@ export interface IBookingServiceDependencies {
427427
attributeRepository: AttributeRepository;
428428
}
429429

430+
/**
431+
* TODO: Ideally we should send organizationId directly to handleNewBooking.
432+
* webapp can derive from domain and API V2 knows it already through its endpoint URL
433+
*/
434+
async function getEventOrganizationId({
435+
eventType,
436+
}: {
437+
eventType: {
438+
userId: number | null;
439+
team: {
440+
parentId: number | null;
441+
} | null;
442+
parent: {
443+
team: {
444+
parentId: number | null;
445+
} | null;
446+
} | null;
447+
};
448+
}) {
449+
let eventOrganizationId: number | null = null;
450+
const team = eventType.team ?? eventType.parent?.team ?? null;
451+
eventOrganizationId = team?.parentId ?? null;
452+
453+
if (eventOrganizationId) {
454+
return eventOrganizationId;
455+
}
456+
457+
if (eventType.userId) {
458+
// TODO: Moving it to instance based access through DI in a followup
459+
const profile = await ProfileRepository.findFirstForUserId({
460+
userId: eventType.userId,
461+
});
462+
eventOrganizationId = profile?.organizationId ?? null;
463+
return eventOrganizationId;
464+
}
465+
466+
return eventOrganizationId;
467+
}
468+
430469
async function handler(
431470
input: BookingHandlerInput,
432471
deps: IBookingServiceDependencies,
@@ -509,9 +548,10 @@ async function handler(
509548
await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail });
510549

511550
const spamCheckService = getSpamCheckService();
512-
// Either it is a team event or a managed child event of a managed event
513-
const team = eventType.team ?? eventType.parent?.team ?? null;
514-
const eventOrganizationId = team?.parentId ?? null;
551+
const eventOrganizationId = await getEventOrganizationId({
552+
eventType,
553+
});
554+
515555
spamCheckService.startCheck({ email: bookerEmail, organizationId: eventOrganizationId });
516556

517557
if (!rawBookingData.rescheduleUid) {
@@ -1492,7 +1532,7 @@ async function handler(
14921532
paymentId: undefined,
14931533
seatReferenceUid: undefined,
14941534
isShortCircuitedBooking: true, // Renamed from isSpamDecoy to avoid exposing spam detection to blocked users
1495-
}
1535+
};
14961536
}
14971537

14981538
// For seats, if the booking already exists then we want to add the new attendee to the existing booking

packages/features/bookings/lib/handleNewBooking/test/spam-booking.integration-test.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import {
1010
BookingLocations,
1111
createOrganization,
1212
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
13-
import { prisma } from "@calcom/prisma"
1413
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
1514
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
1615

1716
import { describe, expect, vi } from "vitest";
1817

18+
import { prisma } from "@calcom/prisma";
1919
import { WatchlistType, BookingStatus } from "@calcom/prisma/enums";
2020
import { test } from "@calcom/web/test/fixtures/fixtures";
2121

@@ -785,5 +785,89 @@ describe("handleNewBooking - Spam Detection", () => {
785785
},
786786
timeout
787787
);
788+
789+
test(
790+
"should block booking for user event in organization when email is in organization watchlist",
791+
async () => {
792+
const handleNewBooking = getNewBookingHandler();
793+
const blockedEmail = "user-event-spammer@example.com";
794+
795+
// Create organization
796+
const org = await createOrganization({
797+
name: "User Event Org",
798+
slug: "user-event-org",
799+
withTeam: false,
800+
});
801+
802+
const booker = getBooker({
803+
email: blockedEmail,
804+
name: "User Event Booker",
805+
});
806+
807+
const organizer = getOrganizer({
808+
name: "Organizer",
809+
email: "organizer@example.com",
810+
id: 101,
811+
schedules: [TestData.schedules.IstWorkHours],
812+
credentials: [getGoogleCalendarCredential()],
813+
selectedCalendars: [TestData.selectedCalendars.google],
814+
organizationId: org.id,
815+
});
816+
817+
await createOrganizationWatchlistEntry(org.id, {
818+
type: WatchlistType.EMAIL,
819+
value: blockedEmail,
820+
action: "BLOCK",
821+
});
822+
823+
// Create a user event (no teamId) but with a profile linking to the organization
824+
await createBookingScenario(
825+
getScenarioData(
826+
{
827+
eventTypes: [
828+
{
829+
id: 1,
830+
slotInterval: 30,
831+
length: 30,
832+
// User Event Type has userId set
833+
userId: 101,
834+
},
835+
],
836+
organizer,
837+
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
838+
},
839+
{ id: org.id }
840+
)
841+
);
842+
843+
await mockCalendarToHaveNoBusySlots("googlecalendar", {
844+
create: {
845+
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
846+
},
847+
});
848+
849+
const mockBookingData = getMockRequestDataForBooking({
850+
data: {
851+
user: organizer.username,
852+
eventTypeId: 1,
853+
responses: {
854+
email: booker.email,
855+
name: booker.name,
856+
location: { optionValue: "", value: BookingLocations.CalVideo },
857+
},
858+
},
859+
});
860+
861+
const createdBooking = await handleNewBooking({
862+
bookingData: mockBookingData,
863+
});
864+
865+
// Should return a decoy response since email is blocked in the organization
866+
expectDecoyBookingResponse(createdBooking);
867+
expect(createdBooking.attendees[0].email).toBe(blockedEmail);
868+
await expectNoBookingInDatabase(blockedEmail);
869+
},
870+
timeout
871+
);
788872
});
789873
});

packages/features/ee/organizations/lib/orgDomains.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ const log = logger.getSubLogger({
1515
*/
1616
export function getOrgSlug(hostname: string, forcedSlug?: string) {
1717
if (forcedSlug) {
18-
if (process.env.NEXT_PUBLIC_IS_E2E) {
19-
log.debug("Using provided forcedSlug in E2E", {
18+
if (process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE) {
19+
log.debug("Using provided forcedSlug in E2E/Integration Test mode", {
2020
forcedSlug,
2121
});
2222
return forcedSlug;

packages/features/profile/repositories/ProfileRepository.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@ const organizationWithSettingsSelect = {
5858
},
5959
};
6060

61+
const profileSelect = {
62+
id: true,
63+
uid: true,
64+
userId: true,
65+
organizationId: true,
66+
username: true,
67+
createdAt: true,
68+
updatedAt: true,
69+
};
70+
71+
6172
export enum LookupTarget {
6273
User,
6374
Profile,
@@ -627,6 +638,15 @@ export class ProfileRepository {
627638
return profiles;
628639
}
629640

641+
static async findFirstForUserId({ userId }: { userId: number }) {
642+
return prisma.profile.findFirst({
643+
where: {
644+
userId: userId,
645+
},
646+
select: profileSelect,
647+
});
648+
}
649+
630650
static async findManyForOrg({ organizationId }: { organizationId: number }) {
631651
return await prisma.profile.findMany({
632652
where: {

0 commit comments

Comments
 (0)