fix: safe redirect if return_to is protected after logout

This commit is contained in:
Raul Lugo
2026-01-29 17:43:47 +01:00
parent 7aa0d63cc6
commit 81239dc6ab
4 changed files with 61 additions and 8 deletions

View File

@@ -21,6 +21,11 @@ function escapeRegex(str: string): string {
}
function globToRegExp(pattern: string): RegExp {
if (pattern.endsWith("/*")) {
const base = pattern.slice(0, -2);
const baseEscaped = escapeRegex(base);
return new RegExp(`^${baseEscaped}(?:/.*)?$`);
}
const escaped = pattern.split("*").map(escapeRegex).join(".*");
return new RegExp(`^${escaped}$`);
}
@@ -38,12 +43,17 @@ export const onRequest: MiddlewareHandler = async (context, next) => {
locals.user = null;
if (token) {
const res = await verifyAndDecode<{ sub: string; email?: string }>(
token,
options.cookie.signingSecret,
);
const res = await verifyAndDecode<{
sub: string;
email?: string;
firstName?: string;
}>(token, options.cookie.signingSecret);
if (res.valid && res.payload && typeof res.payload.sub === "string") {
locals.user = { sub: res.payload.sub, email: res.payload.email };
locals.user = {
sub: res.payload.sub,
email: res.payload.email,
firstName: res.payload.firstName,
};
}
}

View File

@@ -127,9 +127,13 @@ export async function GET(ctx: APIContext) {
audience: options.clientId,
});
const givenName =
typeof payload["given_name"] === "string" ? payload["given_name"] : undefined;
const session = {
sub: String(payload.sub),
email: typeof payload.email === "string" ? payload.email : undefined,
firstName: givenName,
};
const signed = await signPayload(session, options.cookie.signingSecret);

View File

@@ -42,6 +42,39 @@ function safeReturnTo(reqUrl: URL, returnTo: string | undefined): string {
}
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function globToRegExp(pattern: string): RegExp {
if (pattern.endsWith("/*")) {
const base = pattern.slice(0, -2);
const baseEscaped = escapeRegex(base);
return new RegExp(`^${baseEscaped}(?:/.*)?$`);
}
const escaped = pattern.split("*").map(escapeRegex).join(".*");
return new RegExp(`^${escaped}$`);
}
function isProtected(pathname: string, patterns: string[]): boolean {
return patterns.some((p) => globToRegExp(p).test(pathname));
}
function finalizePostLogoutReturnTo(
options: ReturnType<typeof getOptions>,
reqUrl: URL,
returnTo: string | undefined,
): string {
const safe = safeReturnTo(reqUrl, returnTo);
try {
const u = new URL(safe, reqUrl);
if (isProtected(u.pathname, options.protected)) return new URL("/", reqUrl).toString();
return u.toString();
} catch {
return new URL("/", reqUrl).toString();
}
}
function inferPostLogoutRedirectUri(
options: ReturnType<typeof getOptions>,
reqUrl: URL,
@@ -88,7 +121,9 @@ export async function GET(ctx: APIContext) {
const parsed = cookie ? parseLogoutCookie(cookie) : null;
const ok = parsed && parsed.state === state;
const location = ok ? safeReturnTo(url, parsed.returnTo) : "/";
const location = ok
? finalizePostLogoutReturnTo(options, url, parsed.returnTo)
: "/";
const headers = new Headers();
headers.set("Location", location);
@@ -115,7 +150,11 @@ export async function GET(ctx: APIContext) {
disco.end_session_endpoint || new URL("/logout", options.issuer).toString();
const state = generateState();
const returnTo = url.searchParams.get("return_to") || undefined;
const returnTo = finalizePostLogoutReturnTo(
options,
url,
url.searchParams.get("return_to") || undefined,
);
const cookieValue = JSON.stringify({ state, returnTo });
const setLogout = serializeCookie(logoutCookieName, cookieValue, {
path: options.cookie.path,

View File

@@ -1,7 +1,7 @@
declare global {
namespace App {
interface Locals {
user?: { sub: string; email?: string } | null;
user?: { sub: string; email?: string; firstName?: string } | null;
}
}
}