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 <hariombalhara@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Hariom Balhara
2025-10-15 11:22:07 +00:00
committed by GitHub
co-authored by hariom@cal.com <hariombalhara@gmail.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 5a59bb86cd
commit 3076aca457
4 changed files with 157 additions and 13 deletions
@@ -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
@@ -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
);
});
});
@@ -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;
@@ -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: {