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")