Skip to content

Commit 342cea4

Browse files
authored
fix: prevent verify-email token consumption by link scanners (#343)
* fix: prevent verify-email token consumption by link scanners Verification was triggered server-side on GET render with no error handling, so corporate email scanners (Safe Links, Proofpoint) could consume the single-use token before the user clicked, leaving the page broken. Move verification to a server action triggered by an explicit "Confirm my email" button (POST), wrap the SDK call in try/catch with server-side logging, and render a dedicated error state when the token is no longer valid. * style: format VerifyEmailResult union per prettier * fix: don't redirect back to auth pages after sign-in After a successful sign-in we were honouring the `redirectUrl` query param even when it pointed to /auth/* pages — most notably /auth/verify-email, where the token has already been consumed by then, landing the user on a broken page. - Sanitize `redirectUrl` in the sign-in form: reject any value that isn't a same-origin absolute path or that targets /auth/*, falling back to /private/my-reports. - Stop the header from emitting `redirectUrl` when the current page is itself under /auth/, so the dirty value never enters the URL in the first place.
1 parent 4b8af33 commit 342cea4

8 files changed

Lines changed: 139 additions & 22 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"use server";
2+
3+
import { sdk } from "@/services/sdk";
4+
5+
export type VerifyEmailResult =
6+
| { success: true }
7+
| { success: false; reason: "invalid" | "unknown" };
8+
9+
export async function verifyEmailAction(token: string): Promise<VerifyEmailResult> {
10+
if (!token) {
11+
return { success: false, reason: "invalid" };
12+
}
13+
14+
try {
15+
await sdk.verifyEmail({
16+
collection: "users",
17+
token,
18+
});
19+
return { success: true };
20+
} catch (error) {
21+
const message = error instanceof Error ? error.message : String(error);
22+
const isInvalid = /invalid/i.test(message) || /403/.test(message);
23+
24+
console.error("[verify-email] verification failed", {
25+
reason: isInvalid ? "invalid" : "unknown",
26+
message,
27+
});
28+
29+
return { success: false, reason: isInvalid ? "invalid" : "unknown" };
30+
}
31+
}

client/src/app/(frontend)/[locale]/(app)/auth/verify-email/page.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import { VerifyEmail } from "@/containers/auth/verify-email";
77

88
import { redirect } from "@/i18n/navigation";
99

10-
import { sdk } from "@/services/sdk";
11-
1210
export const dynamic = "force-dynamic";
1311

1412
type Params = Promise<{ locale: Locale }>;
@@ -41,17 +39,10 @@ export default async function VerifyEmailPage({
4139
});
4240
}
4341

44-
if (!!token && !Array.isArray(token)) {
45-
await sdk.verifyEmail({
46-
collection: "users",
47-
token,
48-
});
49-
}
50-
5142
return (
5243
<section className="flex grow items-center justify-center">
5344
<div className="mx-auto w-full max-w-lg">
54-
<VerifyEmail />
45+
<VerifyEmail token={token as string} />
5546
</div>
5647
</section>
5748
);

client/src/containers/auth/sign-in.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@ export type SignInFormProps = React.ComponentProps<"div"> & {
2727
redirectUrl?: string;
2828
};
2929

30+
const DEFAULT_REDIRECT = "/private/my-reports";
31+
32+
function safeRedirect(url: string | null | undefined): string {
33+
if (!url) return DEFAULT_REDIRECT;
34+
// Reject anything that isn't a same-origin absolute path
35+
if (!url.startsWith("/") || url.startsWith("//")) return DEFAULT_REDIRECT;
36+
// Reject auth pages — signing in shouldn't bounce back to verify-email,
37+
// sign-in itself, sign-up, password flows, etc.
38+
const pathname = url.split("?")[0];
39+
if (pathname.startsWith("/auth/")) return DEFAULT_REDIRECT;
40+
return url;
41+
}
42+
3043
export function SignInForm(props: SignInFormProps) {
3144
const router = useRouter();
3245
const searchParams = useSearchParams();
@@ -55,9 +68,7 @@ export function SignInForm(props: SignInFormProps) {
5568
if (r?.error) {
5669
throw new Error(r.error);
5770
}
58-
router.push(
59-
props.redirectUrl || searchParams.get("redirectUrl") || "/private/my-reports",
60-
);
71+
router.push(safeRedirect(props.redirectUrl || searchParams.get("redirectUrl")));
6172
}),
6273
{
6374
loading: t("auth-toast-logging-in"),

client/src/containers/auth/verify-email.tsx

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,92 @@
11
"use client";
22

3+
import { useState, useTransition } from "react";
4+
35
import { useTranslations } from "next-intl";
46

7+
import { verifyEmailAction } from "@/app/(frontend)/[locale]/(app)/auth/verify-email/actions";
8+
59
import { Button } from "@/components/ui/button";
610
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
711

812
import { Link } from "@/i18n/navigation";
913

10-
export function VerifyEmail(props: React.ComponentProps<"div">) {
14+
type Status = "idle" | "success" | "error";
15+
16+
interface VerifyEmailProps extends React.ComponentProps<"div"> {
17+
token: string;
18+
}
19+
20+
export function VerifyEmail({ token, ...props }: VerifyEmailProps) {
1121
const t = useTranslations();
22+
const [status, setStatus] = useState<Status>("idle");
23+
const [isPending, startTransition] = useTransition();
24+
25+
const handleConfirm = () => {
26+
startTransition(async () => {
27+
const result = await verifyEmailAction(token);
28+
setStatus(result.success ? "success" : "error");
29+
});
30+
};
31+
32+
if (status === "success") {
33+
return (
34+
<Card className="border-none shadow-none" {...props}>
35+
<CardHeader>
36+
<CardTitle className="text-3xl text-primary">{t("auth-verify-email-title")}</CardTitle>
37+
<CardDescription className="font-medium">
38+
{t("auth-verify-email-description")}
39+
</CardDescription>
40+
</CardHeader>
41+
<CardContent>
42+
<Link href="/auth/sign-in">
43+
<Button size="lg" className="w-full">
44+
{t("auth-link-back-to-sign-in")}
45+
</Button>
46+
</Link>
47+
</CardContent>
48+
</Card>
49+
);
50+
}
51+
52+
if (status === "error") {
53+
return (
54+
<Card className="border-none shadow-none" {...props}>
55+
<CardHeader>
56+
<CardTitle className="text-3xl text-primary">
57+
{t("auth-verify-email-error-title")}
58+
</CardTitle>
59+
<CardDescription className="font-medium">
60+
{t("auth-verify-email-error-description")}
61+
</CardDescription>
62+
</CardHeader>
63+
<CardContent>
64+
<Link href="/auth/sign-in">
65+
<Button size="lg" className="w-full">
66+
{t("auth-link-back-to-sign-in")}
67+
</Button>
68+
</Link>
69+
</CardContent>
70+
</Card>
71+
);
72+
}
1273

1374
return (
1475
<Card className="border-none shadow-none" {...props}>
1576
<CardHeader>
16-
<CardTitle className="text-3xl text-primary">{t("auth-verify-email-title")}</CardTitle>
77+
<CardTitle className="text-3xl text-primary">
78+
{t("auth-verify-email-confirm-title")}
79+
</CardTitle>
1780
<CardDescription className="font-medium">
18-
{t("auth-verify-email-description")}
81+
{t("auth-verify-email-confirm-description")}
1982
</CardDescription>
2083
</CardHeader>
2184
<CardContent>
22-
<Link href="/auth/sign-in">
23-
<Button size="lg" className="w-full">
24-
{t("auth-link-back-to-sign-in")}
25-
</Button>
26-
</Link>
85+
<Button size="lg" className="w-full" onClick={handleConfirm} disabled={isPending}>
86+
{isPending
87+
? t("auth-verify-email-confirm-button-pending")
88+
: t("auth-verify-email-confirm-button")}
89+
</Button>
2790
</CardContent>
2891
</Card>
2992
);

client/src/containers/header/auth/desktop.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ const AuthHeader = () => {
2424
const pathname = usePathname();
2525
const searchParams = useSearchParams();
2626
const currentUrl = `${pathname}${searchParams?.toString() ? `?${searchParams.toString()}` : ""}`;
27-
const signInHref = `/auth/sign-in?redirectUrl=${encodeURIComponent(currentUrl)}`;
27+
const isAuthPage = pathname.startsWith("/auth/");
28+
const signInHref = isAuthPage
29+
? "/auth/sign-in"
30+
: `/auth/sign-in?redirectUrl=${encodeURIComponent(currentUrl)}`;
2831

2932
if (session && session.user.collection === "users") {
3033
// Get user initials for fallback

client/src/i18n/translations/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,12 @@
189189
"auth-reset-password-description": "Enter your new password below",
190190
"auth-verify-email-title": "Your email has been verified",
191191
"auth-verify-email-description": "You can now sign in to your account using your credentials.",
192+
"auth-verify-email-confirm-title": "Verify your email",
193+
"auth-verify-email-confirm-description": "Click the button below to confirm your email address and activate your account.",
194+
"auth-verify-email-confirm-button": "Confirm my email",
195+
"auth-verify-email-confirm-button-pending": "Verifying...",
196+
"auth-verify-email-error-title": "Verification link is no longer valid",
197+
"auth-verify-email-error-description": "This link may have already been used or has expired. Try signing in to access your account.",
192198
"auth-check-email-title": "Check your email",
193199
"auth-check-email-description": "An email with instruction has been sent to your inbox. Please check your email. If you haven't received it, feel free to request another one.",
194200
"auth-field-name": "Name",

client/src/i18n/translations/es.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,12 @@
189189
"auth-reset-password-description": "Introduce tu nueva contraseña a continuación",
190190
"auth-verify-email-title": "Tu correo electrónico ha sido verificado",
191191
"auth-verify-email-description": "Ya puedes iniciar sesión en tu cuenta usando tus credenciales.",
192+
"auth-verify-email-confirm-title": "Verifica tu correo electrónico",
193+
"auth-verify-email-confirm-description": "Haz clic en el botón para confirmar tu dirección de correo y activar tu cuenta.",
194+
"auth-verify-email-confirm-button": "Confirmar mi correo",
195+
"auth-verify-email-confirm-button-pending": "Verificando...",
196+
"auth-verify-email-error-title": "El enlace de verificación ya no es válido",
197+
"auth-verify-email-error-description": "Es posible que este enlace ya haya sido utilizado o haya expirado. Intenta iniciar sesión para acceder a tu cuenta.",
192198
"auth-check-email-title": "Revisa tu correo electrónico",
193199
"auth-check-email-description": "Se ha enviado un correo con instrucciones a tu bandeja de entrada. Por favor, revisa tu correo electrónico. Si no lo has recibido, puedes solicitar otro.",
194200
"auth-field-name": "Nombre",

client/src/i18n/translations/pt.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,12 @@
189189
"auth-reset-password-description": "Digite sua nova senha abaixo",
190190
"auth-verify-email-title": "Seu e-mail foi verificado",
191191
"auth-verify-email-description": "Agora você pode fazer login em sua conta usando suas credenciais.",
192+
"auth-verify-email-confirm-title": "Verifique seu e-mail",
193+
"auth-verify-email-confirm-description": "Clique no botão abaixo para confirmar seu endereço de e-mail e ativar sua conta.",
194+
"auth-verify-email-confirm-button": "Confirmar meu e-mail",
195+
"auth-verify-email-confirm-button-pending": "Verificando...",
196+
"auth-verify-email-error-title": "O link de verificação não é mais válido",
197+
"auth-verify-email-error-description": "Este link pode já ter sido utilizado ou expirou. Tente fazer login para acessar sua conta.",
192198
"auth-check-email-title": "Verifique seu e-mail",
193199
"auth-check-email-description": "Um e-mail com instruções foi enviado para sua caixa de entrada. Por favor, verifique seu e-mail. Se não recebeu, pode solicitar outro.",
194200
"auth-field-name": "Nome",

0 commit comments

Comments
 (0)