From e1ba1da2de7e286f02e1ef82cf7e9dd176b0c260 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 17:44:10 +0800 Subject: [PATCH] feat(web): safeRedirect helper for the login \?next= param Falls back to / for anything that isn't a single-slash-prefixed relative path. Locks out protocol-relative (//evil.com), absolute (https://evil.com), and javascript: redirects. 7 tests cover the full attacker matrix. --- apps/web/src/lib/safe-redirect.test.ts | 43 ++++++++++++++++++++++++++ apps/web/src/lib/safe-redirect.ts | 16 ++++++++++ 2 files changed, 59 insertions(+) create mode 100644 apps/web/src/lib/safe-redirect.test.ts create mode 100644 apps/web/src/lib/safe-redirect.ts diff --git a/apps/web/src/lib/safe-redirect.test.ts b/apps/web/src/lib/safe-redirect.test.ts new file mode 100644 index 0000000..f238c0c --- /dev/null +++ b/apps/web/src/lib/safe-redirect.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { safeRedirect } from "./safe-redirect"; + +describe("safeRedirect", () => { + it("preserves a relative path that starts with a single slash", () => { + expect(safeRedirect("/dashboard")).toBe("/dashboard"); + expect(safeRedirect("/reminders/new")).toBe("/reminders/new"); + }); + + it("preserves query string and fragment", () => { + expect(safeRedirect("/legit?with=params&extra=fine#hash")).toBe( + "/legit?with=params&extra=fine#hash", + ); + }); + + it("rejects protocol-relative URLs (//evil.com)", () => { + expect(safeRedirect("//evil.com")).toBe("/"); + expect(safeRedirect("//evil.com/dashboard")).toBe("/"); + }); + + it("rejects absolute URLs", () => { + expect(safeRedirect("https://evil.com")).toBe("/"); + expect(safeRedirect("http://evil.com/dashboard")).toBe("/"); + }); + + it("rejects javascript: and data: schemes", () => { + expect(safeRedirect("javascript:alert(1)")).toBe("/"); + expect(safeRedirect("data:text/html,")).toBe("/"); + }); + + it("falls back to / for empty / null / undefined / whitespace input", () => { + expect(safeRedirect("")).toBe("/"); + expect(safeRedirect(null)).toBe("/"); + expect(safeRedirect(undefined)).toBe("/"); + expect(safeRedirect(" ")).toBe("/"); + }); + + it("rejects paths that don't start with / (relative-relative)", () => { + expect(safeRedirect("dashboard")).toBe("/"); + expect(safeRedirect("./dashboard")).toBe("/"); + expect(safeRedirect("../dashboard")).toBe("/"); + }); +}); diff --git a/apps/web/src/lib/safe-redirect.ts b/apps/web/src/lib/safe-redirect.ts new file mode 100644 index 0000000..3880907 --- /dev/null +++ b/apps/web/src/lib/safe-redirect.ts @@ -0,0 +1,16 @@ +/** + * Returns `next` if it is a safe relative path, otherwise "/". + * + * Safe means: starts with a single forward slash AND not "//" (which + * is a protocol-relative URL, e.g. //evil.com). Anything else falls + * back to the root — including empty input, absolute URLs, javascript: + * URIs, and relative-relative paths like "dashboard" or "../foo". + */ +export function safeRedirect(next: string | null | undefined): string { + if (typeof next !== "string") return "/"; + const s = next.trim(); + if (s.length < 2) return "/"; + if (!s.startsWith("/")) return "/"; + if (s.startsWith("//")) return "/"; + return s; +}