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:
@@ -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",
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|||||||
@@ -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();
|
|
||||||
window.history.replaceState(
|
|
||||||
window.history.state,
|
|
||||||
"",
|
|
||||||
// Same fragment rule as urlWithParams: this strips QUERY keys only, so a `#verify=…`/`#reset=…`
|
// 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.
|
// 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}`,
|
window.history.replaceState(window.history.state, "", hrefHere(params));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user