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:
co-authored by
Devin AI
unknown
Anik Dhabal Babu
parent
a3fbe3731e
commit
8019c414e1
@@ -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({
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user