Compare commits

...

1 Commits

Author SHA1 Message Date
Matti Nannt
d08c93dd6c fix: harden password reset token handling 2026-03-18 07:51:13 +01:00
3 changed files with 59 additions and 19 deletions

View File

@@ -2,6 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -33,6 +34,7 @@ export const ResetPasswordForm = () => {
const { t } = useTranslation();
const searchParams = useSearchParams();
const router = useRouter();
const [token, setToken] = useState("");
const form = useForm<TPasswordResetForm>({
defaultValues: {
@@ -42,12 +44,24 @@ export const ResetPasswordForm = () => {
resolver: zodResolver(ZPasswordResetForm),
});
useEffect(() => {
const resetToken = searchParams?.get("token");
if (!resetToken) {
return;
}
setToken((currentToken) => currentToken || resetToken);
// Remove the token from the address bar after the page has loaded.
const sanitizedUrl = `${window.location.pathname}${window.location.hash}`;
window.history.replaceState(window.history.state, "", sanitizedUrl);
}, [searchParams]);
const handleSubmit: SubmitHandler<TPasswordResetForm> = async (data) => {
if (data.password !== data.confirmPassword) {
toast.error(t("auth.forgot-password.reset.passwords_do_not_match"));
return;
}
const token = searchParams?.get("token");
if (!token) {
toast.error(t("auth.forgot-password.reset.no_token_provided"));
return;
@@ -94,7 +108,7 @@ export const ResetPasswordForm = () => {
<div>
<Button
type="submit"
disabled={!form.formState.isValid}
disabled={!form.formState.isValid || !token}
className="w-full justify-center"
loading={form.formState.isSubmitting}>
{t("auth.forgot-password.reset_password")}

View File

@@ -234,6 +234,15 @@ const nextConfig = {
},
],
},
{
source: "/auth/forgot-password/reset",
headers: [
{
key: "Referrer-Policy",
value: "no-referrer",
},
],
},
{
source: "/js/(.*)",
headers: [

View File

@@ -8,27 +8,20 @@ test.describe("Authentication Security Tests - Vulnerability Prevention", () =>
let csrfToken: string;
let testUser: { email: string; password: string };
test.beforeEach(async ({ request, users }) => {
// Get CSRF token for authentication requests
const csrfResponse = await request.get("/api/auth/csrf");
const csrfData = await csrfResponse.json();
csrfToken = csrfData.csrfToken;
test("should disable referrers on the password reset page", async ({ request }) => {
const response = await request.get("/auth/forgot-password/reset?token=test-token");
// Create a test user for "existing user" scenarios with unique email
const uniqueId = Date.now() + Math.random();
const userName = "Security Test User";
const userEmail = `security-test-${uniqueId}@example.com`;
await users.create({
name: userName,
email: userEmail,
});
testUser = {
email: userEmail,
password: userName, // The fixture uses the name as password
};
expect(response.status()).toBe(200);
expect(response.headers()["referrer-policy"]).toBe("no-referrer");
});
test.describe("DoS Protection - Password Length Limits", () => {
test.beforeEach(async ({ request }) => {
const csrfResponse = await request.get("/api/auth/csrf");
const csrfData = await csrfResponse.json();
csrfToken = csrfData.csrfToken;
});
test("should handle extremely long passwords without crashing", async ({ request }) => {
const email = "nonexistent-dos-test@example.com"; // Use non-existent email for DoS test
const extremelyLongPassword = "A".repeat(50000); // 50,000 characters
@@ -126,6 +119,24 @@ test.describe("Authentication Security Tests - Vulnerability Prevention", () =>
});
test.describe("Timing Attack Prevention - User Enumeration Protection", () => {
test.beforeEach(async ({ request, users }) => {
const csrfResponse = await request.get("/api/auth/csrf");
const csrfData = await csrfResponse.json();
csrfToken = csrfData.csrfToken;
const uniqueId = Date.now() + Math.random();
const userName = "Security Test User";
const userEmail = `security-test-${uniqueId}@example.com`;
await users.create({
name: userName,
email: userEmail,
});
testUser = {
email: userEmail,
password: userName, // The fixture uses the name as password
};
});
test("should not reveal user existence through response timing differences", async ({ request }) => {
// Helper functions for statistical analysis
const calculateMedian = (values: number[]): number => {
@@ -359,6 +370,12 @@ test.describe("Authentication Security Tests - Vulnerability Prevention", () =>
});
test.describe("Security Headers and Response Safety", () => {
test.beforeEach(async ({ request }) => {
const csrfResponse = await request.get("/api/auth/csrf");
const csrfData = await csrfResponse.json();
csrfToken = csrfData.csrfToken;
});
test("should include security headers in responses", async ({ request }) => {
const response = await request.post("/api/auth/callback/credentials", {
data: {