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.
This commit is contained in:
2026-10-21 02:51:48 +02:00
parent ef99f7b5c7
commit 3c6c23507b
6 changed files with 63 additions and 15 deletions
+1 -1
View File
@@ -135,7 +135,7 @@
"gone": "Ez a lejátszási lista már nem létezik a YouTube-on" "gone": "Ez a lejátszási lista már nem létezik a YouTube-on"
}, },
"refused": { "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", "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_first": "Előbb hozd létre az admin fiókot",
"admin_only": "Ezt csak admin teheti meg", "admin_only": "Ezt csak admin teheti meg",
+1 -1
View File
@@ -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.", "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.", "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.", "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}}", "googleAddress": "Google-fiók: {{email}}",
"activity": "Napló" "activity": "Napló"
}, },
@@ -80,11 +80,26 @@ describe("the reload of a tab another account signed in under", () => {
clearEntryUrl(); clearEntryUrl();
expect(window.location.search).toBe("?page=stats"); 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", () => { describe("a tab whose session the server refused", () => {
const stubLocation = () => { const stubLocation = () => {
const loc = { const loc = {
href: "http://h/?page=stats",
pathname: "/", pathname: "/",
search: "?page=stats", search: "?page=stats",
hash: "", hash: "",
+3 -3
View File
@@ -15,6 +15,7 @@
// with nothing personal in them (login, deleted, account) ride the query string. // with nothing personal in them (login, deleted, account) ride the query string.
import type { NotifLevel } from "./notifications"; import type { NotifLevel } from "./notifications";
import { hrefHere } from "./urlState";
export const entrySearch = new URLSearchParams(window.location.search); export const entrySearch = new URLSearchParams(window.location.search);
export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/, "")); 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 params = new URLSearchParams(window.location.search);
const [key, value] = ACCOUNT_CHANGED_FLAG.split("=") as [string, string]; const [key, value] = ACCOUNT_CHANGED_FLAG.split("=") as [string, string];
params.set(key, value); 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 /** 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 : ""; const keep = hash.has("reset") ? window.location.hash : "";
if (!dropped.length && !window.location.hash) return; if (!dropped.length && !window.location.hash) return;
if (!dropped.length && keep) return; if (!dropped.length && keep) return;
const qs = params.toString();
window.history.replaceState( window.history.replaceState(
// Preserved, not nulled: it carries scroll restoration and the channel entry. // Preserved, not nulled: it carries scroll restoration and the channel entry.
window.history.state, window.history.state,
"", "",
`${window.location.pathname}${qs ? `?${qs}` : ""}${keep}`, hrefHere(params, keep),
); );
} }
+26
View File
@@ -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", () => { describe("filterParamUpdates", () => {
it("maps non-default filters to values and defaults to null (delete)", () => { it("maps non-default filters to values and defaults to null (delete)", () => {
const f: FeedFilters = { ...DEFAULTS, q: "react", tags: [3, 7], show: "watched" }; const f: FeedFilters = { ...DEFAULTS, q: "react", tags: [3, 7], show: "watched" };
+17 -10
View File
@@ -208,8 +208,20 @@ export function urlWithParams(updates: Record<string, string | null>): string {
if (v == null) params.delete(k); if (v == null) params.delete(k);
else params.set(k, v); else params.set(k, v);
} }
const qs = params.toString(); return hrefHere(params);
return `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`; }
/** 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, /** 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; if (!changed) return;
const qs = params.toString(); // Same fragment rule as urlWithParams: this strips QUERY keys only, so a `#verify=…`/`#reset=…`
window.history.replaceState( // token that has not been consumed yet must survive. Welcome's own cleanup is what clears it.
window.history.state, window.history.replaceState(window.history.state, "", hrefHere(params));
"",
// 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}`,
);
} }