Skip to content

Commit fc864c5

Browse files
committed
fix(review): 修复 Copilot PR CR 8 条
安全修复(P0): - fetchProfile 只在后端真返 404 或 success=false 时 notFound();其他非 2xx 抛错进 error boundary (避免后端故障伪装成"用户不存在") - links/projects/papers 的 URL 在渲染前过 sanitizeExternalUrl 白名单 (仅 http/https/mailto + 相对路径,拦 javascript:/data: 等 XSS 向量) - ProfileCard 内部再加 safeHref 二次防御 UX / a11y(P1): - ProfileCard 的 click toggle 用 matchMedia('(hover: none)') 限定触屏设备 (桌面端仍走 group-hover,避免"离开 hover 后仍保持展开"幽灵状态) - 补 role="button" / tabIndex=0 / onKeyDown(Enter|Space) / aria-expanded, 键盘 / 读屏用户可用;只在有 detail 时才挂这些属性 文档(P2): - docs/architecture/frontend-backend-separation.md 环境变量章节改成"生产禁 fallback,开发态过渡允许",与现状(next.config.mjs/upload 仍用 fallback)自洽 - fetchProfile 注释说"直连后端(BACKEND_URL)",不再错写"走 rewrite"
1 parent cdf9948 commit fc864c5

3 files changed

Lines changed: 158 additions & 30 deletions

File tree

app/u/[username]/ProfileCard.tsx

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,31 @@
33
import { useState } from "react";
44
import Link from "next/link";
55

