Skip to content

Commit f6f3675

Browse files
authored
chore: CSRF protect cancel functionality (calcom#23439)
* add csrf endpoint * Update booking-pages.e2e.ts * Update zod-utils.ts * fix type error
1 parent 2e9aa01 commit f6f3675

5 files changed

Lines changed: 40 additions & 2 deletions

File tree

apps/web/app/api/cancel/route.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { NextRequest } from "next/server";
55

66
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
77
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
8-
import { bookingCancelInput } from "@calcom/prisma/zod-utils";
8+
import { bookingCancelWithCsrfSchema } from "@calcom/prisma/zod-utils";
99

1010
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
1111

@@ -16,8 +16,16 @@ async function handler(req: NextRequest) {
1616
} catch (error) {
1717
return NextResponse.json({ success: false, message: "Invalid JSON" }, { status: 400 });
1818
}
19+
const bookingData = bookingCancelWithCsrfSchema.parse(appDirRequestBody);
20+
const cookieStore = await cookies();
21+
const cookieToken = cookieStore.get("calcom.csrf_token")?.value;
22+
23+
if (!cookieToken || cookieToken !== bookingData.csrfToken) {
24+
return NextResponse.json({ success: false, message: "Invalid CSRF token" }, { status: 403 });
25+
}
26+
cookieStore.delete("calcom.csrf_token");
27+
1928
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
20-
const bookingData = bookingCancelInput.parse(appDirRequestBody);
2129
const result = await handleCancelBooking({
2230
bookingData,
2331
userId: session?.user?.id || -1,

apps/web/app/api/csrf/route.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { randomBytes } from "crypto";
2+
import { NextResponse } from "next/server";
3+
4+
export async function GET() {
5+
const token = randomBytes(32).toString("hex");
6+
7+
const res = NextResponse.json({ csrfToken: token });
8+
9+
res.cookies.set("calcom.csrf_token", token, {
10+
httpOnly: true,
11+
secure: process.env.NODE_ENV === "production",
12+
sameSite: "lax",
13+
path: "/",
14+
});
15+
16+
return res;
17+
}

apps/web/components/booking/CancelBooking.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,9 @@ export default function CancelBooking(props: Props) {
206206

207207
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
208208

209+
const response = await fetch("/api/csrf", { cache: "no-store" });
210+
const { csrfToken } = await response.json();
211+
209212
const res = await fetch("/api/cancel", {
210213
body: JSON.stringify({
211214
uid: booking?.uid,
@@ -215,6 +218,7 @@ export default function CancelBooking(props: Props) {
215218
seatReferenceUid,
216219
cancelledBy: currentUserEmail,
217220
internalNote: internalNote,
221+
csrfToken,
218222
}),
219223
headers: {
220224
"Content-Type": "application/json",

apps/web/playwright/booking-pages.e2e.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,9 +656,12 @@ test.describe("Event type with disabled cancellation and rescheduling", () => {
656656
});
657657

658658
test("Should prevent cancellation and show an error message", async ({ page }) => {
659+
const csrfTokenResponse = await page.request.get("/api/csrf");
660+
const { csrfToken } = await csrfTokenResponse.json();
659661
const response = await page.request.post("/api/cancel", {
660662
data: {
661663
uid: bookingId,
664+
csrfToken,
662665
},
663666
headers: {
664667
"Content-Type": "application/json",

packages/prisma/zod-utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,12 @@ export const bookingCancelInput = bookingCancelSchema.refine(
309309
"At least one of the following required: 'id', 'uid'."
310310
);
311311

312+
export const bookingCancelWithCsrfSchema = bookingCancelSchema
313+
.extend({
314+
csrfToken: z.string().length(64, "Invalid CSRF token"),
315+
})
316+
.refine((data) => !!data.id || !!data.uid, "At least one of the following required: 'id', 'uid'.");
317+
312318
export const vitalSettingsUpdateSchema = z.object({
313319
connected: z.boolean().optional(),
314320
selectedParam: z.string().optional(),

0 commit comments

Comments
 (0)