From 3c6c23507bcc04b982e3651889aca9fc38dfe74c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 21 Oct 2026 02:51:48 +0200 Subject: [PATCH] fix(frontend): a page opened at a //path boots, and the account-switch copy reads right in Hungarian Every address-bar writer glued `pathname + query`, so a path of `//evil.com/x` named another host: history refused it (SecurityError) and the first nav mirror threw the app into its crash screen. One builder, `hrefHere`, now makes the address from the URL itself and stays on this origin. --- frontend/src/i18n/locales/hu/errors.json | 2 +- frontend/src/i18n/locales/hu/header.json | 2 +- .../src/lib/accountSwitchNotice.dom.test.ts | 15 +++++++++++ frontend/src/lib/entryUrl.ts | 6 ++--- frontend/src/lib/urlState.dom.test.ts | 26 ++++++++++++++++++ frontend/src/lib/urlState.ts | 27 ++++++++++++------- 6 files changed, 63 insertions(+), 15 deletions(-) diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index 667e5bc6..d0d54d8a 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -135,7 +135,7 @@ "gone": "Ez a lejátszási lista már nem létezik a YouTube-on" }, "refused": { - "account_changed": "Ez a fül más fiókot mutatott, mint ami most be van lépve, ezért újratölt azzal", + "account_changed": "Ez a fül más fiókot mutatott, mint amelyik most be van jelentkezve, ezért azzal töltődik újra", "address_has_account": "Ehhez a címhez már tartozik fiók — azt töröld helyette", "admin_first": "Előbb hozd létre az admin fiókot", "admin_only": "Ezt csak admin teheti meg", diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index 348a9016..59a50f84 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -34,7 +34,7 @@ "switchNotApproved": "Ez a Google-fiók még nincs jóváhagyva ezen a példányon, ezért továbbra is az eddigi fiókkal vagy belépve.", "switchSuspended": "Az a fiók fel van függesztve, ezért továbbra is az eddigi fiókkal vagy belépve.", "switchRequested": "Rögzítettük a hozzáférési kérelmet ehhez a címhez: {{email}}. Amíg egy admin jóvá nem hagyja, továbbra is az eddigi fiókkal vagy belépve.", - "tabSwitched": "Ez a fül egy másik fiókot mutatott, ezért újratöltött az éppen bejelentkezett fiókkal.", + "tabSwitched": "Ez a fül egy másik fiókot mutatott, ezért újratöltődött az éppen bejelentkezett fiókkal.", "googleAddress": "Google-fiók: {{email}}", "activity": "Napló" }, diff --git a/frontend/src/lib/accountSwitchNotice.dom.test.ts b/frontend/src/lib/accountSwitchNotice.dom.test.ts index 39803f89..f38d91ef 100644 --- a/frontend/src/lib/accountSwitchNotice.dom.test.ts +++ b/frontend/src/lib/accountSwitchNotice.dom.test.ts @@ -80,11 +80,26 @@ describe("the reload of a tab another account signed in under", () => { clearEntryUrl(); expect(window.location.search).toBe("?page=stats"); }); + + it("stays on this origin when the path starts with //", () => { + // Glued as `pathname + query`, a path of `//evil.com/x` names another host: history refuses + // it, and the boot that strips the flag threw into the crash screen. + const origin = window.location.origin; + window.history.replaceState(null, "", `${origin}//evil.com/x?page=stats`); + window.history.replaceState(null, "", accountChangedUrl()); + expect(window.location.origin).toBe(origin); + expect(window.location.pathname).toBe("//evil.com/x"); + clearEntryUrl(); + expect(window.location.origin).toBe(origin); + expect(window.location.pathname).toBe("//evil.com/x"); + expect(window.location.search).toBe("?page=stats"); + }); }); describe("a tab whose session the server refused", () => { const stubLocation = () => { const loc = { + href: "http://h/?page=stats", pathname: "/", search: "?page=stats", hash: "", diff --git a/frontend/src/lib/entryUrl.ts b/frontend/src/lib/entryUrl.ts index 24a7e19e..48ae0fb7 100644 --- a/frontend/src/lib/entryUrl.ts +++ b/frontend/src/lib/entryUrl.ts @@ -15,6 +15,7 @@ // with nothing personal in them (login, deleted, account) ride the query string. import type { NotifLevel } from "./notifications"; +import { hrefHere } from "./urlState"; export const entrySearch = new URLSearchParams(window.location.search); export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/, "")); @@ -37,7 +38,7 @@ export function accountChangedUrl(): string { const params = new URLSearchParams(window.location.search); const [key, value] = ACCOUNT_CHANGED_FLAG.split("=") as [string, string]; params.set(key, value); - return `${window.location.pathname}?${params.toString()}${window.location.hash}`; + return hrefHere(params); } /** Reload a tab whose session the server refused (a 401). Showing another account than the one @@ -76,12 +77,11 @@ export function clearEntryUrl(): void { const keep = hash.has("reset") ? window.location.hash : ""; if (!dropped.length && !window.location.hash) return; if (!dropped.length && keep) return; - const qs = params.toString(); window.history.replaceState( // Preserved, not nulled: it carries scroll restoration and the channel entry. window.history.state, "", - `${window.location.pathname}${qs ? `?${qs}` : ""}${keep}`, + hrefHere(params, keep), ); } diff --git a/frontend/src/lib/urlState.dom.test.ts b/frontend/src/lib/urlState.dom.test.ts index 463cd7c1..ab87bf44 100644 --- a/frontend/src/lib/urlState.dom.test.ts +++ b/frontend/src/lib/urlState.dom.test.ts @@ -100,6 +100,32 @@ describe("clearParams", () => { }); }); +describe("a page opened at a path that starts with //", () => { + // Regression: the address was glued together as `pathname + query`, and a path of `//evil.com/x` + // then reads as a link to another host. history refuses that (SecurityError), so the first nav + // mirror to write threw and the app booted into its crash screen. + const openAt = (rest: string) => + window.history.replaceState(null, "", `${window.location.origin}//evil.com/x${rest}`); + const origin = window.location.origin; + + it("writes an address on this origin and this path", () => { + openAt("?page=plex#verify=t"); + window.history.replaceState(null, "", urlWithParams({ v: "show:1" })); + expect(window.location.origin).toBe(origin); + expect(window.location.pathname).toBe("//evil.com/x"); + expect(window.location.search).toBe("?page=plex&v=show%3A1"); + expect(window.location.hash).toBe("#verify=t"); + }); + + it("strips a transient key without leaving this origin", () => { + openAt("?link=ok&page=plex"); + clearParams("link"); + expect(window.location.origin).toBe(origin); + expect(window.location.pathname).toBe("//evil.com/x"); + expect(window.location.search).toBe("?page=plex"); + }); +}); + describe("filterParamUpdates", () => { it("maps non-default filters to values and defaults to null (delete)", () => { const f: FeedFilters = { ...DEFAULTS, q: "react", tags: [3, 7], show: "watched" }; diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index c4afc707..1b99624d 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -208,8 +208,20 @@ export function urlWithParams(updates: Record): string { if (v == null) params.delete(k); else params.set(k, v); } - const qs = params.toString(); - return `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`; + return hrefHere(params); +} + +/** This page's address with its query replaced by `query` and its fragment by `hash` — what every + * address-bar writer hands to `history`. Built on the URL itself, never by gluing `pathname` in front: + * a path opened as `//evil.com/x` would read as a link to ANOTHER host, `history.replaceState` refuses + * that (SecurityError), and the first nav mirror to write threw the whole app into its crash screen. + * Relative where that is safe, so the address stays as it was typed; the full href when the path + * starts with `//`. */ +export function hrefHere(query: URLSearchParams, hash: string = window.location.hash): string { + const url = new URL(window.location.href); + url.search = query.toString(); + url.hash = hash; + return url.pathname.startsWith("//") ? url.href : `${url.pathname}${url.search}${url.hash}`; } /** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify, @@ -226,12 +238,7 @@ export function clearParams(...keys: string[]): void { } } if (!changed) return; - const qs = params.toString(); - window.history.replaceState( - window.history.state, - "", - // Same fragment rule as urlWithParams: this strips QUERY keys only, so a `#verify=…`/`#reset=…` - // token that has not been consumed yet must survive. Welcome's own cleanup is what clears it. - `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`, - ); + // Same fragment rule as urlWithParams: this strips QUERY keys only, so a `#verify=…`/`#reset=…` + // token that has not been consumed yet must survive. Welcome's own cleanup is what clears it. + window.history.replaceState(window.history.state, "", hrefHere(params)); }