diff --git a/.env.example b/.env.example index e156f609a4..ffcbba6bc0 100644 --- a/.env.example +++ b/.env.example @@ -267,6 +267,9 @@ NEXT_PUBLIC_CLOUDFLARE_SITEKEY= NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER= CLOUDFLARE_TURNSTILE_SECRET= +# 0 = false, 1=true +NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER= + # Set the following value to true if you wish to enable Team Impersonation NEXT_PUBLIC_TEAM_IMPERSONATION=false diff --git a/apps/web/instrumentation-client.ts b/apps/web/instrumentation-client.ts index 1743d3615b..c233ab6613 100644 --- a/apps/web/instrumentation-client.ts +++ b/apps/web/instrumentation-client.ts @@ -2,6 +2,7 @@ // The added config here will be used whenever a users loads a page in their browser. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from "@sentry/nextjs"; +import { initBotId } from "botid/client/core"; if (process.env.NODE_ENV === "production") { Sentry.init({ @@ -48,3 +49,13 @@ export function onRouterTransitionStart(url: string, navigationType: "push" | "r Sentry.captureRouterTransitionStart(url, navigationType); } } + +process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER === "1" && + initBotId({ + protect: [ + { + path: "/api/book/event", + method: "POST", + }, + ], + }); diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 029b42e38e..25789cdf02 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,6 +1,7 @@ require("dotenv").config({ path: "../../.env" }); const englishTranslation = require("./public/static/locales/en/common.json"); const { withAxiom } = require("next-axiom"); +const { withBotId } = require("botid/next/config"); const { version } = require("./package.json"); const { PrismaPlugin } = require("@prisma/nextjs-monorepo-workaround-plugin"); const { @@ -117,6 +118,11 @@ if (process.env.ANALYZE === "true") { } plugins.push(withAxiom); + +if (process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER === "1") { + plugins.push(withBotId); +} + const orgDomainMatcherConfig = { root: nextJsOrgRewriteConfig.disableRootPathRewrite ? null diff --git a/apps/web/package.json b/apps/web/package.json index 2424e03df2..185d38e20d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -87,6 +87,7 @@ "async": "^3.2.4", "bcp-47-match": "^2.0.3", "bcryptjs": "^2.4.3", + "botid": "^1.5.7", "classnames": "^2.3.1", "dompurify": "^3.1.7", "dotenv-cli": "^6.0.0", diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 1aa0fb10c0..a4ca317048 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -2,11 +2,15 @@ import type { NextApiRequest } from "next"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking"; +import { BotDetectionService } from "@calcom/features/bot-detection"; +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import getIP from "@calcom/lib/getIP"; import { piiHasher } from "@calcom/lib/server/PiiHasher"; import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; +import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository"; +import prisma from "@calcom/prisma"; import { CreationSource } from "@calcom/prisma/enums"; async function handler(req: NextApiRequest & { userId?: number }) { @@ -19,6 +23,16 @@ async function handler(req: NextApiRequest & { userId?: number }) { }); } + // Check for bot detection using feature flag + const featuresRepository = new FeaturesRepository(prisma); + const eventTypeRepository = new EventTypeRepository(prisma); + const botDetectionService = new BotDetectionService(featuresRepository, eventTypeRepository); + + await botDetectionService.checkBotDetection({ + eventTypeId: req.body.eventTypeId, + headers: req.headers, + }); + await checkRateLimitAndThrowError({ rateLimitingType: "core", identifier: piiHasher.hash(userIp), diff --git a/packages/features/bot-detection/BotDetectionService.test.ts b/packages/features/bot-detection/BotDetectionService.test.ts new file mode 100644 index 0000000000..fb02356255 --- /dev/null +++ b/packages/features/bot-detection/BotDetectionService.test.ts @@ -0,0 +1,184 @@ +import type { IncomingHttpHeaders } from "http"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import { HttpError } from "@calcom/lib/http-error"; +import type { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository"; + +import { BotDetectionService } from "./BotDetectionService"; + +// Mock the botid/server module +vi.mock("botid/server", () => ({ + checkBotId: vi.fn(), +})); + +// Mock the logger +vi.mock("@calcom/lib/logger", () => ({ + default: { + getSubLogger: vi.fn(() => ({ + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + })), + }, +})); + +describe("BotDetectionService", () => { + let botDetectionService: BotDetectionService; + let mockFeaturesRepository: FeaturesRepository; + let mockEventTypeRepository: EventTypeRepository; + let mockHeaders: IncomingHttpHeaders; + + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + + // Setup mock repositories + mockFeaturesRepository = { + checkIfTeamHasFeature: vi.fn(), + } as unknown as FeaturesRepository; + + mockEventTypeRepository = { + getTeamIdByEventTypeId: vi.fn(), + } as unknown as EventTypeRepository; + + mockHeaders = { + "user-agent": "Mozilla/5.0", + "x-forwarded-for": "192.168.1.1", + }; + + botDetectionService = new BotDetectionService(mockFeaturesRepository, mockEventTypeRepository); + + // Reset environment variable + delete process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER; + }); + + describe("checkBotDetection", () => { + it("should return early if BotID is not enabled at instance level", async () => { + process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER = "0"; + + await botDetectionService.checkBotDetection({ + eventTypeId: 123, + headers: mockHeaders, + }); + + expect(mockEventTypeRepository.getTeamIdByEventTypeId).not.toHaveBeenCalled(); + }); + + it("should return early if no eventTypeId is provided", async () => { + process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER = "1"; + + await botDetectionService.checkBotDetection({ + headers: mockHeaders, + }); + + expect(mockEventTypeRepository.getTeamIdByEventTypeId).not.toHaveBeenCalled(); + }); + + it("should return early if event type has no teamId", async () => { + process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER = "1"; + vi.mocked(mockEventTypeRepository.getTeamIdByEventTypeId).mockResolvedValue({ + teamId: null, + }); + + await botDetectionService.checkBotDetection({ + eventTypeId: 123, + headers: mockHeaders, + }); + + expect(mockFeaturesRepository.checkIfTeamHasFeature).not.toHaveBeenCalled(); + }); + + it("should return early if BotID feature is not enabled for the team", async () => { + process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER = "1"; + vi.mocked(mockEventTypeRepository.getTeamIdByEventTypeId).mockResolvedValue({ + teamId: 456, + }); + vi.mocked(mockFeaturesRepository.checkIfTeamHasFeature).mockResolvedValue(false); + + const { checkBotId } = await import("botid/server"); + + await botDetectionService.checkBotDetection({ + eventTypeId: 123, + headers: mockHeaders, + }); + + expect(checkBotId).not.toHaveBeenCalled(); + }); + + it("should throw HttpError when a bot is detected", async () => { + process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER = "1"; + vi.mocked(mockEventTypeRepository.getTeamIdByEventTypeId).mockResolvedValue({ + teamId: 456, + }); + vi.mocked(mockFeaturesRepository.checkIfTeamHasFeature).mockResolvedValue(true); + + const { checkBotId } = await import("botid/server"); + vi.mocked(checkBotId).mockResolvedValue({ + isBot: true, + isHuman: false, + isVerifiedBot: false, + verifiedBotName: undefined, + verifiedBotCategory: undefined, + bypassed: false, + classificationReason: "suspicious-patterns", + }); + + await expect( + botDetectionService.checkBotDetection({ + eventTypeId: 123, + headers: mockHeaders, + }) + ).rejects.toThrow(HttpError); + + await expect( + botDetectionService.checkBotDetection({ + eventTypeId: 123, + headers: mockHeaders, + }) + ).rejects.toThrow("Access denied"); + }); + + it("should pass when a human is detected", async () => { + process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER = "1"; + vi.mocked(mockEventTypeRepository.getTeamIdByEventTypeId).mockResolvedValue({ + teamId: 456, + }); + vi.mocked(mockFeaturesRepository.checkIfTeamHasFeature).mockResolvedValue(true); + + const { checkBotId } = await import("botid/server"); + vi.mocked(checkBotId).mockResolvedValue({ + isBot: false, + isHuman: true, + isVerifiedBot: false, + verifiedBotName: undefined, + verifiedBotCategory: undefined, + bypassed: false, + classificationReason: "human-behavior", + }); + + await expect( + botDetectionService.checkBotDetection({ + eventTypeId: 123, + headers: mockHeaders, + }) + ).resolves.not.toThrow(); + }); + + it("should check feature flag with correct teamId", async () => { + const teamId = 789; + process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER = "1"; + vi.mocked(mockEventTypeRepository.getTeamIdByEventTypeId).mockResolvedValue({ + teamId, + }); + vi.mocked(mockFeaturesRepository.checkIfTeamHasFeature).mockResolvedValue(false); + + await botDetectionService.checkBotDetection({ + eventTypeId: 123, + headers: mockHeaders, + }); + + expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(teamId, "booker-botid"); + }); + }); +}); diff --git a/packages/features/bot-detection/BotDetectionService.ts b/packages/features/bot-detection/BotDetectionService.ts new file mode 100644 index 0000000000..e80d8d1135 --- /dev/null +++ b/packages/features/bot-detection/BotDetectionService.ts @@ -0,0 +1,81 @@ +import { checkBotId } from "botid/server"; +import type { IncomingHttpHeaders } from "http"; + +import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import type { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository"; + +interface BotDetectionConfig { + eventTypeId?: number; + headers: IncomingHttpHeaders; +} + +const log = logger.getSubLogger({ prefix: ["[BotDetectionService]"] }); + +export class BotDetectionService { + constructor( + private featuresRepository: FeaturesRepository, + private eventTypeRepository: EventTypeRepository + ) {} + + private instanceHasBotIdEnabled() { + return process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER === "1"; + } + + async checkBotDetection(config: BotDetectionConfig): Promise { + if (!this.instanceHasBotIdEnabled()) return; + + const { eventTypeId, headers } = config; + + // If no eventTypeId provided, skip bot detection + if (!eventTypeId) { + return; + } + + // Fetch only the teamId from the event type + const eventType = await this.eventTypeRepository.getTeamIdByEventTypeId({ + id: eventTypeId, + }); + + // Only check for team events + if (!eventType?.teamId) { + return; + } + + // Check if BotID feature is enabled for this team (also checks global scope - enabling on all teams) + const isBotIDEnabled = await this.featuresRepository.checkIfTeamHasFeature( + eventType.teamId, + "booker-botid" + ); + + if (!isBotIDEnabled) { + return; + } + + // Perform bot detection + const verification = await checkBotId({ + advancedOptions: { + headers, + }, + }); + + // Log verification results with detailed information + const verificationDetails = { + isBot: verification.isBot, + isHuman: verification.isHuman, + isVerifiedBot: verification.isVerifiedBot, + verifiedBotName: verification.verifiedBotName, + verifiedBotCategory: verification.verifiedBotCategory, + bypassed: verification.bypassed, + classificationReason: verification.classificationReason, + teamId: eventType.teamId, + eventTypeId, + }; + + if (verification.isBot) { + log.warn("Bot detected - blocking request", verificationDetails); + throw new HttpError({ statusCode: 403, message: "Access denied" }); + } + } +} diff --git a/packages/features/bot-detection/index.ts b/packages/features/bot-detection/index.ts new file mode 100644 index 0000000000..ae6f1dbcdd --- /dev/null +++ b/packages/features/bot-detection/index.ts @@ -0,0 +1 @@ +export { BotDetectionService } from "./BotDetectionService"; diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index eb068d2636..81baf16ca5 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -28,6 +28,7 @@ export type AppFlags = { "tiered-support-chat": boolean; "calendar-subscription-cache": boolean; "calendar-subscription-sync": boolean; + "booker-botid": boolean; }; export type TeamFeatures = Record; diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts index a1789bfd82..b8c311a33f 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -27,6 +27,7 @@ const initialData: AppFlags = { "tiered-support-chat": false, "calendar-subscription-cache": false, "calendar-subscription-sync": false, + "booker-botid": false, }; if (process.env.NEXT_PUBLIC_IS_E2E) { diff --git a/packages/lib/server/repository/eventTypeRepository.ts b/packages/lib/server/repository/eventTypeRepository.ts index 0c8056d16f..fbc302efc7 100644 --- a/packages/lib/server/repository/eventTypeRepository.ts +++ b/packages/lib/server/repository/eventTypeRepository.ts @@ -1422,4 +1422,15 @@ export class EventTypeRepository { }, }); } + + async getTeamIdByEventTypeId({ id }: { id: number }) { + return await this.prismaClient.eventType.findFirst({ + where: { + id, + }, + select: { + teamId: true, + }, + }); + } } diff --git a/packages/prisma/migrations/20251002092823_add_booker_botid_feature_flag/migration.sql b/packages/prisma/migrations/20251002092823_add_booker_botid_feature_flag/migration.sql new file mode 100644 index 0000000000..ac1f62c244 --- /dev/null +++ b/packages/prisma/migrations/20251002092823_add_booker_botid_feature_flag/migration.sql @@ -0,0 +1,9 @@ +INSERT INTO + "Feature" (slug, enabled, description, "type") +VALUES + ( + 'booker-botid', + false, + 'Enable BotID protection for booking endpoints - Protects booking API endpoints from bot traffic using BotID verification.', + 'OPERATIONAL' + ) ON CONFLICT (slug) DO NOTHING; diff --git a/turbo.json b/turbo.json index 33ad46ed11..aa40416880 100644 --- a/turbo.json +++ b/turbo.json @@ -50,6 +50,7 @@ "DEBUG", "DUB_API_KEY", "NEXT_PUBLIC_DUB_PROGRAM_ID", + "NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER", "E2E_TEST_APPLE_CALENDAR_EMAIL", "E2E_TEST_APPLE_CALENDAR_PASSWORD", "E2E_TEST_CALCOM_QA_EMAIL", diff --git a/yarn.lock b/yarn.lock index 53001fc5f0..1a59ed188a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4302,6 +4302,7 @@ __metadata: autoprefixer: ^10.4.12 bcp-47-match: ^2.0.3 bcryptjs: ^2.4.3 + botid: ^1.5.7 classnames: ^2.3.1 cron: ^3.1.7 detect-port: ^1.3.0 @@ -21570,6 +21571,21 @@ __metadata: languageName: node linkType: hard +"botid@npm:^1.5.7": + version: 1.5.7 + resolution: "botid@npm:1.5.7" + peerDependencies: + next: "*" + react: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + next: + optional: true + react: + optional: true + checksum: d44f483db90e22e5d097e744bfc8baa667e78b1f84c15cb76680eba638fc8e8561582b2dada4c833c2d3291a8a6dd2ac3dc6d67a57ce17e1a4884ebb98cba0d3 + languageName: node + linkType: hard + "bottleneck@npm:^2.15.3, bottleneck@npm:^2.19.5": version: 2.19.5 resolution: "bottleneck@npm:2.19.5"