Skip to content

Commit ef34372

Browse files
fix(login): ?error=__proto__ 会崩掉整个登录页 + 补全 error code 映射
线上活跃缺陷:LoginErrorNotice 里 messages[error] 是无保护的原型链查找。 messages["__proto__"] 取到的是 Object.prototype(对象而非 undefined,所以 ?? 兜底不触发),把非字符串交给 React 渲染会抛错;/login 没有 error boundary, 整个登录页被替换成 "Application error",连 GitHub 登录一起不可用。 constructor / toString / valueOf 同理(取到函数)。发条链接即可让人登不了录。 改为显式 code→i18n key 白名单 + Object.hasOwn,未知 code 一律落到通用文案。 顺带(同区域): - 补齐 4 个 error code 的映射。后端实发 discord_canary / oauth_failed / oauth_state / oauth_provider,此前后两者塌缩成"请重试"——而 oauth_state (cookie 被拦或超 300s)重试会无限复现,oauth_provider(服务端缺配置) 再怎么重试都不可能好,给的建议是错的。 - 去掉 messages prop:NextIntlClientProvider 已在 layout 包裹全站,直接 useTranslations 即可,省掉"加一个 error code 要改三个文件"的同步负担。 - 删掉按钮旁叙述当前灰度阶段的注释(CLAUDE.md §3 禁止的任务性注释,且 GA 是 改另一个仓库的 env,没人会回来删它)。 新增 tests/login-error-notice.test.ts(5 条),含 __proto__ 系列回归。 72 测试通过,typecheck / lint 干净,build 表 login 仍为 ● SSG。
1 parent 1d81164 commit ef34372

5 files changed

Lines changed: 88 additions & 22 deletions

File tree

