Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// took form https://github.com/calcom/cal.diy/blob/e3eaa69339c549365f7749634e5d27f3aef1462f/packages/features/bot-detection/BotDetectionService.ts
import { checkBotId } from "botid/server";
import type { IncomingHttpHeaders } from "node:http";

import type { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";

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;
}

if (!Number.isInteger(eventTypeId) || eventTypeId <= 0) {
throw new ErrorWithCode(
ErrorCode.BadRequest,
`Invalid eventTypeId: ${eventTypeId}. Must be a positive integer.`
);
}

// 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" });
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// took form https://github.com/calcom/cal.diy/blob/e3eaa69339c549365f7749634e5d27f3aef1462f/packages/features/bot-detection/BotDetectionService.ts
import { checkBotId } from "botid/server";
import type { IncomingHttpHeaders } from "node:http";

import type { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";

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;
}

if (!Number.isInteger(eventTypeId) || eventTypeId <= 0) {
throw new ErrorWithCode(
ErrorCode.BadRequest,
`Invalid eventTypeId: ${eventTypeId}. Must be a positive integer.`
);
}

// 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" });
}
}
}
112 changes: 112 additions & 0 deletions recipes/mock-module-exports/tests/cal-com-fixture/expected.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// took form https://github.com/calcom/cal.diy/blob/e3eaa69339c549365f7749634e5d27f3aef1462f/packages/features/webhooks/lib/sendPayload.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

import { WebhookVersion } from "./interface/IWebhookRepository";
import sendPayload from "./sendPayload";

describe("sendPayload", () => {
const mockFetch = vi.fn();

beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockResolvedValue({
ok: true,
status: 200,
});
});

afterEach(() => {
vi.unstubAllGlobals();
vi.resetAllMocks();
});

describe("X-Cal-Webhook-Version header", () => {
it("should include X-Cal-Webhook-Version header with the webhook version", async () => {
const webhook = {
subscriberUrl: "https://example.com/webhook",
appId: null,
payloadTemplate: null,
version: WebhookVersion.V_2021_10_20,
};

await sendPayload("test-secret", "BOOKING_CREATED", new Date().toISOString(), webhook, {
title: "Test Booking",
startTime: "2024-01-01T10:00:00Z",
endTime: "2024-01-01T11:00:00Z",
organizer: {
email: "organizer@example.com",
name: "Organizer",
timeZone: "UTC",
language: { locale: "en" },
},
attendees: [],
type: "test-event",
description: "",
} as unknown as Parameters<typeof sendPayload>[4]);

expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];

expect(url).toBe("https://example.com/webhook");
expect(options.headers).toHaveProperty("X-Cal-Webhook-Version", "2021-10-20");
});

it("should include X-Cal-Signature-256 header alongside version header", async () => {
const webhook = {
subscriberUrl: "https://example.com/webhook",
appId: null,
payloadTemplate: null,
version: WebhookVersion.V_2021_10_20,
};

await sendPayload("test-secret", "BOOKING_CREATED", new Date().toISOString(), webhook, {
title: "Test Booking",
startTime: "2024-01-01T10:00:00Z",
endTime: "2024-01-01T11:00:00Z",
organizer: {
email: "organizer@example.com",
name: "Organizer",
timeZone: "UTC",
language: { locale: "en" },
},
attendees: [],
type: "test-event",
description: "",
} as unknown as Parameters<typeof sendPayload>[4]);

const [, options] = mockFetch.mock.calls[0];

expect(options.headers).toHaveProperty("X-Cal-Signature-256");
expect(options.headers).toHaveProperty("X-Cal-Webhook-Version");
expect(options.headers).toHaveProperty("Content-Type", "application/json");
});

it("should send correct version for different webhook versions", async () => {
// Test with the current version
const webhook = {
subscriberUrl: "https://example.com/webhook",
appId: null,
payloadTemplate: null,
version: WebhookVersion.V_2021_10_20,
};

await sendPayload("test-secret", "BOOKING_CREATED", new Date().toISOString(), webhook, {
title: "Test",
startTime: "2024-01-01T10:00:00Z",
endTime: "2024-01-01T11:00:00Z",
organizer: {
email: "test@example.com",
name: "Test",
timeZone: "UTC",
language: { locale: "en" },
},
attendees: [],
type: "test",
description: "",
} as unknown as Parameters<typeof sendPayload>[4]);

const [, options] = mockFetch.mock.calls[0];
expect(options.headers["X-Cal-Webhook-Version"]).toBe("2021-10-20");
});
});
});
Loading
Loading