deer-flow/frontend/src/core/auth/next-path.ts
Hyeonsang Cho 9c00fd839a
fix(frontend): return SSO sign-ins to their next path (#7467)
* fix(frontend): return SSO sign-ins to their next path

The (auth) layout redirected every signed-in request to /workspace. The
Gateway sets the session cookie on the same redirect that lands on
/auth/callback?next=..., so the callback page never rendered and every SSO
sign-in landed on the workspace instead of the page that asked for it. A
signed-in visit to /login?next=... lost its target the same way.

Layouts cannot read searchParams, so the layout now renders the page for a
signed-in session and each page sends the user on: the callback and login to
the validated next path, setup to the workspace. The login page shows a
short wait message instead of the form while it redirects, and uses
router.replace so Back does not return to it.

* docs(changelog): reference #7467 for the SSO next-path fix

* fix(auth): reject control characters in the next redirect path

Browsers delete tab, LF and CR from a URL before parsing it, so a next of
"/\n//evil.example" passed both validators' "//" check and then navigated
to //evil.example. The login page's post-sign-in push already followed it,
and the signed-in login and SSO callback redirects reach it too.

validateAuthNextPath and validate_next_param now reject every C0 control
character and DEL. Percent-encoded forms are left alone: URL parsers do
not decode them, so they stay on the local origin.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-26 19:24:35 +08:00

43 lines
1.1 KiB
TypeScript

export const DEFAULT_AUTH_NEXT_PATH = "/workspace";
// The URL parser deletes tab, LF and CR before parsing, so `/\n//evil.example`
// passes the `//` check below and then navigates to `//evil.example`.
// Reject every C0 control and DEL rather than only the three it strips.
function hasControlCharacter(value: string): boolean {
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index);
if (code < 0x20 || code === 0x7f) {
return true;
}
}
return false;
}
export function validateAuthNextPath(
nextPath: string | null | undefined,
): string | null {
if (!nextPath) {
return null;
}
if (!nextPath.startsWith("/")) {
return null;
}
if (nextPath.startsWith("//")) {
return null;
}
if (nextPath.includes("\\") || nextPath.includes(":")) {
return null;
}
if (hasControlCharacter(nextPath)) {
return null;
}
return nextPath;
}
export function resolveAuthNextPath(
nextPath: string | null | undefined,
fallback = DEFAULT_AUTH_NEXT_PATH,
): string {
return validateAuthNextPath(nextPath) ?? fallback;
}