fix: reject protocol-relative path from sanitizeAuthRedirect (open redirect)

sanitizeAuthRedirect returned parsed.pathname+search+hash, which `new URL` can
resolve to a protocol-relative `//host` (e.g. input `/..//evil.com` normalizes
same-origin, passing the origin check, but yields a path starting with `//`).
ClientRedirect / login.tsx feed that path to window.location.replace, so it
navigates cross-origin — an open redirect. Reject any resolved path that is not
a single-leading-slash path (`^/[^/]`), falling back to the default. Adds
coverage for `/..//evil.com`, `/.//evil.com`, and `//evil.com`.
This commit is contained in:
Adam Moussa 2026-07-09 15:41:03 -04:00
parent 179867d120
commit 3daddcd14e
No known key found for this signature in database
2 changed files with 14 additions and 1 deletions

View file

@ -50,7 +50,12 @@ export function sanitizeAuthRedirect(
}
const path = `${parsed.pathname}${parsed.search}${parsed.hash}`
if (!path.startsWith("/") || isBlockedRedirectPath(path)) return fallback
// Reject anything that is not a single-leading-slash path. `new URL` can resolve inputs
// like `/..//evil.com` to a protocol-relative `//evil.com` that still matches the origin
// check above but, when handed to `window.location.replace`, navigates cross-origin.
if (path.startsWith("//") || !/^\/[^/]/.test(path) || isBlockedRedirectPath(path)) {
return fallback
}
return path
}

View file

@ -63,6 +63,14 @@ describe("auth redirect helpers", () => {
)
})
it("rejects inputs that resolve to a protocol-relative path", () => {
// These resolve same-origin (passing the origin check) but yield a `//host`
// path that would navigate cross-origin via window.location.replace.
expect(sanitizeAuthRedirect("/..//evil.com")).toBe(DEFAULT_AUTH_REDIRECT)
expect(sanitizeAuthRedirect("/.//evil.com")).toBe(DEFAULT_AUTH_REDIRECT)
expect(sanitizeAuthRedirect("//evil.com")).toBe(DEFAULT_AUTH_REDIRECT)
})
it("builds a plan sign-in target for the current plan URL", () => {
window.history.pushState({}, "", "/agents/thread-1/plan?from=slack")