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:
parent
27b7a3df1f
commit
e1ba1da2de
43
apps/web/src/lib/safe-redirect.test.ts
Normal file
43
apps/web/src/lib/safe-redirect.test.ts
Normal 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("/");
|
||||||
|
});
|
||||||
|
});
|
||||||
16
apps/web/src/lib/safe-redirect.ts
Normal file
16
apps/web/src/lib/safe-redirect.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user