Skip to content

Commit b9f6421

Browse files
committed
chore(docs-history): CR - 路由补路径校验与 403 细分
接着前一 commit 补落下的 route.ts 改动(SSRF 防护 + 403 区分限流/权限不足 + 401 单独处理 + 头像兜底)
1 parent 7f7b4cf commit b9f6421

1 file changed

Lines changed: 61 additions & 15 deletions

File tree

app/api/docs/history/route.ts

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextRequest, NextResponse } from "next/server";
2+
import type { HistoryItem } from "@/app/types/docs-history";
23

3-
// 响应缓存 1 小时,GitHub API 每小时限额 5000 次
4+
// 响应缓存 1 小时,GitHub API 每小时限额 5000 次(authenticated)
45
export const revalidate = 3600;
56

67
interface GitHubCommit {
@@ -19,23 +20,51 @@ interface GitHubCommit {
1920
html_url: string;
2021
}
2122

22-
export interface HistoryItem {
23-
sha: string;
24-
authorName: string;
25-
authorLogin: string;
26-
avatarUrl: string;
27-
date: string;
28-
message: string;
29-
htmlUrl: string;
23+
/**
24+
* 规范化前端传入的文档路径为仓库根相对路径(GitHub API 要求)。
25+
*
26+
* 接受的输入形态:
27+
* - `app/docs/ai/...`(仓库根相对)→ 原样返回
28+
* - `docs/ai/...` → 前面补 `app/`
29+
* - `/docs/ai/...`(浏览器 URL 风格)→ 去开头斜杠再补 `app/`
30+
*
31+
* 拒绝:含 `..`、反斜杠、null 字节;最终不落在 `app/docs/` 下的路径一律拒绝,
32+
* 避免用服务端 GITHUB_TOKEN 被动泄露仓库内任意文件的 commit 信息。
33+
*/
34+
function normalizeDocsPath(raw: string): string | null {
35+
if (!raw) return null;
36+
// 路径穿越 / 反斜杠 / null 字节 直接拒
37+
if (raw.includes("..") || raw.includes("\\") || raw.includes("\0")) {
38+
return null;
39+
}
40+
41+
let normalized = raw;
42+
// URL 风格 /docs/... → docs/...
43+
if (normalized.startsWith("/")) {
44+
normalized = normalized.slice(1);
45+
}
46+
// docs/... → app/docs/...
47+
if (normalized.startsWith("docs/")) {
48+
normalized = `app/${normalized}`;
49+
}
50+
// 必须落在 app/docs/ 下才放行
51+
if (!normalized.startsWith("app/docs/")) {
52+
return null;
53+
}
54+
return normalized;
3055
}
3156

3257
export async function GET(req: NextRequest) {
3358
const { searchParams } = new URL(req.url);
34-
const path = searchParams.get("path");
59+
const rawPath = searchParams.get("path");
3560

61+
const path = rawPath ? normalizeDocsPath(rawPath) : null;
3662
if (!path) {
3763
return NextResponse.json(
38-
{ success: false, error: "缺少 path 参数" },
64+
{
65+
success: false,
66+
error: "缺少合法的 path 参数(仅允许 app/docs/ 路径)",
67+
},
3968
{ status: 400 },
4069
);
4170
}
@@ -68,10 +97,25 @@ export async function GET(req: NextRequest) {
6897
);
6998
}
7099

100+
// 403 可能是限流、也可能是 token 权限不足 / 仓库不可访问;用 x-ratelimit-remaining 区分
71101
if (res.status === 403) {
102+
const rateRemaining = res.headers.get("x-ratelimit-remaining");
103+
if (rateRemaining === "0") {
104+
return NextResponse.json(
105+
{ success: false, error: "GitHub API 限流,请稍后重试" },
106+
{ status: 429 },
107+
);
108+
}
109+
return NextResponse.json(
110+
{ success: false, error: "GitHub API 403(可能 token 权限不足)" },
111+
{ status: 403 },
112+
);
113+
}
114+
115+
if (res.status === 401) {
72116
return NextResponse.json(
73-
{ success: false, error: "GitHub API 限流,请稍后重试" },
74-
{ status: 429 },
117+
{ success: false, error: "GitHub token 无效或过期" },
118+
{ status: 401 },
75119
);
76120
}
77121

@@ -87,9 +131,11 @@ export async function GET(req: NextRequest) {
87131
const data: HistoryItem[] = commits.map((c) => ({
88132
sha: c.sha,
89133
authorName: c.commit.author.name,
134+
// author 为 null 时(commit 作者邮箱未关联 GitHub 账号),login 退回展示名
90135
authorLogin: c.author?.login ?? c.commit.author.name,
91-
avatarUrl:
92-
c.author?.avatar_url ?? `https://github.com/${c.commit.author.name}.png`,
136+
// commit.author.name 是展示名(可能含中文/空格),拼 github.com/<name>.png 容易 404;
137+
// 仅在有真实 author 时用其 avatar_url,否则返回空串让前端用占位资源
138+
avatarUrl: c.author?.avatar_url ?? "",
93139
date: c.commit.author.date,
94140
// 只取 commit message 第一行
95141
message: c.commit.message.split("\n")[0],

0 commit comments

Comments
 (0)