diff --git a/src/api/routes.ts b/src/api/routes.ts index 6503d18098..7ff51ed8d5 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -256,6 +256,44 @@ async function recordRouteProductUsage( }).catch(() => undefined); } +const QUEUE_INTELLIGENCE_MAX_BODY_BYTES = 1024 * 1024; +const QUEUE_INTELLIGENCE_MAX_PULL_REQUESTS = 250; +const QUEUE_INTELLIGENCE_MAX_AUTHOR_LENGTH = 100; +const QUEUE_INTELLIGENCE_MAX_TITLE_LENGTH = 300; +const QUEUE_INTELLIGENCE_MAX_BODY_LENGTH = 4000; +const QUEUE_INTELLIGENCE_MAX_DUPLICATE_CANDIDATES = 25; + +function parsePositiveInt(value: string | null | undefined): number | null { + if (!value) return null; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return null; + return parsed; +} + +async function readRequestBodyWithLimit(request: Request, maxBytes: number): Promise { + const stream = request.body; + if (!stream) return ""; + const reader = stream.getReader(); + const decoder = new TextDecoder(); + const chunks: string[] = []; + let total = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => undefined); + return null; + } + chunks.push(decoder.decode(value, { stream: true })); + } + + chunks.push(decoder.decode()); + return chunks.join(""); +} + const MAX_LOCAL_BRANCH_REF_CHARS = 256; const MAX_LOCAL_BRANCH_TEXT_CHARS = 4000; const PR_VISIBILITY_SKIP_REASONS = [ @@ -2225,13 +2263,29 @@ export function createApp() { }); app.post("/v1/internal/queue-intelligence", async (c) => { - const body = await c.req.json().catch(() => null); - if (!body || !Array.isArray(body.pullRequests)) { + const contentLength = parsePositiveInt(c.req.header("content-length")); + if (contentLength !== null && contentLength > QUEUE_INTELLIGENCE_MAX_BODY_BYTES) { + return c.json({ error: "payload_too_large", maxBytes: QUEUE_INTELLIGENCE_MAX_BODY_BYTES }, 413); + } + + const rawBody = await readRequestBodyWithLimit(c.req.raw, QUEUE_INTELLIGENCE_MAX_BODY_BYTES); + if (rawBody === null) { + return c.json({ error: "payload_too_large", maxBytes: QUEUE_INTELLIGENCE_MAX_BODY_BYTES }, 413); + } + + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + body = null; + } + if (!body || typeof body !== "object" || !Array.isArray((body as { pullRequests?: unknown }).pullRequests)) { return c.json({ error: "invalid_request", detail: "pullRequests array required" }, 400); } + const queueBody = body as { pullRequests: unknown[]; repoContext?: unknown }; const prSchema = z.object({ number: z.number().int().positive(), - author: z.string(), + author: z.string().max(QUEUE_INTELLIGENCE_MAX_AUTHOR_LENGTH), authorRole: z.enum(["first-time", "contributor", "maintainer"] as [AuthorRole, ...AuthorRole[]]), isConfirmedMiner: z.boolean(), linkedIssue: z.object({ qualityScore: z.number().min(0).max(1) }).nullable(), @@ -2239,9 +2293,9 @@ export function createApp() { isStale: z.boolean(), additions: z.number().int().nonnegative(), deletions: z.number().int().nonnegative(), - title: z.string(), - body: z.string(), - duplicateCandidates: z.array(z.number().int().positive()), + title: z.string().max(QUEUE_INTELLIGENCE_MAX_TITLE_LENGTH), + body: z.string().max(QUEUE_INTELLIGENCE_MAX_BODY_LENGTH), + duplicateCandidates: z.array(z.number().int().positive()).max(QUEUE_INTELLIGENCE_MAX_DUPLICATE_CANDIDATES), createdAt: z.string().datetime(), lastUpdatedAt: z.string().datetime(), }); @@ -2250,10 +2304,10 @@ export function createApp() { avgReviewTimeDays: z.number().nonnegative(), maintainerWorkload: z.number().min(0).max(1), }); - const prsResult = z.array(prSchema).safeParse(body.pullRequests); + const prsResult = z.array(prSchema).max(QUEUE_INTELLIGENCE_MAX_PULL_REQUESTS).safeParse(queueBody.pullRequests); if (!prsResult.success) return c.json({ error: "invalid_request", issues: prsResult.error.issues }, 400); - const repoContext = repoContextSchema.safeParse(body.repoContext).success - ? repoContextSchema.parse(body.repoContext) + const repoContext = repoContextSchema.safeParse(queueBody.repoContext).success + ? repoContextSchema.parse(queueBody.repoContext) : { totalOpenPRs: 0, avgReviewTimeDays: 0, maintainerWorkload: 0 }; const result = await analyzePRQueue(prsResult.data, repoContext); const recommendations: Record = {}; diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts index c30fd7904e..b1b66c88df 100644 --- a/src/auth/rate-limit.ts +++ b/src/auth/rate-limit.ts @@ -109,7 +109,8 @@ export function routeClassForPath(path: string): RateLimitClass { path.includes("/internal/jobs/generate-signal-snapshots") || path.includes("/internal/jobs/build-contributor-decision-packs") || path.includes("/internal/jobs/refresh-upstream-drift") || - path.includes("/internal/jobs/file-upstream-drift-issues") + path.includes("/internal/jobs/file-upstream-drift-issues") || + path.includes("/internal/queue-intelligence") ) { return "expensive"; } diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 3dff912ba6..46e8e1e424 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -988,6 +988,100 @@ describe("api routes", () => { ); expect(invalidRepoContext.status).toBe(200); + const boundedQueuePr = { + number: 1, + author: "alice", + authorRole: "contributor", + isConfirmedMiner: true, + linkedIssue: { qualityScore: 0.9 }, + checksStatus: "passing", + isStale: false, + additions: 50, + deletions: 10, + title: "Fix cache", + body: "Fixes #1", + duplicateCandidates: [], + createdAt: new Date(Date.now() - 5 * 86400000).toISOString(), + lastUpdatedAt: new Date(Date.now() - 3600000).toISOString(), + }; + + const tooManyQueuePRs = await app.request( + "/v1/internal/queue-intelligence", + { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ pullRequests: Array.from({ length: 251 }, (_, index) => ({ ...boundedQueuePr, number: index + 1 })) }), + }, + env, + ); + expect(tooManyQueuePRs.status).toBe(400); + + const oversizedQueueFields = await app.request( + "/v1/internal/queue-intelligence", + { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ + pullRequests: [ + { + ...boundedQueuePr, + author: "a".repeat(101), + title: "t".repeat(301), + body: "b".repeat(4001), + duplicateCandidates: Array.from({ length: 26 }, (_, index) => index + 1), + }, + ], + }), + }, + env, + ); + expect(oversizedQueueFields.status).toBe(400); + + const oversizedQueuePayload = await app.request( + "/v1/internal/queue-intelligence", + { + method: "POST", + headers: { ...internalHeaders(env), "content-length": "1048577" }, + body: JSON.stringify({}), + }, + env, + ); + expect(oversizedQueuePayload.status).toBe(413); + await expect(oversizedQueuePayload.json()).resolves.toMatchObject({ error: "payload_too_large", maxBytes: 1048576 }); + + const oversizedQueueStream = await app.request( + "/v1/internal/queue-intelligence", + { + method: "POST", + headers: internalHeaders(env), + body: "x".repeat(1048577), + }, + env, + ); + expect(oversizedQueueStream.status).toBe(413); + await expect(oversizedQueueStream.json()).resolves.toMatchObject({ error: "payload_too_large", maxBytes: 1048576 }); + + const invalidQueueContentLength = await app.request( + "/v1/internal/queue-intelligence", + { + method: "POST", + headers: { ...internalHeaders(env), "content-length": "not-a-number" }, + body: JSON.stringify({ pullRequests: [boundedQueuePr] }), + }, + env, + ); + expect(invalidQueueContentLength.status).toBe(200); + + const missingQueueBody = await app.request( + "/v1/internal/queue-intelligence", + { + method: "POST", + headers: internalHeaders(env), + }, + env, + ); + expect(missingQueueBody.status).toBe(400); + const localBranchAnalysis = await app.request( "/v1/local/branch-analysis", { diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index e7051d885d..4e583216e2 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -90,6 +90,7 @@ describe("private-beta auth and rate limiting", () => { expect(routeClassForPath("/v1/internal/jobs/generate-signal-snapshots")).toBe("expensive"); expect(routeClassForPath("/v1/internal/jobs/build-contributor-decision-packs")).toBe("expensive"); expect(routeClassForPath("/v1/internal/jobs/refresh-upstream-drift")).toBe("expensive"); + expect(routeClassForPath("/v1/internal/queue-intelligence")).toBe("expensive"); expect(routeClassForPath("/v1/repos")).toBe("normal"); });