diff --git a/src/app/api/foundry/[...path]/route.ts b/src/app/api/foundry/[...path]/route.ts index 11e018c..cb3ebec 100644 --- a/src/app/api/foundry/[...path]/route.ts +++ b/src/app/api/foundry/[...path]/route.ts @@ -41,6 +41,7 @@ async function readBody(req: NextRequest, limit = bodyLimit, binary = false) { } // 空body普通模式用空对象,binary模式保留空字节;无reader则早已返回空对象。 if (!size) return binary ? new Uint8Array() : {}; + // chunks 与合并 Buffer 在此可能同时占内存;limit 约束载荷字节,不是整个请求处理的堆内存上限。 const bytes = Buffer.concat(chunks); return binary ? bytes : JSON.parse(bytes.toString("utf8")); } @@ -67,6 +68,7 @@ async function handler( return json({ error: "invalid_origin" }, 403); } } + // Origin 比对只使用 host(含端口),不要求 scheme 相同;也不在代理本地解析会话账户权限。 const path = (await params).path.join("/"); if (!allowed.test(path) && !canvasRoute.test(path) && !assetRoute.test(path)) return json({ error: "not_found" }, 404); // operations/metadata返回JSON,不能被当成原图流,也不使用图片multipart请求体上限。 @@ -75,6 +77,7 @@ async function handler( const asset = assetRoute.test(path) && !assetReceipt && !assetMetadata; const canvas = canvasRoute.test(path); const canonical = canvas && /\/canvas\/model(?:\/|$)/.test(path); + // frozenAI 仅匹配 ai/runs 分支,ai/operations 查询不由此标记;新文档 model 路径整体由 canonical 覆盖。 const frozenAI = canvas && /\/canvas\/ai\/runs(?:\/|$)/.test(path); let requestBody: unknown; // 在网络转发前完成请求体限额:原始图片含multipart开销,新模型/AI写入保留原请求字节。 @@ -108,6 +111,7 @@ async function handler( const query = new URLSearchParams(); for (const key of ["kind", "sort", "q", "limit", "offset", "after_seq", "status", "include_descendants", "include_subgraphs", "cursor", "variant", "width"]) { const values = req.nextUrl.searchParams.getAll(key); + // 不合规则的已知参数和名单外参数都静默不转发,不在这里返回 invalid_request。 if (values.length === 1 && values[0].length < 300) query.set(key, values[0]); } const encodingStarted = performance.now(); @@ -119,6 +123,7 @@ async function handler( try { // 模型/旧快照与图片读取使用分层超时流;其它请求组合15秒总超时和客户端取消。 const snapshotRead = canvas && (path.endsWith("/canvas") || canonical) && req.method === "GET"; + // canonical GET 包括操作回执查询;只有这些 GET 使用分层流超时,写入仍用下方15秒总时限。 const streamedRead = snapshotRead || asset && req.method === "GET"; const snapshotWrite = canvas && path.endsWith("/canvas") && req.method === "PATCH"; const traceId = streamedRead || snapshotWrite ? crypto.randomUUID() : ""; @@ -151,12 +156,14 @@ async function handler( const renewal = upstream.headers.get(SESSION_EXPIRY_HEADER); if (session && upstream.status !== 401 && renewal !== null && /^\d+$/.test(renewal)) { const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https"; + // 纯数字也可能超出安全整数,sessionMaxAge 此时回退默认30天;续期不代表这里验证了账户资料。 responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewal), secure)); } // 成功素材GET只透传允许的图片MIME并加nosniff;不是任意内容的同源文件托管。 if (asset && upstream.ok && req.method === "GET") { const type = upstream.headers.get("content-type") || ""; if (!/^image\/(png|jpeg|webp|gif)(?:;|$)/i.test(type)) { await upstream.body?.cancel(); return json({ error: "invalid_asset" }, 502); } + // MIME 白名单只看响应头,不解码图片或核对实际字节类型;内容权威校验属于上游资源协议。 responseHeaders.set("Content-Type", type); responseHeaders.set("X-Content-Type-Options", "nosniff"); const variant = upstream.headers.get("x-asset-variant"); if (variant === "original" || variant === "preview") responseHeaders.set("X-Asset-Variant", variant); @@ -173,6 +180,7 @@ async function handler( return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders }); } // 普通响应整体解JSON,因为登录/交换成功时须把access_token换成HttpOnly Cookie并从body移除。 + // 普通响应没有预解析字节上限;请求体 limit 不能用来证明上游 JSON 响应也受同样约束。 const body = upstream.status === 204 ? null : await upstream.json(); // 旧快照写入trace分出接收/重编码/上游/响应耗时,不输出请求正文;字节数仅对字符串body计算。 if (snapshotWrite) console.info("[canvas-save]", JSON.stringify({ @@ -190,6 +198,7 @@ async function handler( ); const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https"; // 登录/交换令牌只进Set-Cookie,不回到浏览器可读JSON;其它会话响应按上游剩余时间更新。 + // 按字段存在处理,不仅限定 auth 路径/成功状态;原始流透传分支不进入此令牌移除逻辑。 if (body?.access_token) { responseHeaders.set("Set-Cookie", sessionCookie(body.access_token, sessionMaxAge(body.expires_in), secure)); delete body.access_token; @@ -197,6 +206,7 @@ async function handler( const renewed = upstream.headers.get(SESSION_EXPIRY_HEADER); if (renewed !== null && /^\d+$/.test(renewed)) responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewed), secure)); } + // 401 不在这里主动清 Cookie;错误响应保持状态,客户端再决定登录/重试流程。 // 仅上游确认登出成功后清Cookie,不把一次网络失败当成成功退出。 if (path === "auth/logout" && upstream.ok) responseHeaders.set( diff --git a/src/app/api/oauth/[provider]/route.ts b/src/app/api/oauth/[provider]/route.ts index 3cc881c..1218fd1 100644 --- a/src/app/api/oauth/[provider]/route.ts +++ b/src/app/api/oauth/[provider]/route.ts @@ -12,6 +12,7 @@ export async function GET( ) { const { provider } = await params; const challenges = req.nextUrl.searchParams.getAll("challenge"); + // 这里只验证 challenge 字符表示,不验证 verifier/登录 state 或交换身份;额外查询参数也不会透传上游。 const challenge = challenges[0]; if ( !["google", "slack"].includes(provider) ||