Skip to content

Commit 8019c41

Browse files
emrysaldevin-ai-integration[bot]anikdhabal
authored
chore: CSRF protect forgot-password functionality (calcom#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 calcom#23439 and calcom#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>
1 parent a3fbe37 commit 8019c41

6 files changed

Lines changed: 187 additions & 111 deletions

File tree

apps/web/app/api/auth/reset-password/route.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
22
import { parseRequestData } from "app/api/parseRequestData";
3+
import { cookies } from "next/headers";
34
import type { NextRequest } from "next/server";
45
import { NextResponse } from "next/server";
56
import { z } from "zod";
@@ -10,6 +11,7 @@ import prisma from "@calcom/prisma";
1011
import { IdentityProvider } from "@calcom/prisma/enums";
1112

1213
const passwordResetRequestSchema = z.object({
14+
csrfToken: z.string(),
1315
password: z.string().refine(validPassword, () => ({
1416
message: "Password does not meet the requirements",
1517
})),
@@ -18,8 +20,22 @@ const passwordResetRequestSchema = z.object({
1820

1921
async function handler(req: NextRequest) {
2022
const body = await parseRequestData(req);
23+
const {
24+
password: rawPassword,
25+
requestId: rawRequestId,
26+
csrfToken: submittedToken,
27+
} = passwordResetRequestSchema.parse(body);
28+
const cookieStore = await cookies();
29+
30+
const cookieToken = cookieStore.get("calcom.csrf_token")?.value;
31+
32+
if (submittedToken !== cookieToken) {
33+
return NextResponse.json({ error: "Invalid CSRF token" }, { status: 403 });
34+
}
35+
36+
// token verified, delete the cookie / a resubmit on failure requires a new csrf token.
37+
cookieStore.delete("calcom.csrf_token");
2138

22-
const { password: rawPassword, requestId: rawRequestId } = passwordResetRequestSchema.parse(body);
2339
// rate-limited there is a low, very low chance that a password request stays valid long enough
2440
// to brute force 3.8126967e+40 options.
2541
const maybeRequest = await prisma.resetPasswordRequest.findFirstOrThrow({

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,25 @@ import { NextResponse } from "next/server";
33

44
import { WEBAPP_URL } from "@calcom/lib/constants";
55

6-
export async function GET() {
7-
const token = randomBytes(32).toString("hex");
6+
export async function GET(req: Request) {
7+
const url = new URL(req.url);
8+
const sameSiteParam = url.searchParams.get("sameSite");
9+
10+
const useSecureCookies = WEBAPP_URL.startsWith("https://");
811

12+
// Validate the param, default to "lax"
13+
let sameSite: "lax" | "strict" | "none" = "lax";
14+
if (sameSiteParam === "strict" || (sameSiteParam === "none" && useSecureCookies)) {
15+
sameSite = sameSiteParam;
16+
}
17+
18+
const token = randomBytes(32).toString("hex");
919
const res = NextResponse.json({ csrfToken: token });
1020

11-
// We need this cookie to be accessible from embeds where the booking flow is displayed within an iframe on a different origin.
12-
// For third‑party iframe contexts (embeds on other sites), browsers require SameSite=None and Secure to make the cookie available.
13-
// For local development on http://localhost we fall back to SameSite=Lax to avoid requiring https during development.
14-
const useSecureCookies = WEBAPP_URL.startsWith("https://");
1521
res.cookies.set("calcom.csrf_token", token, {
1622
httpOnly: true,
1723
secure: useSecureCookies,
18-
sameSite: useSecureCookies ? "none" : "lax",
24+
sameSite,
1925
path: "/",
2026
});
2127

apps/web/components/booking/CancelBooking.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ export default function CancelBooking(props: Props) {
266266

267267
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
268268

269-
const response = await fetch("/api/csrf", { cache: "no-store" });
269+
const response = await fetch("/api/csrf?sameSite=none", { cache: "no-store" });
270270
const { csrfToken } = await response.json();
271271

272272
const res = await fetch("/api/cancel", {
Lines changed: 155 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"use client";
22

33
import Link from "next/link";
4-
import type { CSSProperties } from "react";
4+
import { useEffect, useReducer, type CSSProperties } from "react";
5+
import type { UseFormReturn } from "react-hook-form";
56
import { useForm } from "react-hook-form";
67

78
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -16,121 +17,176 @@ import type { getServerSideProps } from "@server/lib/auth/forgot-password/[id]/g
1617

1718
export type PageProps = inferSSRProps<typeof getServerSideProps>;
1819

19-
export default function Page({ requestId, isRequestExpired, csrfToken }: PageProps) {
20+
function Success() {
2021
const { t } = useLocale();
21-
const formMethods = useForm<{ new_password: string }>();
22-
const success = formMethods.formState.isSubmitSuccessful;
23-
const loading = formMethods.formState.isSubmitting;
24-
const passwordValue = formMethods.watch("new_password");
25-
const isEmpty = passwordValue?.length === 0;
22+
return (
23+
<>
24+
<div className="space-y-6">
25+
<div>
26+
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">
27+
{t("password_updated")}
28+
</h2>
29+
</div>
30+
<Button href="/auth/login" className="w-full justify-center">
31+
{t("login")}
32+
</Button>
33+
</div>
34+
</>
35+
);
36+
}
37+
38+
function Expired() {
39+
const { t } = useLocale();
40+
return (
41+
<>
42+
<div className="space-y-6">
43+
<div>
44+
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">{t("whoops")}</h2>
45+
<h2 className="text-emphasis text-center text-3xl font-extrabold">{t("request_is_expired")}</h2>
46+
</div>
47+
<p>{t("request_is_expired_instructions")}</p>
48+
<Link href="/auth/forgot-password" passHref legacyBehavior>
49+
<button
50+
type="button"
51+
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">
52+
{t("try_again")}
53+
</button>
54+
</Link>
55+
</div>
56+
</>
57+
);
58+
}
59+
60+
type FormValues = {
61+
newPassword: string;
62+
csrfToken: string;
63+
};
2664

27-
const submitChangePassword = async ({ password, requestId }: { password: string; requestId: string }) => {
65+
function PasswordResetForm({
66+
form: formMethods,
67+
requestId,
68+
}: {
69+
form: UseFormReturn<FormValues>;
70+
requestId: string;
71+
}) {
72+
const { t } = useLocale();
73+
const [refreshToken, forceRefresh] = useReducer((x) => x + 1, 0);
74+
const {
75+
watch,
76+
setValue,
77+
setError,
78+
formState: { isSubmitting: loading },
79+
} = formMethods;
80+
81+
useEffect(() => {
82+
fetch("/api/csrf", { cache: "no-store" })
83+
.then((res) => res.json())
84+
.then(({ csrfToken }) => setValue("csrfToken", csrfToken))
85+
.catch(() => setValue("csrfToken", ""));
86+
}, [setValue, refreshToken]);
87+
88+
const submitChangePassword = async ({
89+
password,
90+
requestId,
91+
csrfToken,
92+
}: {
93+
password: string;
94+
requestId: string;
95+
csrfToken: string;
96+
}) => {
2897
const res = await fetch("/api/auth/reset-password", {
2998
method: "POST",
30-
body: JSON.stringify({ requestId, password }),
99+
body: JSON.stringify({ requestId, password, csrfToken }),
31100
headers: {
32101
"Content-Type": "application/json",
33102
},
34103
});
35104
const json = await res.json();
36-
if (!res.ok) return formMethods.setError("new_password", { type: "server", message: json.message });
105+
if (!res.ok) {
106+
// if the request fails, we want to force refresh of the CSRF token - this allows resubmit
107+
forceRefresh();
108+
return setError("newPassword", { type: "server", message: json.message });
109+
}
37110
};
38111

39-
const Success = () => {
40-
return (
41-
<>
42-
<div className="space-y-6">
43-
<div>
44-
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">
45-
{t("password_updated")}
46-
</h2>
47-
</div>
48-
<Button href="/auth/login" className="w-full justify-center">
49-
{t("login")}
50-
</Button>
51-
</div>
52-
</>
53-
);
54-
};
112+
const passwordValue = watch("newPassword");
113+
const isEmpty = passwordValue?.length === 0;
55114

56-
const Expired = () => {
115+
return (
116+
<Form
117+
className="space-y-6"
118+
form={formMethods}
119+
style={
120+
{
121+
"--cal-brand": "#111827",
122+
"--cal-brand-emphasis": "#101010",
123+
"--cal-brand-text": "white",
124+
"--cal-brand-subtle": "#9CA3AF",
125+
} as CSSProperties
126+
}
127+
handleSubmit={async (values) => {
128+
await submitChangePassword({
129+
password: values.newPassword,
130+
csrfToken: values.csrfToken,
131+
requestId,
132+
});
133+
}}>
134+
<input {...formMethods.register("csrfToken")} name="csrfToken" type="hidden" hidden />
135+
<div className="mt-1">
136+
<PasswordField
137+
{...formMethods.register("newPassword", {
138+
minLength: {
139+
message: t("password_hint_min"),
140+
value: 7, // We don't have user here so we can't check if they are admin or not
141+
},
142+
pattern: {
143+
message: "Should contain a number, uppercase and lowercase letters",
144+
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
145+
},
146+
})}
147+
label={t("new_password")}
148+
/>
149+
</div>
150+
151+
<div>
152+
<Button
153+
loading={loading}
154+
color="primary"
155+
type="submit"
156+
disabled={loading || isEmpty}
157+
className="w-full justify-center">
158+
{t("reset_password")}
159+
</Button>
160+
</div>
161+
</Form>
162+
);
163+
}
164+
165+
export default function Page({ requestId, isRequestExpired }: PageProps) {
166+
const { t } = useLocale();
167+
168+
const formMethods = useForm<FormValues>({
169+
defaultValues: {
170+
newPassword: "",
171+
csrfToken: "",
172+
},
173+
});
174+
175+
const {
176+
formState: { isSubmitSuccessful: success },
177+
} = formMethods;
178+
179+
if (isRequestExpired) {
57180
return (
58-
<>
59-
<div className="space-y-6">
60-
<div>
61-
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">{t("whoops")}</h2>
62-
<h2 className="text-emphasis text-center text-3xl font-extrabold">{t("request_is_expired")}</h2>
63-
</div>
64-
<p>{t("request_is_expired_instructions")}</p>
65-
<Link href="/auth/forgot-password" passHref legacyBehavior>
66-
<button
67-
type="button"
68-
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">
69-
{t("try_again")}
70-
</button>
71-
</Link>
72-
</div>
73-
</>
181+
<AuthContainer showLogo heading={t("reset_password")}>
182+
<Expired />
183+
</AuthContainer>
74184
);
75-
};
185+
}
76186

77187
return (
78188
<AuthContainer showLogo heading={!success ? t("reset_password") : undefined}>
79-
{isRequestExpired && <Expired />}
80-
{!isRequestExpired && !success && (
81-
<>
82-
<Form
83-
className="space-y-6"
84-
form={formMethods}
85-
style={
86-
{
87-
"--cal-brand": "#111827",
88-
"--cal-brand-emphasis": "#101010",
89-
"--cal-brand-text": "white",
90-
"--cal-brand-subtle": "#9CA3AF",
91-
} as CSSProperties
92-
}
93-
handleSubmit={async (values) => {
94-
await submitChangePassword({
95-
password: values.new_password,
96-
requestId,
97-
});
98-
}}>
99-
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
100-
<div className="mt-1">
101-
<PasswordField
102-
{...formMethods.register("new_password", {
103-
minLength: {
104-
message: t("password_hint_min"),
105-
value: 7, // We don't have user here so we can't check if they are admin or not
106-
},
107-
pattern: {
108-
message: "Should contain a number, uppercase and lowercase letters",
109-
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
110-
},
111-
})}
112-
label={t("new_password")}
113-
/>
114-
</div>
115-
116-
<div>
117-
<Button
118-
loading={loading}
119-
color="primary"
120-
type="submit"
121-
disabled={loading || isEmpty}
122-
className="w-full justify-center">
123-
{t("reset_password")}
124-
</Button>
125-
</div>
126-
</Form>
127-
</>
128-
)}
129-
{!isRequestExpired && success && (
130-
<>
131-
<Success />
132-
</>
133-
)}
189+
{success ? <Success /> : <PasswordResetForm form={formMethods} requestId={requestId} />}
134190
</AuthContainer>
135191
);
136192
}

apps/web/playwright/auth/forgot-password.e2e.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ test.describe("Forgot password", async () => {
7070
// Wait for page to fully load
7171
await page.waitForSelector("text=Reset Password");
7272

73-
await page.fill('input[name="new_password"]', newPassword);
73+
await page.fill('input[name="newPassword"]', newPassword);
7474
await page.click('button[type="submit"]');
7575

7676
await page.waitForSelector("text=Password updated");

apps/web/server/lib/auth/forgot-password/[id]/getServerSideProps.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { GetServerSidePropsContext } from "next";
2-
import { getCsrfToken } from "next-auth/react";
32

43
import prisma from "@calcom/prisma";
54

@@ -28,7 +27,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
2827
props: {
2928
isRequestExpired: !resetPasswordRequest,
3029
requestId: id,
31-
csrfToken: await getCsrfToken({ req: context.req }),
3230
},
3331
};
3432
}

0 commit comments

Comments
 (0)