docs(api): explain proxy and OAuth validation boundaries
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
Reference in New Issue
Block a user