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 <me@alexvanandel.com>

* update

* change

* NIT

* minor

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
This commit is contained in:
Alex van Andel
2025-10-01 13:38:29 +00:00
committed by GitHub
co-authored by Devin AI unknown Anik Dhabal Babu
parent a3fbe3731e
commit 8019c414e1
6 changed files with 187 additions and 111 deletions
+17 -1
View File
@@ -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({
+13 -7
View File
@@ -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: "/",
});
@@ -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", {
@@ -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<typeof getServerSideProps>;
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 (
<>
<div className="space-y-6">
<div>
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">
{t("password_updated")}
</h2>
</div>
<Button href="/auth/login" className="w-full justify-center">
{t("login")}
</Button>
</div>
</>
);
}
const submitChangePassword = async ({ password, requestId }: { password: string; requestId: string }) => {
function Expired() {
const { t } = useLocale();
return (
<>
<div className="space-y-6">
<div>
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">{t("whoops")}</h2>
<h2 className="text-emphasis text-center text-3xl font-extrabold">{t("request_is_expired")}</h2>
</div>
<p>{t("request_is_expired_instructions")}</p>
<Link href="/auth/forgot-password" passHref legacyBehavior>
<button
type="button"
className="flex w-full justify-center px-4 py-2 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
{t("try_again")}
</button>
</Link>
</div>
</>
);
}
type FormValues = {
newPassword: string;
csrfToken: string;
};
function PasswordResetForm({
form: formMethods,
requestId,
}: {
form: UseFormReturn<FormValues>;
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 (
<>
<div className="space-y-6">
<div>
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">
{t("password_updated")}
</h2>
</div>
<Button href="/auth/login" className="w-full justify-center">
{t("login")}
</Button>
</div>
</>
);
};
const passwordValue = watch("newPassword");
const isEmpty = passwordValue?.length === 0;
const Expired = () => {
return (
<Form
className="space-y-6"
form={formMethods}
style={
{
"--cal-brand": "#111827",
"--cal-brand-emphasis": "#101010",
"--cal-brand-text": "white",
"--cal-brand-subtle": "#9CA3AF",
} as CSSProperties
}
handleSubmit={async (values) => {
await submitChangePassword({
password: values.newPassword,
csrfToken: values.csrfToken,
requestId,
});
}}>
<input {...formMethods.register("csrfToken")} name="csrfToken" type="hidden" hidden />
<div className="mt-1">
<PasswordField
{...formMethods.register("newPassword", {
minLength: {
message: t("password_hint_min"),
value: 7, // We don't have user here so we can't check if they are admin or not
},
pattern: {
message: "Should contain a number, uppercase and lowercase letters",
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
},
})}
label={t("new_password")}
/>
</div>
<div>
<Button
loading={loading}
color="primary"
type="submit"
disabled={loading || isEmpty}
className="w-full justify-center">
{t("reset_password")}
</Button>
</div>
</Form>
);
}
export default function Page({ requestId, isRequestExpired }: PageProps) {
const { t } = useLocale();
const formMethods = useForm<FormValues>({
defaultValues: {
newPassword: "",
csrfToken: "",
},
});
const {
formState: { isSubmitSuccessful: success },
} = formMethods;
if (isRequestExpired) {
return (
<>
<div className="space-y-6">
<div>
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">{t("whoops")}</h2>
<h2 className="text-emphasis text-center text-3xl font-extrabold">{t("request_is_expired")}</h2>
</div>
<p>{t("request_is_expired_instructions")}</p>
<Link href="/auth/forgot-password" passHref legacyBehavior>
<button
type="button"
className="flex w-full justify-center px-4 py-2 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
{t("try_again")}
</button>
</Link>
</div>
</>
<AuthContainer showLogo heading={t("reset_password")}>
<Expired />
</AuthContainer>
);
};
}
return (
<AuthContainer showLogo heading={!success ? t("reset_password") : undefined}>
{isRequestExpired && <Expired />}
{!isRequestExpired && !success && (
<>
<Form
className="space-y-6"
form={formMethods}
style={
{
"--cal-brand": "#111827",
"--cal-brand-emphasis": "#101010",
"--cal-brand-text": "white",
"--cal-brand-subtle": "#9CA3AF",
} as CSSProperties
}
handleSubmit={async (values) => {
await submitChangePassword({
password: values.new_password,
requestId,
});
}}>
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
<div className="mt-1">
<PasswordField
{...formMethods.register("new_password", {
minLength: {
message: t("password_hint_min"),
value: 7, // We don't have user here so we can't check if they are admin or not
},
pattern: {
message: "Should contain a number, uppercase and lowercase letters",
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
},
})}
label={t("new_password")}
/>
</div>
<div>
<Button
loading={loading}
color="primary"
type="submit"
disabled={loading || isEmpty}
className="w-full justify-center">
{t("reset_password")}
</Button>
</div>
</Form>
</>
)}
{!isRequestExpired && success && (
<>
<Success />
</>
)}
{success ? <Success /> : <PasswordResetForm form={formMethods} requestId={requestId} />}
</AuthContainer>
);
}
@@ -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");
@@ -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 }),
},
};
}