From f6f36750ecdfd2c77d586c6b95fb2c0890420a7a Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Tue, 2 Sep 2025 04:48:50 +0530 Subject: [PATCH] chore: CSRF protect cancel functionality (#23439) * add csrf endpoint * Update booking-pages.e2e.ts * Update zod-utils.ts * fix type error --- apps/web/app/api/cancel/route.ts | 12 ++++++++++-- apps/web/app/api/csrf/route.ts | 17 +++++++++++++++++ apps/web/components/booking/CancelBooking.tsx | 4 ++++ apps/web/playwright/booking-pages.e2e.ts | 3 +++ packages/prisma/zod-utils.ts | 6 ++++++ 5 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/api/csrf/route.ts diff --git a/apps/web/app/api/cancel/route.ts b/apps/web/app/api/cancel/route.ts index 5ed42c280c..46da34f7e7 100644 --- a/apps/web/app/api/cancel/route.ts +++ b/apps/web/app/api/cancel/route.ts @@ -5,7 +5,7 @@ import type { NextRequest } from "next/server"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking"; -import { bookingCancelInput } from "@calcom/prisma/zod-utils"; +import { bookingCancelWithCsrfSchema } from "@calcom/prisma/zod-utils"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; @@ -16,8 +16,16 @@ async function handler(req: NextRequest) { } catch (error) { return NextResponse.json({ success: false, message: "Invalid JSON" }, { status: 400 }); } + const bookingData = bookingCancelWithCsrfSchema.parse(appDirRequestBody); + const cookieStore = await cookies(); + const cookieToken = cookieStore.get("calcom.csrf_token")?.value; + + if (!cookieToken || cookieToken !== bookingData.csrfToken) { + return NextResponse.json({ success: false, message: "Invalid CSRF token" }, { status: 403 }); + } + cookieStore.delete("calcom.csrf_token"); + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); - const bookingData = bookingCancelInput.parse(appDirRequestBody); const result = await handleCancelBooking({ bookingData, userId: session?.user?.id || -1, diff --git a/apps/web/app/api/csrf/route.ts b/apps/web/app/api/csrf/route.ts new file mode 100644 index 0000000000..86d4ba410c --- /dev/null +++ b/apps/web/app/api/csrf/route.ts @@ -0,0 +1,17 @@ +import { randomBytes } from "crypto"; +import { NextResponse } from "next/server"; + +export async function GET() { + const token = randomBytes(32).toString("hex"); + + const res = NextResponse.json({ csrfToken: token }); + + res.cookies.set("calcom.csrf_token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + }); + + return res; +} diff --git a/apps/web/components/booking/CancelBooking.tsx b/apps/web/components/booking/CancelBooking.tsx index a521515eb5..bd57791e52 100644 --- a/apps/web/components/booking/CancelBooking.tsx +++ b/apps/web/components/booking/CancelBooking.tsx @@ -206,6 +206,9 @@ export default function CancelBooking(props: Props) { telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters()); + const response = await fetch("/api/csrf", { cache: "no-store" }); + const { csrfToken } = await response.json(); + const res = await fetch("/api/cancel", { body: JSON.stringify({ uid: booking?.uid, @@ -215,6 +218,7 @@ export default function CancelBooking(props: Props) { seatReferenceUid, cancelledBy: currentUserEmail, internalNote: internalNote, + csrfToken, }), headers: { "Content-Type": "application/json", diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index f0643d913a..2a4fb90e9f 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -656,9 +656,12 @@ test.describe("Event type with disabled cancellation and rescheduling", () => { }); test("Should prevent cancellation and show an error message", async ({ page }) => { + const csrfTokenResponse = await page.request.get("/api/csrf"); + const { csrfToken } = await csrfTokenResponse.json(); const response = await page.request.post("/api/cancel", { data: { uid: bookingId, + csrfToken, }, headers: { "Content-Type": "application/json", diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index c2f34e24c4..9ce5e33d8f 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -309,6 +309,12 @@ export const bookingCancelInput = bookingCancelSchema.refine( "At least one of the following required: 'id', 'uid'." ); +export const bookingCancelWithCsrfSchema = bookingCancelSchema + .extend({ + csrfToken: z.string().length(64, "Invalid CSRF token"), + }) + .refine((data) => !!data.id || !!data.uid, "At least one of the following required: 'id', 'uid'."); + export const vitalSettingsUpdateSchema = z.object({ connected: z.boolean().optional(), selectedParam: z.string().optional(),