// 请求体的入口闸:在**把整个 body 读进内存之前**就把超大请求挡掉。 // // 为什么需要它:业务上限都在解析之后才判——需求 64KB、内容 256KB、目录源 1MB(分层后喂模型 256KB)。这些上限保护的是"落库与喂给模型的东西", // 不保护"服务端的内存"。实测发一个 12MB 的 publish 请求:服务端**完整缓冲并解析** // 了 12MB,然后才回 4522 体积超限。Next 的路由处理器对请求体没有缺省上限 // (pages 时代的 bodyParser.sizeLimit 在 App Router 不适用),所以这条路上 // 唯一的上界就是机器内存——这台机器只有 2GB,且有过 OOM 整机重启的实弹教训。 // // 两道判定,顺序有讲究: // 1. **先看 Content-Length**:声明了多大就按多大拒,一个字节都不读。 // 2. **再按流计数**:分块传输没有 Content-Length,也可能有人谎报。边读边数, // 超了立刻停止读取并释放已读分片——这才是真正的上界,头部检查只是省一次读。 // // 解析失败不是这里的事:上层历来把坏 JSON 当空对象处理(协议宽进严出), // 这里只负责"太大就不读",解析语义保持不变。 /** 请求体超限:调用方负责映射成自己域里的错误码(仓库 4522 / AI 4503 等) */ export class RequestBodyTooLargeError extends Error { constructor( readonly limitBytes: number, /** 已知的实际大小;分块传输在触发时只知道"已经超过 limit",此时给 null */ readonly seenBytes: number | null, ) { const seen = seenBytes === null ? `超过 ${limitBytes}` : `${seenBytes}`; super(`请求体过大:${seen} 字节 / 上限 ${limitBytes} 字节`); this.name = 'RequestBodyTooLargeError'; } } /** * AI 生成端点的请求体上限。 * 当前一次真实调用约 250KB(组件目录 md 98KB + catalog.json 150KB), * revise 模式还要带底稿。4MB 留足组件库继续长大的空间(按现在的密度够 900+ 组件), * 同时把单请求的内存占用钉在一个这台机器扛得住的量级上。 */ export const AI_REQUEST_MAX_BYTES = 4 * 1024 * 1024; /** 只依赖请求的最小面,便于单测直接喂替身 */ export interface BodyLimitedRequest { headers?: { get(name: string): string | null }; body?: ReadableStream | null; json?: () => Promise; text?: () => Promise; } /** * 在字节上限内读取并解析 JSON 请求体。 * * - 超限抛 `RequestBodyTooLargeError`(读之前或读一半都可能); * - JSON 坏了返回 `null`——与既有的 `req.json().catch(() => null)` 语义一致, * 上层据此回落成空对象,协议宽进严出的行为不变; * - 拿不到 `body` 流时(测试替身、只实现了 json() 的运行时)回落 `json()`。 * 回落路径**没有流式上界**,所以头部检查仍然先跑一遍;真实的 Next 请求走流式路径。 */ export async function readJsonWithLimit( req: BodyLimitedRequest, maxBytes: number, ): Promise { assertContentLength(req, maxBytes); const stream = req.body; if (!stream || typeof stream.getReader !== 'function') { return await req.json?.().catch(() => null) ?? null; } const reader = stream.getReader(); const chunks: Uint8Array[] = []; let received = 0; try { for (;;) { const { done, value } = await reader.read(); if (done) break; if (!value) continue; received += value.byteLength; if (received > maxBytes) { // 立刻断流:再读下去就是替攻击者把内存填满 chunks.length = 0; await reader.cancel().catch(() => undefined); throw new RequestBodyTooLargeError(maxBytes, null); } chunks.push(value); } } finally { reader.releaseLock?.(); } if (received === 0) { return null; } try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return null; } } /** 头部检查:声明了多大就按多大拒,一个字节都不读(谎报的由流式计数兜住) */ function assertContentLength(req: BodyLimitedRequest, maxBytes: number): void { const raw = req.headers?.get('content-length'); if (raw === null || raw === undefined || raw === '') { return; } const declared = Number(raw); if (Number.isFinite(declared) && declared > maxBytes) { throw new RequestBodyTooLargeError(maxBytes, declared); } }