victor HF Staff commited on
Commit
180c5a2
·
unverified ·
1 Parent(s): 9f870c5

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(sessionId: string, redirectUrl: string): Promise<string> {
 
 
 
 
 
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(params.sessionId, settings.redirectURI);
 
 
 
 
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
  }