Skip to content

Commit 2f233c4

Browse files
committed
fix(assistant): 回应 PR #295 CR — IP 防伪造 / 真实 hasImage / 上游错误匹配收紧
Copilot 提了 6 条 + CodeQL 2 条正则告警,全部修复: **lib/rate-limit.ts** - 文档头 usage 示例 API 改对(CR #1) - getClientIp 防伪造(CR #2,**安全修复**): 优先 x-real-ip(Vercel 等 CDN 写的是可信值);降级用 XFF 时取**最后一个** 而非首个,避免客户端伪造 `x-forwarded-for: fakeip` 绕过 rate-limit - Upstash 缺失 warn 改用 module-scoped flag,整个实例生命周期只打一次, 不再按 NODE_ENV 区分 —— dev 也得看到提示(CR #3) **app/api/chat/route.ts** - POST 入口预读 body 判定 hasImage,true 时触发 5 req/60s 严限流; 预读失败不阻塞,保持原有容错(CR #4) - 新增 messagesHaveImage helper:识别 type=image / image_url / file+image 媒体 - mapUpstreamError 不再把 err.stack 拼进匹配文本:stack 里的 `:429:` 行号 会误触发 rate-limited 分类(CR #5,**真实 bug**) - JSON.stringify 加 try/catch 兜底 String(err),避免循环引用再抛错(CR #6) - 所有业务码正则里的 `.*` 改成 `[^\s]{0,10}?`,限死回溯深度防 ReDoS (CodeQL polynomial regex 告警)
1 parent 161086a commit 2f233c4

2 files changed

Lines changed: 90 additions & 20 deletions

File tree

app/api/chat/route.ts

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,17 @@ export async function POST(req: Request) {
3333
// 0. Rate limit:免费模型 GLM-4.6V-Flash 并发极低(≈ 5),
3434
// 单用户开几个 tab 就能打爆。per-IP 滑动窗口限流先挡一层。
3535
// (L2 防护;如果 Upstash env 漏配会自动降级为放行+warn)
36-
const rl = await limitChat(req, false);
36+
//
37+
// 预读 body 判断是否带图(hasImage=true 会触发更严的 5 req/60s 窗口)。
38+
// 为此多克一次请求,后续 proxyReq/req.json() 仍可独立读(Copilot CR #4)。
39+
let hasImage = false;
40+
try {
41+
const body = (await req.clone().json()) as Partial<ChatRequest>;
42+
hasImage = messagesHaveImage(body.messages);
43+
} catch {
44+
// body 不是合法 JSON:按无图处理,继续让下游的 req.json() 去报真正的错
45+
}
46+
const rl = await limitChat(req, hasImage);
3747
if (!rl.success) return rateLimitResponse(rl);
3848

3949
// 1. 克隆请求,因为如果代理失败,后面的代码还需要读取 req.json()
@@ -261,6 +271,30 @@ export async function POST(req: Request) {
261271
}
262272
}
263273

274+
/**
275+
* 判断一组 UIMessage 里是否含图片 part。支持 AI SDK v5 的多种图片表达:
276+
* `type === "image"` / `type === "image_url"` / `type === "file"` 且 mediaType 起头 image。
277+
* 任何异常结构都当作无图,宁可放过也不误杀。
278+
*/
279+
function messagesHaveImage(messages: unknown): boolean {
280+
if (!Array.isArray(messages)) return false;
281+
return messages.some((msg) => {
282+
if (!msg || typeof msg !== "object") return false;
283+
const parts = (msg as { parts?: unknown }).parts;
284+
if (!Array.isArray(parts)) return false;
285+
return parts.some((part) => {
286+
if (!part || typeof part !== "object") return false;
287+
const type = (part as { type?: unknown }).type;
288+
if (type === "image" || type === "image_url") return true;
289+
if (type === "file") {
290+
const mediaType = (part as { mediaType?: unknown }).mediaType;
291+
return typeof mediaType === "string" && mediaType.startsWith("image/");
292+
}
293+
return false;
294+
});
295+
});
296+
}
297+
264298
interface MappedUpstreamError {
265299
status: number;
266300
code: "rate_limited" | "quota_exhausted" | "upstream_auth" | "upstream_down";
@@ -269,19 +303,33 @@ interface MappedUpstreamError {
269303

270304
function mapUpstreamError(err: unknown): MappedUpstreamError | null {
271305
if (!err) return null;
272-
const raw =
273-
err instanceof Error
274-
? `${err.message} ${(err as Error & { stack?: string }).stack ?? ""}`
275-
: typeof err === "string"
276-
? err
277-
: JSON.stringify(err);
278-
279-
// GLM/OpenAI-compatible 的错误通常把 HTTP status 和业务码都塞在 message 里
306+
307+
// 仅使用 message / response payload,**不要拼 stack** —— stack 里带行号
308+
// 形如 `:429:` / `:1302:` 会误匹配业务码正则(Copilot CR #5)。
309+
// JSON.stringify 对循环引用会抛错,用 try/catch 兜底(Copilot CR #6)。
310+
let raw: string;
311+
if (err instanceof Error) {
312+
raw = err.message;
313+
} else if (typeof err === "string") {
314+
raw = err;
315+
} else {
316+
try {
317+
raw = JSON.stringify(err);
318+
} catch {
319+
raw = String(err);
320+
}
321+
}
322+
323+
// 业务码正则:全部用 `[^\s]{0,N}?` 代替 `.*`,限死回溯深度避免 ReDoS
324+
// (CodeQL polynomial regex 告警)。关键词语义够短,10~20 字符窗口足够。
280325
const hasStatus429 = /\b429\b|rate[-_ ]?limit|too many requests/i.test(raw);
281326
const has1302 = /\b1302\b|||/.test(raw);
282-
const has1113 = /\b1113\b||.*|quota.*exhaust/i.test(raw);
327+
const has1113 =
328+
/\b1113\b||[^\s]{0,10}?|quota[^\s]{0,10}?exhaust/i.test(
329+
raw,
330+
);
283331
const hasAuth =
284-
/\b1001\b|\b1002\b|\b1003\b|\b401\b|unauthorized|invalid.*api.*key/i.test(
332+
/\b1001\b|\b1002\b|\b1003\b|\b401\b|unauthorized|invalid[^\s]{0,10}?api[^\s]{0,10}?key/i.test(
285333
raw,
286334
);
287335

lib/rate-limit.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
* 但生产必须配齐 UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN。
99
*
1010
* 使用:
11-
* const { success, reset } = await limitChat(req);
12-
* if (!success) return rateLimitResponse(reset);
11+
* const result = await limitChat(req);
12+
* if (!result.success) return rateLimitResponse(result);
1313
*/
1414
import { Ratelimit } from "@upstash/ratelimit";
1515
import { Redis } from "@upstash/redis";
@@ -18,6 +18,9 @@ import { Redis } from "@upstash/redis";
1818
let cachedChatLimiter: Ratelimit | null = null;
1919
let cachedChatImageLimiter: Ratelimit | null = null;
2020
let cachedDailyLimiter: Ratelimit | null = null;
21+
// Upstash env 缺失的 warn 只在模块生命周期内打一次,
22+
// 避免生产环境每请求刷爆 serverless 日志(Copilot CR #3)
23+
let hasWarnedMissingUpstash = false;
2124

2225
function getRedis(): Redis | null {
2326
const url = process.env.UPSTASH_REDIS_REST_URL;
@@ -70,13 +73,28 @@ function getDailyLimiter(): Ratelimit | null {
7073

7174
/**
7275
* 从 request headers 里提取客户端 IP。
73-
* Vercel 上优先 x-forwarded-for;本地开发回退到 x-real-ip 或 "anonymous"。
76+
*
77+
* 防伪造(Copilot CR #2):
78+
* - 优先读 `x-real-ip`:Vercel/多数 CDN 只写由自己验证过的真实客户端 IP,
79+
* 不会把客户端伪造的值透传进来,最可信。
80+
* - 没有 `x-real-ip` 时才降级到 `x-forwarded-for`;但不能取 XFF 的 **第一个**,
81+
* 因为那是客户端可以随便伪造的值;应该取 **最后一个非空项**,也就是最内层
82+
* 可信代理看到的实际来源地址。
83+
* - 都没有(本地 dev)时用固定字符串,所有请求共享一个额度桶,避免本地爆测。
7484
*/
7585
function getClientIp(req: Request): string {
76-
const xff = req.headers.get("x-forwarded-for");
77-
if (xff) return xff.split(",")[0].trim();
7886
const xri = req.headers.get("x-real-ip");
79-
if (xri) return xri.trim();
87+
if (xri && xri.trim()) return xri.trim();
88+
89+
const xff = req.headers.get("x-forwarded-for");
90+
if (xff) {
91+
const parts = xff
92+
.split(",")
93+
.map((ip) => ip.trim())
94+
.filter(Boolean);
95+
if (parts.length > 0) return parts[parts.length - 1];
96+
}
97+
8098
return "anonymous";
8199
}
82100

@@ -102,12 +120,16 @@ export async function limitChat(
102120
const minuteLimiter = hasImage ? getChatImageLimiter() : getChatLimiter();
103121
const dayLimiter = getDailyLimiter();
104122

105-
// Upstash 未配置:本地开发或生产漏配。不阻塞请求,但打 warn 提示运维。
123+
// Upstash 未配置:本地开发或生产漏配。不阻塞请求,只打一次 warn 提示运维。
124+
// 不再按 NODE_ENV 区分(dev 也提示,免得开发期"没限流却不知道"),
125+
// 用 module 级 flag 避免每请求刷爆日志(Copilot CR #3)。
106126
if (!minuteLimiter || !dayLimiter) {
107-
if (process.env.NODE_ENV === "production") {
127+
if (!hasWarnedMissingUpstash) {
128+
hasWarnedMissingUpstash = true;
108129
console.warn(
109130
"[rate-limit] UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN 未配置," +
110-
"生产环境聊天接口无限流保护,请尽快在 Vercel Env 中补齐。",
131+
"聊天接口暂无限流保护(本实例生命周期内不会再次提示)。" +
132+
"生产环境请在 Vercel Env 中补齐。",
111133
);
112134
}
113135
return {

0 commit comments

Comments
 (0)