Skip to content

Commit 7799b19

Browse files
authored
feat: botid enabled on api/book/event api route (calcom#24207)
* wip * WIP * restore event * testing without instrument client * Add conditional for botID init * Bump BotID version + pass in header * botID yarn lock changes * feat: Add feature flag checks + tidy up into service * rely on env var also * use eventType repo instead of passing in prisma * remove slug from audit * rename botId feature to botid * add unit tests for bot service
1 parent f3269a3 commit 7799b19

14 files changed

Lines changed: 340 additions & 0 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,9 @@ NEXT_PUBLIC_CLOUDFLARE_SITEKEY=
267267
NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER=
268268
CLOUDFLARE_TURNSTILE_SECRET=
269269

270+
# 0 = false, 1=true
271+
NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER=
272+
270273
# Set the following value to true if you wish to enable Team Impersonation
271274
NEXT_PUBLIC_TEAM_IMPERSONATION=false
272275

apps/web/instrumentation-client.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The added config here will be used whenever a users loads a page in their browser.
33
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
44
import * as Sentry from "@sentry/nextjs";
5+
import { initBotId } from "botid/client/core";
56

67
if (process.env.NODE_ENV === "production") {
78
Sentry.init({
@@ -48,3 +49,13 @@ export function onRouterTransitionStart(url: string, navigationType: "push" | "r
4849
Sentry.captureRouterTransitionStart(url, navigationType);
4950
}
5051
}
52+
53+
process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER === "1" &&
54+
initBotId({
55+
protect: [
56+
{
57+
path: "/api/book/event",
58+
method: "POST",
59+
},
60+
],
61+
});

apps/web/next.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
require("dotenv").config({ path: "../../.env" });
22
const englishTranslation = require("./public/static/locales/en/common.json");
33
const { withAxiom } = require("next-axiom");
4+
const { withBotId } = require("botid/next/config");
45
const { version } = require("./package.json");
56
const { PrismaPlugin } = require("@prisma/nextjs-monorepo-workaround-plugin");
67
const {
@@ -117,6 +118,11 @@ if (process.env.ANALYZE === "true") {
117118
}
118119

119120
plugins.push(withAxiom);
121+
122+
if (process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER === "1") {
123+
plugins.push(withBotId);
124+
}
125+
120126
const orgDomainMatcherConfig = {
121127
root: nextJsOrgRewriteConfig.disableRootPathRewrite
122128
? null

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"async": "^3.2.4",
8888
"bcp-47-match": "^2.0.3",
8989
"bcryptjs": "^2.4.3",
90+
"botid": "^1.5.7",
9091
"classnames": "^2.3.1",
9192
"dompurify": "^3.1.7",
9293
"dotenv-cli": "^6.0.0",

apps/web/pages/api/book/event.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ import type { NextApiRequest } from "next";
22

33
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
44
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
5+
import { BotDetectionService } from "@calcom/features/bot-detection";
6+
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
57
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
68
import getIP from "@calcom/lib/getIP";
79
import { piiHasher } from "@calcom/lib/server/PiiHasher";
810
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
911
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
12+
import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository";
13+
import prisma from "@calcom/prisma";
1014
import { CreationSource } from "@calcom/prisma/enums";
1115

1216
async function handler(req: NextApiRequest & { userId?: number }) {
@@ -19,6 +23,16 @@ async function handler(req: NextApiRequest & { userId?: number }) {
1923
});
2024
}
2125

26+
// Check for bot detection using feature flag
27+
const featuresRepository = new FeaturesRepository(prisma);
28+
const eventTypeRepository = new EventTypeRepository(prisma);
29+
const botDetectionService = new BotDetectionService(featuresRepository, eventTypeRepository);
30+
31+
await botDetectionService.checkBotDetection({
32+
eventTypeId: req.body.eventTypeId,
33+
headers: req.headers,
34+
});
35+
2236
await checkRateLimitAndThrowError({
2337
rateLimitingType: "core",
2438
identifier: piiHasher.hash(userIp),
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import type { IncomingHttpHeaders } from "http";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
5+
import { HttpError } from "@calcom/lib/http-error";
6+
import type { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository";
7+
8+
import { BotDetectionService } from "./BotDetectionService";
9+
10+
// Mock the botid/server module
11+
vi.mock("botid/server", () => ({
12+
checkBotId: vi.fn(),
13+
}));
14+
15+
// Mock the logger
16+
vi.mock("@calcom/lib/logger", () => ({
17+
default: {
18+
getSubLogger: vi.fn(() => ({
19+
warn: vi.fn(),
20+
info: vi.fn(),
21+
error: vi.fn(),
22+
})),
23+
},
24+
}));
25+
26+
describe("BotDetectionService", () => {
27+
let botDetectionService: BotDetectionService;
28+
let mockFeaturesRepository: FeaturesRepository;
29+
let mockEventTypeRepository: EventTypeRepository;
30+
let mockHeaders: IncomingHttpHeaders;
31+
32+
beforeEach(() => {
33+
// Reset all mocks before each test
34+
vi.clearAllMocks();
35+
36+
// Setup mock repositories
37+
mockFeaturesRepository = {
38+
checkIfTeamHasFeature: vi.fn(),
39+
} as unknown as FeaturesRepository;
40+
41+
mockEventTypeRepository = {
42+
getTeamIdByEventTypeId: vi.fn(),
43+
} as unknown as EventTypeRepository;
44+
45+
mockHeaders = {
46+
"user-agent": "Mozilla/5.0",
47+
"x-forwarded-for": "192.168.1.1",
48+
};
49+
50+
botDetectionService = new BotDetectionService(mockFeaturesRepository, mockEventTypeRepository);
51+
52+
// Reset environment variable
53+
delete process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER;
54+
});
55+
56+
describe("checkBotDetection", () => {
57+
it("should return early if BotID is not enabled at instance level", async () => {
58+
process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER = "0";
59+
60+
await botDetectionService.checkBotDetection({
61+
eventTypeId: 123,
62+
headers: mockHeaders,
63+
});
64+
65+
expect(mockEventTypeRepository.getTeamIdByEventTypeId).not.toHaveBeenCalled();
66+
});
67+
68+
it("should return early if no eventTypeId is provided", async () => {
69+
process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER = "1";
70+
71+
await botDetectionService.checkBotDetection({
72+
headers: mockHeaders,
73+
});
74+
75+
expect(mockEventTypeRepository.getTeamIdByEventTypeId).not.toHaveBeenCalled();
76+
});
77+
78+
it("should return early if event type has no teamId", async () => {
79+
process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER = "1";
80+
vi.mocked(mockEventTypeRepository.getTeamIdByEventTypeId).mockResolvedValue({
81+
teamId: null,
82+
});
83+
84+
await botDetectionService.checkBotDetection({
85+
eventTypeId: 123,
86+
headers: mockHeaders,
87+
});
88+
89+
expect(mockFeaturesRepository.checkIfTeamHasFeature).not.toHaveBeenCalled();
90+
});
91+
92+
it("should return early if BotID feature is not enabled for the team", async () => {
93+
process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER = "1";
94+
vi.mocked(mockEventTypeRepository.getTeamIdByEventTypeId).mockResolvedValue({
95+
teamId: 456,
96+
});
97+
vi.mocked(mockFeaturesRepository.checkIfTeamHasFeature).mockResolvedValue(false);
98+
99+
const { checkBotId } = await import("botid/server");
100+
101+
await botDetectionService.checkBotDetection({
102+
eventTypeId: 123,
103+
headers: mockHeaders,
104+
});
105+
106+
expect(checkBotId).not.toHaveBeenCalled();
107+
});
108+
109+
it("should throw HttpError when a bot is detected", async () => {
110+
process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER = "1";
111+
vi.mocked(mockEventTypeRepository.getTeamIdByEventTypeId).mockResolvedValue({
112+
teamId: 456,
113+
});
114+
vi.mocked(mockFeaturesRepository.checkIfTeamHasFeature).mockResolvedValue(true);
115+
116+
const { checkBotId } = await import("botid/server");
117+
vi.mocked(checkBotId).mockResolvedValue({
118+
isBot: true,
119+
isHuman: false,
120+
isVerifiedBot: false,
121+
verifiedBotName: undefined,
122+
verifiedBotCategory: undefined,
123+
bypassed: false,
124+
classificationReason: "suspicious-patterns",
125+
});
126+
127+
await expect(
128+
botDetectionService.checkBotDetection({
129+
eventTypeId: 123,
130+
headers: mockHeaders,
131+
})
132+
).rejects.toThrow(HttpError);
133+
134+
await expect(
135+
botDetectionService.checkBotDetection({
136+
eventTypeId: 123,
137+
headers: mockHeaders,
138+
})
139+
).rejects.toThrow("Access denied");
140+
});
141+
142+
it("should pass when a human is detected", async () => {
143+
process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER = "1";
144+
vi.mocked(mockEventTypeRepository.getTeamIdByEventTypeId).mockResolvedValue({
145+
teamId: 456,
146+
});
147+
vi.mocked(mockFeaturesRepository.checkIfTeamHasFeature).mockResolvedValue(true);
148+
149+
const { checkBotId } = await import("botid/server");
150+
vi.mocked(checkBotId).mockResolvedValue({
151+
isBot: false,
152+
isHuman: true,
153+
isVerifiedBot: false,
154+
verifiedBotName: undefined,
155+
verifiedBotCategory: undefined,
156+
bypassed: false,
157+
classificationReason: "human-behavior",
158+
});
159+
160+
await expect(
161+
botDetectionService.checkBotDetection({
162+
eventTypeId: 123,
163+
headers: mockHeaders,
164+
})
165+
).resolves.not.toThrow();
166+
});
167+
168+
it("should check feature flag with correct teamId", async () => {
169+
const teamId = 789;
170+
process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER = "1";
171+
vi.mocked(mockEventTypeRepository.getTeamIdByEventTypeId).mockResolvedValue({
172+
teamId,
173+
});
174+
vi.mocked(mockFeaturesRepository.checkIfTeamHasFeature).mockResolvedValue(false);
175+
176+
await botDetectionService.checkBotDetection({
177+
eventTypeId: 123,
178+
headers: mockHeaders,
179+
});
180+
181+
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(teamId, "booker-botid");
182+
});
183+
});
184+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { checkBotId } from "botid/server";
2+
import type { IncomingHttpHeaders } from "http";
3+
4+
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
5+
import { HttpError } from "@calcom/lib/http-error";
6+
import logger from "@calcom/lib/logger";
7+
import type { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository";
8+
9+
interface BotDetectionConfig {
10+
eventTypeId?: number;
11+
headers: IncomingHttpHeaders;
12+
}
13+
14+
const log = logger.getSubLogger({ prefix: ["[BotDetectionService]"] });
15+
16+
export class BotDetectionService {
17+
constructor(
18+
private featuresRepository: FeaturesRepository,
19+
private eventTypeRepository: EventTypeRepository
20+
) {}
21+
22+
private instanceHasBotIdEnabled() {
23+
return process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER === "1";
24+
}
25+
26+
async checkBotDetection(config: BotDetectionConfig): Promise<void> {
27+
if (!this.instanceHasBotIdEnabled()) return;
28+
29+
const { eventTypeId, headers } = config;
30+
31+
// If no eventTypeId provided, skip bot detection
32+
if (!eventTypeId) {
33+
return;
34+
}
35+
36+
// Fetch only the teamId from the event type
37+
const eventType = await this.eventTypeRepository.getTeamIdByEventTypeId({
38+
id: eventTypeId,
39+
});
40+
41+
// Only check for team events
42+
if (!eventType?.teamId) {
43+
return;
44+
}
45+
46+
// Check if BotID feature is enabled for this team (also checks global scope - enabling on all teams)
47+
const isBotIDEnabled = await this.featuresRepository.checkIfTeamHasFeature(
48+
eventType.teamId,
49+
"booker-botid"
50+
);
51+
52+
if (!isBotIDEnabled) {
53+
return;
54+
}
55+
56+
// Perform bot detection
57+
const verification = await checkBotId({
58+
advancedOptions: {
59+
headers,
60+
},
61+
});
62+
63+
// Log verification results with detailed information
64+
const verificationDetails = {
65+
isBot: verification.isBot,
66+
isHuman: verification.isHuman,
67+
isVerifiedBot: verification.isVerifiedBot,
68+
verifiedBotName: verification.verifiedBotName,
69+
verifiedBotCategory: verification.verifiedBotCategory,
70+
bypassed: verification.bypassed,
71+
classificationReason: verification.classificationReason,
72+
teamId: eventType.teamId,
73+
eventTypeId,
74+
};
75+
76+
if (verification.isBot) {
77+
log.warn("Bot detected - blocking request", verificationDetails);
78+
throw new HttpError({ statusCode: 403, message: "Access denied" });
79+
}
80+
}
81+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { BotDetectionService } from "./BotDetectionService";

packages/features/flags/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type AppFlags = {
2828
"tiered-support-chat": boolean;
2929
"calendar-subscription-cache": boolean;
3030
"calendar-subscription-sync": boolean;
31+
"booker-botid": boolean;
3132
};
3233

3334
export type TeamFeatures = Record<keyof AppFlags, boolean>;

packages/features/flags/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const initialData: AppFlags = {
2727
"tiered-support-chat": false,
2828
"calendar-subscription-cache": false,
2929
"calendar-subscription-sync": false,
30+
"booker-botid": false,
3031
};
3132

3233
if (process.env.NEXT_PUBLIC_IS_E2E) {

0 commit comments

Comments
 (0)