‎app/[locale]/login/page.tsx‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,10 @@ export default async function LoginPage({ params }: Props) {
3333
<p className="text-muted-foreground">{t("subheading")}</p>
3434
</div>
3535
<Suspense fallback={null}>
36-
<LoginErrorNotice
37-
messages={{
38-
discord_canary: t("errorDiscordCanary"),
39-
generic: t("errorGeneric"),
40-
}}
41-
/>
36+
<LoginErrorNotice />
4237
</Suspense>
4338
<div className="flex flex-col items-center gap-3">
4439
<SignInButton provider="github" label={t("github")} />
45-
{/* Discord 登录灰度中:后端按 Discord id 白名单放行(auth.discord.allowlist),
46-
名单外的人点了会被回调弹回 /login?error=discord_canary。GA(OTP wiring 完成、
47-
清空白名单)后此按钮即对所有人开放。 */}
4840
<SignInButton provider="discord" label={t("discord")} />
4941
</div>
5042
</div>
Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,39 @@
11
"use client";
22

3+
import { useTranslations } from "next-intl";
34
import { useSearchParams } from "next/navigation";
45

5-
// 登录页顶端的错误提示。后端 OAuth 回调失败会 302 到 /login?error=xxx。
6+
// 后端 OAuth 回调失败会 302 到 /login?error=<code>,这里把 code 映射成 i18n key。
7+
//
8+
// 必须是显式白名单 + Object.hasOwn,不能拿 error 直接当对象索引:那样会命中原型链,
9+
// ?error=__proto__ 取到的是 Object.prototype(对象而非 undefined,所以 ?? 兜底不
10+
// 触发),把非字符串交给 React 渲染会抛错;/login 没有 error boundary,整个登录页
11+
// 会被替换成 Application error,连 GitHub 登录一起不可用。
12+
const ERROR_KEYS: Record<string, string> = {
13+
discord_canary: "errorDiscordCanary",
14+
oauth_state: "errorOauthState",
15+
oauth_provider: "errorOauthProvider",
16+
oauth_failed: "errorGeneric",
17+
};
18+
19+
/** null 表示不显示提示。未知 code 一律落到通用文案,绝不透传原值。 */
20+
export function resolveErrorKey(error: string | null): string | null {
21+
if (!error) return null;
22+
return Object.hasOwn(ERROR_KEYS, error) ? ERROR_KEYS[error]! : "errorGeneric";
23+
}
24+
625
// 用 useSearchParams(客户端读 query)而非 server 读 searchParams,避免整页退回
726
// 动态渲染(登录页要保持 SSG,见 CLAUDE.md 路由分类约束)。父级用 <Suspense> 包裹。
8-
// 文案由 server 端 getTranslations 取好后作为 prop 传入,省一套 client i18n provider。
9-
export function LoginErrorNotice({
10-
messages,
11-
}: {
12-
messages: Record<string, string>;
13-
}) {
14-
const error = useSearchParams().get("error");
15-
if (!error) return null;
16-
const msg = messages[error] ?? messages.generic;
27+
export function LoginErrorNotice() {
28+
const t = useTranslations("login");
29+
const key = resolveErrorKey(useSearchParams().get("error"));
30+
if (!key) return null;
1731
return (
1832
<div
1933
role="alert"
2034
className="w-full rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200"
2135
>
22-
{msg}
36+
{t(key)}
2337
</div>
2438
);
2539
}

‎messages/en.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@
7272
"github": "Sign in with GitHub",
7373
"discord": "Sign in with Discord",
7474
"errorDiscordCanary": "Discord sign-in is in limited testing and not open yet — stay tuned!",
75-
"errorGeneric": "Sign-in didn't complete. Please try again."
75+
"errorGeneric": "Sign-in didn't complete. Please try again.",
76+
"errorOauthState": "Your sign-in session expired (took too long on the provider's page, or cookies were blocked). Please start the sign-in again.",
77+
"errorOauthProvider": "This sign-in method isn't configured on the server — retrying won't help. Please contact an admin."
7678
},
7779
"settings": {
7880
"theme": {

‎messages/zh.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@
7272
"github": "用 GitHub 登录",
7373
"discord": "用 Discord 登录",
7474
"errorDiscordCanary": "Discord 登录正在灰度测试中,暂未对外开放,敬请期待~",
75-
"errorGeneric": "登录未完成,请重试。"
75+
"errorGeneric": "登录未完成,请重试。",
76+
"errorOauthState": "登录会话已失效(可能是等待授权太久,或浏览器拦截了 Cookie)。请重新点击登录。",
77+
"errorOauthProvider": "该登录方式服务端未配置好,重试也无法解决。请联系管理员。"
7678
},
7779
"settings": {
7880
"theme": {

‎tests/login-error-notice.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from "vitest";
2+
import enMessages from "../messages/en.json";
3+
import zhMessages from "../messages/zh.json";
4+
import { resolveErrorKey } from "../app/components/LoginErrorNotice";
5+
6+
// 后端 OAuthController 实际会重定向的全部 error code。新增 code 时两边一起加,
7+
// 否则用户只会看到通用的"请重试"(对 oauth_provider 这类重试无用的错误是误导)。
8+
const BACKEND_ERROR_CODES = [
9+
"discord_canary",
10+
"oauth_failed",
11+
"oauth_state",
12+
"oauth_provider",
13+
];
14+
15+
describe("resolveErrorKey", () => {
16+
it("没有 error 参数时不显示提示", () => {
17+
expect(resolveErrorKey(null)).toBeNull();
18+
expect(resolveErrorKey("")).toBeNull();
19+
});
20+
21+
it("后端每个 error code 都有专属文案,不塌缩成通用提示", () => {
22+
for (const code of BACKEND_ERROR_CODES) {
23+
const key = resolveErrorKey(code);
24+
expect(key, `${code} 应有映射`).not.toBeNull();
25+
if (code !== "oauth_failed") {
26+
expect(key, `${code} 不该塌缩成通用文案`).not.toBe("errorGeneric");
27+
}
28+
}
29+
});
30+
31+
// 回归:直接用 error 索引对象会命中原型链,?error=__proto__ 取到 Object.prototype
32+
// (非 undefined,?? 兜底不触发),渲染非字符串会让整个登录页崩掉。
33+
it("原型链上的键不会逃逸,一律落到通用文案", () => {
34+
for (const key of [
35+
"__proto__",
36+
"constructor",
37+
"toString",
38+
"valueOf",
39+
"hasOwnProperty",
40+
]) {
41+
expect(resolveErrorKey(key), `${key} 必须被兜住`).toBe("errorGeneric");
42+
}
43+
});
44+
45+
it("未知 code 落到通用文案", () => {
46+
expect(resolveErrorKey("wat")).toBe("errorGeneric");
47+
});
48+
49+
it("解析出的 key 在 zh/en 里都存在", () => {
50+
for (const code of [...BACKEND_ERROR_CODES, "__proto__", "wat"]) {
51+
const key = resolveErrorKey(code)!;
52+
expect(zhMessages.login, `zh 缺 ${key}`).toHaveProperty(key);
53+
expect(enMessages.login, `en 缺 ${key}`).toHaveProperty(key);
54+
}
55+
});
56+
});

0 commit comments

Comments
 (0)