From 3daddcd14e995660c0ae4162c298214284d3ce44 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 9 Jul 2026 15:41:03 -0400 Subject: [PATCH] fix: reject protocol-relative path from sanitizeAuthRedirect (open redirect) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- ui/src/lib/auth-redirect-core.ts | 7 ++++++- ui/src/lib/auth-redirect.test.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/ui/src/lib/auth-redirect-core.ts b/ui/src/lib/auth-redirect-core.ts index 91cc0e99..0a138612 100644 --- a/ui/src/lib/auth-redirect-core.ts +++ b/ui/src/lib/auth-redirect-core.ts @@ -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 } diff --git a/ui/src/lib/auth-redirect.test.ts b/ui/src/lib/auth-redirect.test.ts index eacc5063..15ae6870 100644 --- a/ui/src/lib/auth-redirect.test.ts +++ b/ui/src/lib/auth-redirect.test.ts @@ -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")