Skip to content

Commit 137f1ff

Browse files
committed
Normalize login return destinations
1 parent c9cc5e7 commit 137f1ff

4 files changed

Lines changed: 70 additions & 9 deletions

File tree

‎apps/cloud/src/auth/return-to.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ describe("isSafeReturnTo", () => {
2323
const unsafe = [
2424
"https://evil.example", // absolute URL — off-origin redirect
2525
"//evil.example", // protocol-relative — same thing in disguise
26+
"/\\evil.example", // browsers normalize backslashes to slashes
27+
"/\t/evil.example", // URL parsing strips embedded tabs
28+
"/\n/evil.example",
29+
"/\r/evil.example",
30+
"/safe/../api/auth/me", // normalized API destination
31+
"/safe/%2e%2e/api/auth/me",
32+
"/api/oauth/callback/../logout",
2633
"/api/auth/logout", // API endpoints are never a landing page
2734
"/api/oauth/callback/extra?state=oauth-state", // only the exact OAuth callback resumes
2835
"/api", // bare /api too
@@ -47,6 +54,9 @@ describe("safeReturnTo", () => {
4754
it("passes a safe path through", () => {
4855
expect(safeReturnTo("/tools")).toBe("/tools");
4956
});
57+
it("returns the canonical destination while preserving its query and fragment", () => {
58+
expect(safeReturnTo("/old/../tools?view=all#list")).toBe("/tools?view=all#list");
59+
});
5060
it("nulls unsafe and absent values", () => {
5161
expect(safeReturnTo("https://evil.example")).toBeNull();
5262
expect(safeReturnTo(null)).toBeNull();

‎apps/cloud/src/auth/return-to.ts‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,29 @@
1111
// Pure string code — imported by server handlers and the login page alike.
1212
// ---------------------------------------------------------------------------
1313

14-
const pathPart = (path: string): string => path.split(/[?#]/, 1)[0] ?? "";
14+
const RETURN_TO_ORIGIN = "https://executor.invalid";
1515

16-
const isOAuthCallbackReturnTo = (path: string): boolean => pathPart(path) === "/api/oauth/callback";
16+
/** Parse a same-origin landing path, or return null for absent or unsafe input. */
17+
export const safeReturnTo = (path: string | null | undefined): string | null => {
18+
if (!path || !path.startsWith("/") || path.startsWith("//")) return null;
19+
// Browsers treat backslashes as path separators and strip some control
20+
// characters. Reject those spellings before interpreting the destination.
21+
for (const character of path) {
22+
if (character === "\\" || character <= " " || character === "\u007f") return null;
23+
}
1724

18-
export const isSafeReturnTo = (path: string): boolean =>
19-
path.startsWith("/") &&
20-
!path.startsWith("//") &&
21-
(!/^\/api(\/|$)/.test(path) || isOAuthCallbackReturnTo(path));
25+
// The fixed origin and single leading slash guarantee a parseable URL.
26+
// Check the normalized pathname so dot segments cannot bypass the API gate.
27+
const destination = new URL(path, RETURN_TO_ORIGIN);
28+
if (destination.origin !== RETURN_TO_ORIGIN) return null;
29+
if (/^\/api(\/|$)/.test(destination.pathname) && destination.pathname !== "/api/oauth/callback") {
30+
return null;
31+
}
32+
return `${destination.pathname}${destination.search}${destination.hash}`;
33+
};
2234

23-
/** The validated returnTo, or null when absent/unsafe. */
24-
export const safeReturnTo = (path: string | null | undefined): string | null =>
25-
path && isSafeReturnTo(path) ? path : null;
35+
/** Whether a value parses as a same-origin landing path. */
36+
export const isSafeReturnTo = (path: string): boolean => safeReturnTo(path) !== null;
2637

2738
/** The /login URL that comes back to `returnTo` ("/" needs no parameter). */
2839
export const loginPath = (returnTo: string): string =>

‎apps/cloud/src/auth/workos-callback-state.node.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,21 @@ const callbackUrl = (state?: string, code = "code_1") =>
113113
`https://executor.test/auth/callback${state ? `?state=${encodeURIComponent(state)}` : ""}${state ? "&" : "?"}code=${code}`;
114114

115115
describe("workos callback · CSRF state hardening", () => {
116+
for (const returnTo of ["/\\evil.example", "/safe/../api/auth/me"]) {
117+
it(`keeps an unsafe return destination on the homepage: ${JSON.stringify(returnTo)}`, async () => {
118+
const state = encodeLoginState({ nonce: "redirect-boundary", returnTo });
119+
const res = await run(
120+
new Request(callbackUrl(state), {
121+
headers: { cookie: `${STATE_COOKIE}=${state}` },
122+
redirect: "manual",
123+
}),
124+
);
125+
expect(res.status).toBe(302);
126+
expect(res.headers.get("location")).toBe("/");
127+
expect(res.headers.get("set-cookie") ?? "").toContain(SESSION_COOKIE);
128+
});
129+
}
130+
116131
it("rejects a callback with NO state (the former bypass) before any WorkOS call", async () => {
117132
const res = await run(new Request(callbackUrl(undefined), { redirect: "manual" }));
118133
expect(res.status).toBe(400);

‎e2e/cloud/auth-session.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,31 @@ scenario(
6565
}),
6666
);
6767

68+
scenario(
69+
"Auth · login refuses return paths that normalize outside the allowed pages",
70+
{},
71+
Effect.gen(function* () {
72+
yield* Api;
73+
const target = yield* Target;
74+
for (const returnTo of [
75+
"/\\evil.example",
76+
"/safe/../api/auth/me",
77+
"/safe/%2e%2e/api/auth/me",
78+
]) {
79+
const login = new URL("/api/auth/login", target.baseUrl);
80+
login.searchParams.set("returnTo", returnTo);
81+
const response = yield* Effect.promise(() => fetch(login, { redirect: "manual" }));
82+
expect(response.status).toBe(302);
83+
const state = new URL(response.headers.get("location") ?? "").searchParams.get("state") ?? "";
84+
const decoded = decodeLoginState(
85+
Result.getOrElse(Encoding.decodeBase64UrlString(state), () => ""),
86+
);
87+
expect(decoded._tag).toBe("Some");
88+
if (decoded._tag === "Some") expect(decoded.value.returnTo).toBeUndefined();
89+
}
90+
}),
91+
);
92+
6893
scenario(
6994
"Auth · the callback rejects forged or incomplete redirects without exchanging the code",
7095
{},

0 commit comments

Comments
 (0)