6+
/**
7+
* URL scheme 白名单:仅允许 http(s)/mailto 和相对路径(/ 开头)。
8+
* 防备 preferences 里注入 javascript: / data: 等导致的 XSS。
9+
* 上游 page.tsx 已做一次过滤,这里二次防御以免组件被复用到未过滤的地方。
10+
*/
11+
function safeHref(raw: string | undefined | null): string | null {
12+
if (!raw || typeof raw !== "string") return null;
13+
const trimmed = raw.trim();
14+
if (!trimmed) return null;
15+
try {
16+
if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return trimmed;
17+
const u = new URL(trimmed);
18+
if (
19+
u.protocol === "http:" ||
20+
u.protocol === "https:" ||
21+
u.protocol === "mailto:"
22+
) {
23+
return u.toString();
24+
}
25+
return null;
26+
} catch {
27+
return null;
28+
}
29+
}
30+
631
interface ProfileCardProps {
732
kind: "PROJ" | "PAPER" | "DOC";
833
index: number;
@@ -41,15 +66,41 @@ export function ProfileCard({
4166
DOC: "Docs",
4267
}[kind];
4368

69+
// desktop 走 hover(CSS 驱动,见下面的 group-hover 样式),mobile 走 tap toggle;
70+
// 这里用 `@media (hover: none)` 探测"不支持 hover 的设备"(触屏),只有这种设备才在 click 时翻转 state。
71+
// 避免桌面端点击导致"离开 hover 后仍保持展开"的幽灵状态。
72+
const toggleIfTouchDevice = () => {
73+
if (typeof window === "undefined") return;
74+
if (window.matchMedia?.("(hover: none)").matches) {
75+
setExpanded((v) => !v);
76+
}
77+
};
78+
79+
const hasDetail = Boolean(detail);
80+
4481
return (
4582
<article
46-
// onClick 提供 mobile tap toggle;desktop 的 hover 通过 group-hover 样式实现
47-
onClick={() => setExpanded((v) => !v)}
83+
onClick={hasDetail ? toggleIfTouchDevice : undefined}
84+
onKeyDown={
85+
hasDetail
86+
? (e) => {
87+
if (e.key === "Enter" || e.key === " ") {
88+
e.preventDefault();
89+
setExpanded((v) => !v);
90+
}
91+
}
92+
: undefined
93+
}
94+
role={hasDetail ? "button" : undefined}
95+
tabIndex={hasDetail ? 0 : undefined}
96+
aria-expanded={hasDetail ? expanded : undefined}
4897
className={[
4998
"group relative border border-[var(--foreground)] bg-[var(--background)]",
50-
"p-6 flex flex-col gap-3 min-h-[180px] cursor-pointer",
99+
"p-6 flex flex-col gap-3 min-h-[180px]",
100+
hasDetail ? "cursor-pointer" : "",
51101
"transition-shadow duration-200 ease-out",
52102
"hover:shadow-[6px_6px_0_var(--foreground)]",
103+
"focus-visible:outline-none focus-visible:shadow-[6px_6px_0_var(--foreground)] focus-visible:ring-2 focus-visible:ring-[#CC0000]",
53104
spanFull ? "sm:col-span-2" : "",
54105
].join(" ")}
55106
>
@@ -96,12 +147,16 @@ export function ProfileCard({
96147
</div>
97148
)}
98149

99-
{href && (
150+
{safeHref(href) && (
100151
<div className="mt-auto pt-3">
101152
<Link
102-
href={href}
103-
target={href.startsWith("http") ? "_blank" : undefined}
104-
rel={href.startsWith("http") ? "noopener noreferrer" : undefined}
153+
href={safeHref(href)!}
154+
target={safeHref(href)!.startsWith("http") ? "_blank" : undefined}
155+
rel={
156+
safeHref(href)!.startsWith("http")
157+
? "noopener noreferrer"
158+
: undefined
159+
}
105160
onClick={(e) => e.stopPropagation()}
106161
className="font-mono text-[10px] uppercase tracking-widest text-[#CC0000] hover:underline"
107162
>

app/u/[username]/page.tsx

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -102,22 +102,74 @@ interface ProfileResponse {
102102

103103
/**
104104
* SSR 获取用户主页基础信息(账户 + preferences)。
105+
* 服务端使用 BACKEND_URL 直连 Java 后端(不走 next.config.mjs rewrites,那是给浏览器用的)。
105106
* 贡献文档不走后端 DB,而是在组件里从 build-time 的 site-leaderboard.json 读取,
106107
* 目的:每次访问 /u/{x} 省一次 Neon 查询(免费额度有限)。docs 本来就是 git-based,
107108
* JSON 新鲜度和 DB 一致,都是 deploy 级。
109+
*
110+
* 错误策略:只有后端明确返回 404 或 success=false 才 return null(走 notFound())。
111+
* 其他失败(500 / 网关异常 / JSON 解析错 / BACKEND_URL 缺失)一律抛错,
112+
* 让 Next error boundary 兜底,避免把"后端故障"伪装成"用户不存在"。
108113
*/
114+
function warnFetchProfile(message: string, details?: Record<string, unknown>) {
115+
if (process.env.NODE_ENV !== "production") {
116+
console.warn(`[fetchProfile] ${message}`, details ?? {});
117+
}
118+
}
119+
109120
async function fetchProfile(identifier: string): Promise<ProfileData | null> {
110121
const backendUrl = process.env.BACKEND_URL;
111-
if (!backendUrl) return null;
112-
try {
113-
const res = await fetch(
114-
`${backendUrl}/api/user-center/profile/${encodeURIComponent(identifier)}`,
115-
{ next: { revalidate: 300 } },
122+
if (!backendUrl) {
123+
// 关键配置缺失不能静默 notFound,给个可见错误
124+
throw new Error("BACKEND_URL is not configured");
125+
}
126+
const res = await fetch(
127+
`${backendUrl}/api/user-center/profile/${encodeURIComponent(identifier)}`,
128+
{ next: { revalidate: 300 } },
129+
);
130+
// 404:用户确实不存在 → notFound
131+
if (res.status === 404) {
132+
warnFetchProfile("backend 404", { identifier });
133+
return null;
134+
}
135+
// 其他非 2xx 都抛,进 Next error boundary
136+
if (!res.ok) {
137+
throw new Error(
138+
`profile backend ${res.status} ${res.statusText} for ${identifier}`,
116139
);
117-
if (!res.ok) return null;
118-
const json = (await res.json()) as ProfileResponse;
119-
if (!json.success || !json.data) return null;
120-
return json.data;
140+
}
141+
const json = (await res.json()) as ProfileResponse;
142+
// 后端用 {success:false, message:"用户不存在"} 表示软 404
143+
if (!json.success || !json.data) {
144+
warnFetchProfile("backend success=false", {
145+
identifier,
146+
message: json.message,
147+
});
148+
return null;
149+
}
150+
return json.data;
151+
}
152+
153+
/**
154+
* URL scheme 白名单:仅允许 http(s)/mailto,拦截 javascript: / data: 等向量。
155+
* 任何 preferences 里的 URL 在渲染前必须过这里。
156+
*/
157+
function sanitizeExternalUrl(raw: string | undefined | null): string | null {
158+
if (!raw || typeof raw !== "string") return null;
159+
const trimmed = raw.trim();
160+
if (!trimmed) return null;
161+
try {
162+
// 允许相对路径(以 / 开头),直接放行
163+
if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return trimmed;
164+
const u = new URL(trimmed);
165+
if (
166+
u.protocol === "http:" ||
167+
u.protocol === "https:" ||
168+
u.protocol === "mailto:"
169+
) {
170+
return u.toString();
171+
}
172+
return null;
121173
} catch {
122174
return null;
123175
}
@@ -283,17 +335,32 @@ export default async function UserProfilePage({ params }: Param) {
283335
<FollowButton identifier={username} targetUserId={user.id} />
284336
{links.length > 0 && (
285337
<div className="border-t border-[var(--foreground)] pt-4 flex flex-wrap gap-2">
286-
{links.slice(0, 5).map((link) => (
287-
<a
288-
key={link.url}
289-
href={link.url}
290-
target="_blank"
291-
rel="noopener noreferrer"
292-
className="font-mono text-[10px] uppercase tracking-widest px-2 py-1 border border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-colors"
293-
>
294-
{link.label}
295-
</a>
296-
))}
338+
{links.slice(0, 5).map((link, idx) => {
339+
// 过滤 javascript: / data: 等危险 scheme,不合法直接渲染成不可点击的纯文本
340+
const safe = sanitizeExternalUrl(link.url);
341+
if (!safe) {
342+
return (
343+
<span
344+
key={`${link.label}-${idx}`}
345+
className="font-mono text-[10px] uppercase tracking-widest px-2 py-1 border border-dashed border-neutral-400 text-neutral-400"
346+
title="链接协议不安全,已禁用"
347+
>
348+
{link.label}
349+
</span>
350+
);
351+
}
352+
return (
353+
<a
354+
key={safe}
355+
href={safe}
356+
target="_blank"
357+
rel="noopener noreferrer"
358+
className="font-mono text-[10px] uppercase tracking-widest px-2 py-1 border border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-colors"
359+
>
360+
{link.label}
361+
</a>
362+
);
363+
})}
297364
</div>
298365
)}
299366
</section>
@@ -309,7 +376,7 @@ export default async function UserProfilePage({ params }: Param) {
309376
meta={p.tags?.join(" · ")}
310377
summary={p.description}
311378
detail={p.description}
312-
href={p.url}
379+
href={sanitizeExternalUrl(p.url) ?? undefined}
313380
/>
314381
))}
315382
{papers.map((p, idx) => (
@@ -322,7 +389,7 @@ export default async function UserProfilePage({ params }: Param) {
322389
meta={[p.authors, p.year].filter(Boolean).join(", ")}
323390
summary={p.abstract}
324391
detail={p.abstract}
325-
href={p.url}
392+
href={sanitizeExternalUrl(p.url) ?? undefined}
326393
/>
327394
))}
328395
{docs.slice(0, 8).map((doc, idx) => (

docs/architecture/frontend-backend-separation.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,13 @@ involutionhell.com 早期为了快,前端 Next.js 做了不少后端该做的
9393

9494
## 环境变量约定
9595

96-
**不做硬编码 fallback**`?? "http://localhost:8081"` 这种禁止)。理由:端口不一致(8080/8081/其他),fallback 到错的端口会让配置漏配变成静默失败。
96+
**生产环境不做硬编码 fallback**`?? "http://localhost:8081"` 这类写法在生产环境禁止)。
97+
当前仓库里还有少量开发态 / 历史路径在用 `BACKEND_URL ?? "http://localhost:8080"`
98+
(例如 `next.config.mjs` 的 rewrites 和 `app/api/upload/route.ts`),目的是本地联调
99+
时减少启动门槛;这些只是过渡状态,**不能作为长期约定**
100+
101+
原因:端口不一致(8080/8081/其他)时,fallback 很容易把"漏配"变成"静默请求到错误地址",
102+
排查成本更高。新代码应该在 env 缺失时显式报错或返回空结果。
97103

98104
| 变量 | 前端读取场景 | 设置位置 |
99105
| ------------------------- | --------------------------- | -------------------------------------- |

0 commit comments

Comments
 (0)