Files
HealthLog/src/lib/__tests__/url-safety.test.ts
T
Marc-André Bombeck d8e48a6304 fix(security): reject a same-origin next path that re-resolves protocol-relative
sanitizeSameOriginPath returned pathname+search+hash, so a crafted relative next like /..//evil.com passed the origin check but left a //evil.com pathname that re-resolves to a foreign origin when a consumer re-feeds it to new URL(result, base) or router.push. Re-verify the reconstructed path resolves on-origin; reject to / otherwise. Covers the OIDC callback and the password/passkey login redirect.
2026-07-17 17:30:04 +02:00

61 lines
2.6 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { sanitizeSameOriginPath } from "../url-safety";
const BASE = "https://healthlog.example.com/auth/login";
describe("sanitizeSameOriginPath", () => {
it("passes through a plain same-origin path", () => {
expect(sanitizeSameOriginPath("/dashboard", BASE)).toBe("/dashboard");
});
it("defaults to / when next is null, undefined, or empty", () => {
expect(sanitizeSameOriginPath(null, BASE)).toBe("/");
expect(sanitizeSameOriginPath(undefined, BASE)).toBe("/");
expect(sanitizeSameOriginPath("", BASE)).toBe("/");
});
it("rejects a protocol-relative next (//host)", () => {
expect(sanitizeSameOriginPath("//evil.com", BASE)).toBe("/");
});
it("rejects an absolute next pointing at another origin", () => {
expect(sanitizeSameOriginPath("https://evil.com/phish", BASE)).toBe("/");
});
it("rejects the backslash-normalization bypass (/\\evil.com)", () => {
// WHATWG URL parsing treats a leading backslash as a path separator for
// special schemes, so a naive startsWith("/") && !startsWith("//")
// check would accept this — Next.js's router.push() re-parses the
// string the same way and hard-navigates to the resolved origin.
expect(sanitizeSameOriginPath("/\\evil.com", BASE)).toBe("/");
expect(sanitizeSameOriginPath("\\\\evil.com", BASE)).toBe("/");
});
it("rejects a same-origin next whose pathname collapses to protocol-relative", () => {
// `/..//evil.com` resolves ON-origin here (the `..` collapses at root),
// but its pathname is `//evil.com`. Consumers re-resolve the returned
// value (`new URL(result, base)`, `router.push`), where `//evil.com`
// becomes protocol-relative and escapes to https://evil.com. The
// reconstructed-path re-check must reject these to "/".
expect(sanitizeSameOriginPath("/..//evil.com", BASE)).toBe("/");
expect(sanitizeSameOriginPath("/.//evil.com", BASE)).toBe("/");
expect(sanitizeSameOriginPath("/../..//evil.com", BASE)).toBe("/");
});
it("still allows a legitimate same-origin path with an inner double slash", () => {
// Only a LEADING `//` (protocol-relative) escapes on re-resolve; a double
// slash inside the path stays on-origin and must be preserved.
expect(sanitizeSameOriginPath("/reports/a//b", BASE)).toBe("/reports/a//b");
});
it("preserves a same-origin path's search and hash", () => {
expect(sanitizeSameOriginPath("/settings?tab=security#top", BASE)).toBe(
"/settings?tab=security#top",
);
});
it("falls back to / on an unparseable value", () => {
expect(sanitizeSameOriginPath("https://", BASE)).toBe("/");
});
});