Preserve and sanitize return path after login (#1959)
Browse files* Preserve and sanitize return path after login
Adds logic to capture, sanitize, and persist a 'next' return path through the OAuth login flow, ensuring users are redirected to their intended in-app location after authentication. Prevents open redirects by only allowing absolute in-app paths and updates both the auth and login callback logic to handle the new parameter.
* Update ChatMessage.svelte
src/lib/server/auth.ts
CHANGED
|
@@ -66,6 +66,19 @@ const secure = z
|
|
| 66 |
.default(!(dev || config.ALLOW_INSECURE_COOKIES === "true"))
|
| 67 |
.parse(config.COOKIE_SECURE === "" ? undefined : config.COOKIE_SECURE === "true");
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
export function refreshSessionCookie(cookies: Cookies, sessionId: string) {
|
| 70 |
cookies.set(config.COOKIE_NAME, sessionId, {
|
| 71 |
path: "/",
|
|
@@ -197,10 +210,20 @@ export function tokenSetToSessionOauth(tokenSet: TokenSet): Session["oauth"] {
|
|
| 197 |
/**
|
| 198 |
* Generates a CSRF token using the user sessionId. Note that we don't need a secret because sessionId is enough.
|
| 199 |
*/
|
| 200 |
-
export async function generateCsrfToken(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
const data = {
|
| 202 |
expiration: addHours(new Date(), 1).getTime(),
|
| 203 |
redirectUrl,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
};
|
| 205 |
|
| 206 |
return Buffer.from(
|
|
@@ -249,10 +272,14 @@ async function getOIDCClient(settings: OIDCSettings): Promise<BaseClient> {
|
|
| 249 |
|
| 250 |
export async function getOIDCAuthorizationUrl(
|
| 251 |
settings: OIDCSettings,
|
| 252 |
-
params: { sessionId: string }
|
| 253 |
): Promise<string> {
|
| 254 |
const client = await getOIDCClient(settings);
|
| 255 |
-
const csrfToken = await generateCsrfToken(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
return client.authorizationUrl({
|
| 258 |
scope: OIDConfig.SCOPES,
|
|
@@ -291,6 +318,8 @@ export async function validateAndParseCsrfToken(
|
|
| 291 |
): Promise<{
|
| 292 |
/** This is the redirect url that was passed to the OIDC provider */
|
| 293 |
redirectUrl: string;
|
|
|
|
|
|
|
| 294 |
} | null> {
|
| 295 |
try {
|
| 296 |
const { data, signature } = z
|
|
@@ -298,6 +327,7 @@ export async function validateAndParseCsrfToken(
|
|
| 298 |
data: z.object({
|
| 299 |
expiration: z.number().int(),
|
| 300 |
redirectUrl: z.string().url(),
|
|
|
|
| 301 |
}),
|
| 302 |
signature: z.string().length(64),
|
| 303 |
})
|
|
@@ -306,7 +336,7 @@ export async function validateAndParseCsrfToken(
|
|
| 306 |
const reconstructSign = await sha256(JSON.stringify(data) + "##" + sessionId);
|
| 307 |
|
| 308 |
if (data.expiration > Date.now() && signature === reconstructSign) {
|
| 309 |
-
return { redirectUrl: data.redirectUrl };
|
| 310 |
}
|
| 311 |
} catch (e) {
|
| 312 |
logger.error(e);
|
|
@@ -493,9 +523,23 @@ export async function triggerOauthFlow({
|
|
| 493 |
}
|
| 494 |
}
|
| 495 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
const authorizationUrl = await getOIDCAuthorizationUrl(
|
| 497 |
{ redirectURI },
|
| 498 |
-
{ sessionId: locals.sessionId }
|
| 499 |
);
|
| 500 |
|
| 501 |
throw redirect(302, authorizationUrl);
|
|
|
|
| 66 |
.default(!(dev || config.ALLOW_INSECURE_COOKIES === "true"))
|
| 67 |
.parse(config.COOKIE_SECURE === "" ? undefined : config.COOKIE_SECURE === "true");
|
| 68 |
|
| 69 |
+
function sanitizeReturnPath(path: string | undefined | null): string | undefined {
|
| 70 |
+
if (!path) {
|
| 71 |
+
return undefined;
|
| 72 |
+
}
|
| 73 |
+
if (path.startsWith("//")) {
|
| 74 |
+
return undefined;
|
| 75 |
+
}
|
| 76 |
+
if (!path.startsWith("/")) {
|
| 77 |
+
return undefined;
|
| 78 |
+
}
|
| 79 |
+
return path;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
export function refreshSessionCookie(cookies: Cookies, sessionId: string) {
|
| 83 |
cookies.set(config.COOKIE_NAME, sessionId, {
|
| 84 |
path: "/",
|
|
|
|
| 210 |
/**
|
| 211 |
* Generates a CSRF token using the user sessionId. Note that we don't need a secret because sessionId is enough.
|
| 212 |
*/
|
| 213 |
+
export async function generateCsrfToken(
|
| 214 |
+
sessionId: string,
|
| 215 |
+
redirectUrl: string,
|
| 216 |
+
next?: string
|
| 217 |
+
): Promise<string> {
|
| 218 |
+
const sanitizedNext = sanitizeReturnPath(next);
|
| 219 |
const data = {
|
| 220 |
expiration: addHours(new Date(), 1).getTime(),
|
| 221 |
redirectUrl,
|
| 222 |
+
...(sanitizedNext ? { next: sanitizedNext } : {}),
|
| 223 |
+
} as {
|
| 224 |
+
expiration: number;
|
| 225 |
+
redirectUrl: string;
|
| 226 |
+
next?: string;
|
| 227 |
};
|
| 228 |
|
| 229 |
return Buffer.from(
|
|
|
|
| 272 |
|
| 273 |
export async function getOIDCAuthorizationUrl(
|
| 274 |
settings: OIDCSettings,
|
| 275 |
+
params: { sessionId: string; next?: string }
|
| 276 |
): Promise<string> {
|
| 277 |
const client = await getOIDCClient(settings);
|
| 278 |
+
const csrfToken = await generateCsrfToken(
|
| 279 |
+
params.sessionId,
|
| 280 |
+
settings.redirectURI,
|
| 281 |
+
sanitizeReturnPath(params.next)
|
| 282 |
+
);
|
| 283 |
|
| 284 |
return client.authorizationUrl({
|
| 285 |
scope: OIDConfig.SCOPES,
|
|
|
|
| 318 |
): Promise<{
|
| 319 |
/** This is the redirect url that was passed to the OIDC provider */
|
| 320 |
redirectUrl: string;
|
| 321 |
+
/** Relative path (within this app) to return to after login */
|
| 322 |
+
next?: string;
|
| 323 |
} | null> {
|
| 324 |
try {
|
| 325 |
const { data, signature } = z
|
|
|
|
| 327 |
data: z.object({
|
| 328 |
expiration: z.number().int(),
|
| 329 |
redirectUrl: z.string().url(),
|
| 330 |
+
next: z.string().optional(),
|
| 331 |
}),
|
| 332 |
signature: z.string().length(64),
|
| 333 |
})
|
|
|
|
| 336 |
const reconstructSign = await sha256(JSON.stringify(data) + "##" + sessionId);
|
| 337 |
|
| 338 |
if (data.expiration > Date.now() && signature === reconstructSign) {
|
| 339 |
+
return { redirectUrl: data.redirectUrl, next: sanitizeReturnPath(data.next) };
|
| 340 |
}
|
| 341 |
} catch (e) {
|
| 342 |
logger.error(e);
|
|
|
|
| 523 |
}
|
| 524 |
}
|
| 525 |
|
| 526 |
+
// Preserve a safe in-app return path after login.
|
| 527 |
+
// Priority: explicit ?next=... (must be an absolute path), else the current path (when auto-login kicks in).
|
| 528 |
+
let next: string | undefined = undefined;
|
| 529 |
+
const nextParam = sanitizeReturnPath(url.searchParams.get("next"));
|
| 530 |
+
if (nextParam) {
|
| 531 |
+
// Only accept absolute in-app paths to prevent open redirects
|
| 532 |
+
next = nextParam;
|
| 533 |
+
} else if (!url.pathname.startsWith(`${base}/login`)) {
|
| 534 |
+
// For automatic login on protected pages, return to the page the user was on
|
| 535 |
+
next = sanitizeReturnPath(`${url.pathname}${url.search}`) ?? `${base}/`;
|
| 536 |
+
} else {
|
| 537 |
+
next = sanitizeReturnPath(`${base}/`) ?? "/";
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
const authorizationUrl = await getOIDCAuthorizationUrl(
|
| 541 |
{ redirectURI },
|
| 542 |
+
{ sessionId: locals.sessionId, next }
|
| 543 |
);
|
| 544 |
|
| 545 |
throw redirect(302, authorizationUrl);
|
src/routes/login/callback/+server.ts
CHANGED
|
@@ -86,5 +86,11 @@ export async function GET({ url, locals, cookies, request, getClientAddress }) {
|
|
| 86 |
ip: getClientAddress(),
|
| 87 |
});
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
return redirect(302, `${base}/`);
|
| 90 |
}
|
|
|
|
| 86 |
ip: getClientAddress(),
|
| 87 |
});
|
| 88 |
|
| 89 |
+
// Prefer returning the user to their original in-app path when provided.
|
| 90 |
+
// `validatedToken.next` is sanitized server-side to avoid protocol-relative redirects.
|
| 91 |
+
const next = validatedToken.next;
|
| 92 |
+
if (next) {
|
| 93 |
+
return redirect(302, next);
|
| 94 |
+
}
|
| 95 |
return redirect(302, `${base}/`);
|
| 96 |
}
|