fix: preserve deep link when redirecting to login (#7876)

This commit is contained in:
Henri Cook
2026-07-17 19:21:19 +01:00
committed by GitHub
parent ae5aa5a0b8
commit 16a0ba2e21
6 changed files with 44 additions and 5 deletions

View File

@@ -2,6 +2,7 @@ import { ref } from "vue";
import { useAsyncKey } from "../use-utils";
import { usePublicExploreApi } from "~/composables/api/api-client";
import { useUserApi } from "~/composables/api";
import { isSafeRedirectTarget } from "~/lib/validators/redirect";
import type { OrderByNullPosition, Recipe } from "~/lib/api/types/recipe";
import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
@@ -43,6 +44,7 @@ function getParams(
export const useLazyRecipes = function (publicGroupSlug: string | null = null) {
const router = useRouter();
const route = useRoute();
// passing the group slug switches to using the public API
const api = publicGroupSlug ? usePublicExploreApi(publicGroupSlug).explore : useUserApi();
@@ -65,7 +67,8 @@ export const useLazyRecipes = function (publicGroupSlug: string | null = null) {
);
if (error?.response?.status === 404) {
router.push("/login");
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : undefined;
router.push(isSafeRedirectTarget(redirect) ? { path: "/login", query: { redirect } } : "/login");
}
return data ? data.items : [];

View File

@@ -0,0 +1,27 @@
import { describe, expect, test } from "vitest";
import { isSafeRedirectTarget } from "./redirect";
describe("isSafeRedirectTarget", () => {
test("accepts an internal path", () => {
expect(isSafeRedirectTarget("/g/home/r/some-recipe")).toBe(true);
});
test("rejects protocol-relative URLs", () => {
expect(isSafeRedirectTarget("//evil.com")).toBe(false);
});
test("rejects backslash variants used to bypass same-origin checks", () => {
expect(isSafeRedirectTarget("/\\evil.com")).toBe(false);
expect(isSafeRedirectTarget("\\\\evil.com")).toBe(false);
});
test("rejects absolute URLs", () => {
expect(isSafeRedirectTarget("https://evil.com")).toBe(false);
});
test("rejects empty or missing values", () => {
expect(isSafeRedirectTarget("")).toBe(false);
expect(isSafeRedirectTarget(null)).toBe(false);
expect(isSafeRedirectTarget(undefined)).toBe(false);
});
});

View File

@@ -0,0 +1,4 @@
// rejects protocol-relative payloads (`//evil.com`, `/\evil.com`) that a bare startsWith("/") check would miss
export function isSafeRedirectTarget(target: string | null | undefined): target is string {
return !!target && target.startsWith("/") && !/^[/\\]{2}/.test(target);
}

View File

@@ -40,7 +40,7 @@ async function loadPublicRecipe() {
const { data, error } = await api.explore.recipes.getOne(slug);
if (error) {
console.error("error loading recipe -> ", error);
router.push(`/g/${groupSlug.value}`);
router.push({ path: `/g/${groupSlug.value}`, query: { redirect: route.fullPath } });
}
return data;

View File

@@ -216,6 +216,7 @@ import { useLoggedInState } from "~/composables/use-logged-in-state";
import { usePasswordField } from "~/composables/use-passwords";
import { alert } from "~/composables/use-toast";
import { useAsyncKey } from "~/composables/use-utils";
import { isSafeRedirectTarget } from "~/lib/validators/redirect";
import type { AppStartupInfo } from "~/lib/api/types/admin";
import { useUserActivityPreferences } from "~/composables/use-users/preferences";
@@ -275,7 +276,7 @@ whenever(
// the query string).
const redirectFromQuery = route.query.redirect as string | undefined;
const redirectTarget = redirectFromQuery ?? pendingShareRedirect.value;
if (redirectTarget && redirectTarget.startsWith("/")) {
if (isSafeRedirectTarget(redirectTarget)) {
pendingShareRedirect.value = null;
router.push(redirectTarget);
return;
@@ -339,7 +340,7 @@ async function oidcAuthenticate(callback = false) {
// The OIDC callback reloads the page, which clears the query string, so we
// persist the target in sessionStorage and restore it after login.
const redirectTarget = route.query.redirect as string | undefined;
if (redirectTarget && redirectTarget.startsWith("/")) {
if (isSafeRedirectTarget(redirectTarget)) {
pendingShareRedirect.value = redirectTarget;
}
navigateTo("/api/auth/oauth", { external: true }); // start the redirect process

View File

@@ -1,6 +1,7 @@
import axios from "axios";
import { alert } from "~/composables/use-toast";
import { getTokenCookieOptions } from "~/composables/use-token-cookie";
import { isSafeRedirectTarget } from "~/lib/validators/redirect";
declare module "axios" {
interface AxiosRequestConfig {
@@ -49,7 +50,10 @@ export default defineNuxtPlugin(() => {
// Disable beforeunload warnings to prevent "Are you sure you want to leave?" popups
window.onbeforeunload = null;
window.location.href = "/login";
const target = window.location.pathname + window.location.search;
const redirect = isSafeRedirectTarget(target) && target !== "/login" ? `?redirect=${encodeURIComponent(target)}` : "";
window.location.href = `/login${redirect}`;
}
}