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.
This commit is contained in:
yiekheng 2026-05-10 17:44:10 +08:00
parent 27b7a3df1f
commit e1ba1da2de
2 changed files with 59 additions and 0 deletions

View File

@ -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,<script>x</script>")).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("/");
});
});

View File

@ -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;
}