From 8019c414e126ea698a9a1460d808474a35586803 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Wed, 1 Oct 2025 14:38:29 +0100 Subject: [PATCH] chore: CSRF protect forgot-password functionality (#22361) * chore: CSRF protect forgot-password functionality * feat: implement conditional sameSite cookie setting for CSRF tokens - Use WEBAPP_URL to determine secure cookie settings - Set sameSite to 'none' for HTTPS environments to support cross-origin scenarios - Fall back to 'lax' for HTTP development environments - Follows patterns from PRs #23439 and #23556 Co-Authored-By: alex@cal.com * update * change * NIT * minor --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: unknown Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> --- apps/web/app/api/auth/reset-password/route.ts | 18 +- apps/web/app/api/csrf/route.ts | 20 +- apps/web/components/booking/CancelBooking.tsx | 2 +- .../[id]/forgot-password-single-view.tsx | 254 +++++++++++------- .../playwright/auth/forgot-password.e2e.ts | 2 +- .../[id]/getServerSideProps.tsx | 2 - 6 files changed, 187 insertions(+), 111 deletions(-) diff --git a/apps/web/app/api/auth/reset-password/route.ts b/apps/web/app/api/auth/reset-password/route.ts index 57b761bf65..1ebdc5da4b 100644 --- a/apps/web/app/api/auth/reset-password/route.ts +++ b/apps/web/app/api/auth/reset-password/route.ts @@ -1,5 +1,6 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; import { parseRequestData } from "app/api/parseRequestData"; +import { cookies } from "next/headers"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { z } from "zod"; @@ -10,6 +11,7 @@ import prisma from "@calcom/prisma"; import { IdentityProvider } from "@calcom/prisma/enums"; const passwordResetRequestSchema = z.object({ + csrfToken: z.string(), password: z.string().refine(validPassword, () => ({ message: "Password does not meet the requirements", })), @@ -18,8 +20,22 @@ const passwordResetRequestSchema = z.object({ async function handler(req: NextRequest) { const body = await parseRequestData(req); + const { + password: rawPassword, + requestId: rawRequestId, + csrfToken: submittedToken, + } = passwordResetRequestSchema.parse(body); + const cookieStore = await cookies(); + + const cookieToken = cookieStore.get("calcom.csrf_token")?.value; + + if (submittedToken !== cookieToken) { + return NextResponse.json({ error: "Invalid CSRF token" }, { status: 403 }); + } + + // token verified, delete the cookie / a resubmit on failure requires a new csrf token. + cookieStore.delete("calcom.csrf_token"); - const { password: rawPassword, requestId: rawRequestId } = passwordResetRequestSchema.parse(body); // rate-limited there is a low, very low chance that a password request stays valid long enough // to brute force 3.8126967e+40 options. const maybeRequest = await prisma.resetPasswordRequest.findFirstOrThrow({ diff --git a/apps/web/app/api/csrf/route.ts b/apps/web/app/api/csrf/route.ts index 33d5dbc9b8..21bd767993 100644 --- a/apps/web/app/api/csrf/route.ts +++ b/apps/web/app/api/csrf/route.ts @@ -3,19 +3,25 @@ import { NextResponse } from "next/server"; import { WEBAPP_URL } from "@calcom/lib/constants"; -export async function GET() { - const token = randomBytes(32).toString("hex"); +export async function GET(req: Request) { + const url = new URL(req.url); + const sameSiteParam = url.searchParams.get("sameSite"); + const useSecureCookies = WEBAPP_URL.startsWith("https://"); + + // Validate the param, default to "lax" + let sameSite: "lax" | "strict" | "none" = "lax"; + if (sameSiteParam === "strict" || (sameSiteParam === "none" && useSecureCookies)) { + sameSite = sameSiteParam; + } + + const token = randomBytes(32).toString("hex"); const res = NextResponse.json({ csrfToken: token }); - // We need this cookie to be accessible from embeds where the booking flow is displayed within an iframe on a different origin. - // For third‑party iframe contexts (embeds on other sites), browsers require SameSite=None and Secure to make the cookie available. - // For local development on http://localhost we fall back to SameSite=Lax to avoid requiring https during development. - const useSecureCookies = WEBAPP_URL.startsWith("https://"); res.cookies.set("calcom.csrf_token", token, { httpOnly: true, secure: useSecureCookies, - sameSite: useSecureCookies ? "none" : "lax", + sameSite, path: "/", }); diff --git a/apps/web/components/booking/CancelBooking.tsx b/apps/web/components/booking/CancelBooking.tsx index 0376f3291e..26b084bbda 100644 --- a/apps/web/components/booking/CancelBooking.tsx +++ b/apps/web/components/booking/CancelBooking.tsx @@ -266,7 +266,7 @@ export default function CancelBooking(props: Props) { telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters()); - const response = await fetch("/api/csrf", { cache: "no-store" }); + const response = await fetch("/api/csrf?sameSite=none", { cache: "no-store" }); const { csrfToken } = await response.json(); const res = await fetch("/api/cancel", { diff --git a/apps/web/modules/auth/forgot-password/[id]/forgot-password-single-view.tsx b/apps/web/modules/auth/forgot-password/[id]/forgot-password-single-view.tsx index 0fb70af953..6266131dc0 100644 --- a/apps/web/modules/auth/forgot-password/[id]/forgot-password-single-view.tsx +++ b/apps/web/modules/auth/forgot-password/[id]/forgot-password-single-view.tsx @@ -1,7 +1,8 @@ "use client"; import Link from "next/link"; -import type { CSSProperties } from "react"; +import { useEffect, useReducer, type CSSProperties } from "react"; +import type { UseFormReturn } from "react-hook-form"; import { useForm } from "react-hook-form"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -16,121 +17,176 @@ import type { getServerSideProps } from "@server/lib/auth/forgot-password/[id]/g export type PageProps = inferSSRProps; -export default function Page({ requestId, isRequestExpired, csrfToken }: PageProps) { +function Success() { const { t } = useLocale(); - const formMethods = useForm<{ new_password: string }>(); - const success = formMethods.formState.isSubmitSuccessful; - const loading = formMethods.formState.isSubmitting; - const passwordValue = formMethods.watch("new_password"); - const isEmpty = passwordValue?.length === 0; + return ( + <> +
+
+

+ {t("password_updated")} +

+
+ +
+ + ); +} - const submitChangePassword = async ({ password, requestId }: { password: string; requestId: string }) => { +function Expired() { + const { t } = useLocale(); + return ( + <> +
+
+

{t("whoops")}

+

{t("request_is_expired")}

+
+

{t("request_is_expired_instructions")}

+ + + +
+ + ); +} + +type FormValues = { + newPassword: string; + csrfToken: string; +}; + +function PasswordResetForm({ + form: formMethods, + requestId, +}: { + form: UseFormReturn; + requestId: string; +}) { + const { t } = useLocale(); + const [refreshToken, forceRefresh] = useReducer((x) => x + 1, 0); + const { + watch, + setValue, + setError, + formState: { isSubmitting: loading }, + } = formMethods; + + useEffect(() => { + fetch("/api/csrf", { cache: "no-store" }) + .then((res) => res.json()) + .then(({ csrfToken }) => setValue("csrfToken", csrfToken)) + .catch(() => setValue("csrfToken", "")); + }, [setValue, refreshToken]); + + const submitChangePassword = async ({ + password, + requestId, + csrfToken, + }: { + password: string; + requestId: string; + csrfToken: string; + }) => { const res = await fetch("/api/auth/reset-password", { method: "POST", - body: JSON.stringify({ requestId, password }), + body: JSON.stringify({ requestId, password, csrfToken }), headers: { "Content-Type": "application/json", }, }); const json = await res.json(); - if (!res.ok) return formMethods.setError("new_password", { type: "server", message: json.message }); + if (!res.ok) { + // if the request fails, we want to force refresh of the CSRF token - this allows resubmit + forceRefresh(); + return setError("newPassword", { type: "server", message: json.message }); + } }; - const Success = () => { - return ( - <> -
-
-

- {t("password_updated")} -

-
- -
- - ); - }; + const passwordValue = watch("newPassword"); + const isEmpty = passwordValue?.length === 0; - const Expired = () => { + return ( +
{ + await submitChangePassword({ + password: values.newPassword, + csrfToken: values.csrfToken, + requestId, + }); + }}> + +
+ +
+ +
+ +
+
+ ); +} + +export default function Page({ requestId, isRequestExpired }: PageProps) { + const { t } = useLocale(); + + const formMethods = useForm({ + defaultValues: { + newPassword: "", + csrfToken: "", + }, + }); + + const { + formState: { isSubmitSuccessful: success }, + } = formMethods; + + if (isRequestExpired) { return ( - <> -
-
-

{t("whoops")}

-

{t("request_is_expired")}

-
-

{t("request_is_expired_instructions")}

- - - -
- + + + ); - }; + } return ( - {isRequestExpired && } - {!isRequestExpired && !success && ( - <> -
{ - await submitChangePassword({ - password: values.new_password, - requestId, - }); - }}> - -
- -
- -
- -
-
- - )} - {!isRequestExpired && success && ( - <> - - - )} + {success ? : }
); } diff --git a/apps/web/playwright/auth/forgot-password.e2e.ts b/apps/web/playwright/auth/forgot-password.e2e.ts index 33a775ad57..1b62be7de9 100644 --- a/apps/web/playwright/auth/forgot-password.e2e.ts +++ b/apps/web/playwright/auth/forgot-password.e2e.ts @@ -70,7 +70,7 @@ test.describe("Forgot password", async () => { // Wait for page to fully load await page.waitForSelector("text=Reset Password"); - await page.fill('input[name="new_password"]', newPassword); + await page.fill('input[name="newPassword"]', newPassword); await page.click('button[type="submit"]'); await page.waitForSelector("text=Password updated"); diff --git a/apps/web/server/lib/auth/forgot-password/[id]/getServerSideProps.tsx b/apps/web/server/lib/auth/forgot-password/[id]/getServerSideProps.tsx index c511604ad9..8bc44d0353 100644 --- a/apps/web/server/lib/auth/forgot-password/[id]/getServerSideProps.tsx +++ b/apps/web/server/lib/auth/forgot-password/[id]/getServerSideProps.tsx @@ -1,5 +1,4 @@ import type { GetServerSidePropsContext } from "next"; -import { getCsrfToken } from "next-auth/react"; import prisma from "@calcom/prisma"; @@ -28,7 +27,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { props: { isRequestExpired: !resetPasswordRequest, requestId: id, - csrfToken: await getCsrfToken({ req: context.req }), }, }; }