feat: botid enabled on api/book/event api route (#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
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BotDetectionService } from "./BotDetectionService";
|
||||
@@ -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<keyof AppFlags, boolean>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1422,4 +1422,15 @@ export class EventTypeRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamIdByEventTypeId({ id }: { id: number }) {
|
||||
return await this.prismaClient.eventType.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user