diff --git a/frontend/app/composables/recipes/use-recipes.ts b/frontend/app/composables/recipes/use-recipes.ts index 15b307638..f661a9358 100644 --- a/frontend/app/composables/recipes/use-recipes.ts +++ b/frontend/app/composables/recipes/use-recipes.ts @@ -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 : []; diff --git a/frontend/app/lib/validators/redirect.test.ts b/frontend/app/lib/validators/redirect.test.ts new file mode 100644 index 000000000..230985a40 --- /dev/null +++ b/frontend/app/lib/validators/redirect.test.ts @@ -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); + }); +}); diff --git a/frontend/app/lib/validators/redirect.ts b/frontend/app/lib/validators/redirect.ts new file mode 100644 index 000000000..d55dee194 --- /dev/null +++ b/frontend/app/lib/validators/redirect.ts @@ -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); +} diff --git a/frontend/app/pages/g/[groupSlug]/r/[slug]/index.vue b/frontend/app/pages/g/[groupSlug]/r/[slug]/index.vue index 016b218f4..97c0411d4 100644 --- a/frontend/app/pages/g/[groupSlug]/r/[slug]/index.vue +++ b/frontend/app/pages/g/[groupSlug]/r/[slug]/index.vue @@ -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; diff --git a/frontend/app/pages/login.vue b/frontend/app/pages/login.vue index 7b9cd4acc..49afb8aa2 100644 --- a/frontend/app/pages/login.vue +++ b/frontend/app/pages/login.vue @@ -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 diff --git a/frontend/app/plugins/axios.ts b/frontend/app/plugins/axios.ts index 62eb2240a..10ca36e51 100644 --- a/frontend/app/plugins/axios.ts +++ b/frontend/app/plugins/axios.ts @@ -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}`; } }