Files
HealthLog/src/lib/url-safety.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

42 lines
1.8 KiB
TypeScript

/**
* Isomorphic (client + server) — no `node:crypto`/`jose`/server-only imports,
* so client components can pull this in without dragging server auth code
* into the browser bundle.
*/
/**
* Resolve a caller-supplied "redirect back to" path against a base URL and
* only accept it if it stays on that origin. A plain `startsWith("/") &&
* !startsWith("//")` string check is not enough — WHATWG URL parsing treats
* a leading backslash as a path separator for special schemes, so a value
* like `/\evil.com` passes that check but resolves off-origin (confirmed:
* `router.push()` on such a value drives Next.js's client router into a
* real cross-origin `location.assign()`, not just client-side History
* state). Resolving through `new URL()` first and comparing the real
* origin closes that class of bypass instead of pattern-matching the raw
* string.
*/
export function sanitizeSameOriginPath(
next: string | null | undefined,
baseUrl: string,
): string {
if (!next) return "/";
try {
const base = new URL(baseUrl);
const resolved = new URL(next, baseUrl);
if (resolved.origin !== base.origin) return "/";
const result = `${resolved.pathname}${resolved.search}${resolved.hash}`;
// Consumers commonly RE-RESOLVE the returned value against the origin
// (`new URL(result, base)` in the OIDC callback redirect, `router.push()`
// on the login page). A same-origin `next` can still leave a pathname
// like `//evil.com` — e.g. `/..//evil.com` resolves on-origin here (the
// `..` collapses at root) yet its pathname is protocol-relative, so the
// re-resolve escapes to `https://evil.com`. Re-verify that the
// reconstructed path itself still resolves on-origin; reject otherwise.
if (new URL(result, base).origin !== base.origin) return "/";
return result;
} catch {
return "/";
}
}