fix(i18n): survive a malformed locale cookie (#7553)

* fix(i18n): survive a malformed locale cookie

getLocaleFromCookie decoded the raw cookie value without a guard, so a
malformed percent-encoding threw URIError during locale bootstrap and in
formatTimeAgo. It also split the cookie on every "=", truncating values
that contain one. Decode defensively and split on the first "=" only.

* fix(i18n): normalize the cookie locale in formatTimeAgo

The second caller of getLocaleFromCookie cast the raw value to Locale, so a
malformed-but-known-prefixed cookie skipped normalization. Normalize it like
useI18n does, and cover the malformed and valid cookie paths.

---------

Co-authored-by: 一片一片 <192845873+gdut4140@users.noreply.github.com>
This commit is contained in:
一片一片 2026-10-26 23:37:38 +08:00 committed by GitHub
parent 19768d73cc
commit a2fd094b40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 61 additions and 6 deletions

View File

@ -15,9 +15,21 @@ export function getLocaleFromCookie(): string | null {
const cookies = document.cookie.split(";");
for (const cookie of cookies) {
const [name, value] = cookie.trim().split("=");
if (name === LOCALE_COOKIE_NAME) {
return decodeURIComponent(value ?? "");
// Split on the first "=" only: a cookie value may contain "=" itself.
const separator = cookie.indexOf("=");
if (separator < 0) {
continue;
}
if (cookie.slice(0, separator).trim() !== LOCALE_COOKIE_NAME) {
continue;
}
const value = cookie.slice(separator + 1).trim();
try {
return decodeURIComponent(value);
} catch {
// A malformed value must not crash locale bootstrap; hand the raw
// value to normalizeLocale, which falls back to the default locale.
return value;
}
}
return null;

View File

@ -1,7 +1,7 @@
import { formatDistanceToNow } from "date-fns";
import { enUS as dateFnsEnUS, zhCN as dateFnsZhCN } from "date-fns/locale";
import { detectLocale, type Locale } from "@/core/i18n";
import { detectLocale, normalizeLocale, type Locale } from "@/core/i18n";
import { getLocaleFromCookie } from "@/core/i18n/cookies";
function getDateFnsLocale(locale: Locale) {
@ -15,9 +15,9 @@ function getDateFnsLocale(locale: Locale) {
}
export function formatTimeAgo(date: Date | string | number, locale?: Locale) {
const savedLocale = locale ?? getLocaleFromCookie();
const effectiveLocale =
locale ??
(getLocaleFromCookie() as Locale | null) ??
(savedLocale ? normalizeLocale(savedLocale) : null) ??
// Fallback when cookie is missing (or on first render)
detectLocale();
// Guard against invalid/empty timestamps (e.g. a backend returning "" for

View File

@ -0,0 +1,26 @@
import { afterEach, expect, test } from "@rstest/core";
import { getLocaleFromCookie } from "@/core/i18n/cookies";
function setLocaleCookie(value: string) {
document.cookie = `locale=${value}; path=/`;
}
afterEach(() => {
document.cookie = "locale=; max-age=0; path=/";
});
test("decodes an encoded locale", () => {
setLocaleCookie("zh-CN");
expect(getLocaleFromCookie()).toBe("zh-CN");
});
test("does not throw on a malformed percent-encoding", () => {
setLocaleCookie("%E0%A4%A");
expect(getLocaleFromCookie()).toBe("%E0%A4%A");
});
test("keeps a cookie value that contains '='", () => {
setLocaleCookie("en=US");
expect(getLocaleFromCookie()).toBe("en=US");
});

View File

@ -0,0 +1,17 @@
import { afterEach, expect, test } from "@rstest/core";
import { formatTimeAgo } from "@/core/utils/datetime";
afterEach(() => {
document.cookie = "locale=; max-age=0; path=/";
});
test("falls back to a known locale for a malformed locale cookie", () => {
document.cookie = "locale=zh%FF; path=/";
expect(() => formatTimeAgo(Date.now() - 5_000)).not.toThrow();
});
test("uses the locale from the cookie", () => {
document.cookie = "locale=zh-CN; path=/";
expect(formatTimeAgo(Date.now() - 5_000)).toContain("前");
});