From 9e2c72b75e3deb969662102b111bd2d44ad4d1cd Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:41:31 -0700 Subject: [PATCH 1/2] fix(security): bound focus manifest ingestion --- src/api/routes.ts | 45 +++++++++++++++++++++++++++++++++++++++++---- src/mcp/server.ts | 17 ++++++++++++++++- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/api/routes.ts b/src/api/routes.ts index 8db71ad86c..65f9f7adf4 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -220,7 +220,7 @@ import { buildRepoOutcomeCalibration } from "../services/outcome-calibration"; import { loadGatePrecisionReport } from "../services/gate-precision"; import { buildMaintainerQualityDashboard, isMaintainerQualityDataStale } from "../services/maintainer-quality-dashboard"; import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../signals/local-scorer-diagnostics"; -import { compileFocusManifestPolicy } from "../signals/focus-manifest"; +import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest"; import { loadRepoFocusManifest, upsertRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack"; import { generateContributorIssueDrafts } from "../services/contributor-issue-draft"; @@ -301,6 +301,7 @@ async function recordRouteProductUsage( }).catch(() => undefined); } +const LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES = 1024 * 1024; const QUEUE_INTELLIGENCE_MAX_BODY_BYTES = 1024 * 1024; const QUEUE_INTELLIGENCE_MAX_PULL_REQUESTS = 250; const QUEUE_INTELLIGENCE_MAX_AUTHOR_LENGTH = 100; @@ -315,6 +316,14 @@ function parsePositiveInt(value: string | null | undefined): number | null { return parsed; } +function isJsonByteLengthWithinLimit(value: unknown, maxBytes: number): boolean { + try { + return new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxBytes; + } catch { + return false; + } +} + async function readRequestBodyWithLimit(request: Request, maxBytes: number): Promise { const stream = request.body; if (!stream) return ""; @@ -471,6 +480,12 @@ const branchEligibilitySchema = z }) .strict(); +const focusManifestInputSchema = z + .record(z.string(), z.unknown()) + .refine((manifest) => isJsonByteLengthWithinLimit(manifest, MAX_FOCUS_MANIFEST_BYTES), { + message: `focusManifest must serialize to ${MAX_FOCUS_MANIFEST_BYTES} bytes or fewer`, + }); + const localBranchAnalysisSchema = z .object({ login: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS), @@ -498,7 +513,7 @@ const localBranchAnalysisSchema = z scenarioNotes: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(20).optional(), pendingCommitCount: z.number().int().min(0).optional(), ciStatusHints: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(20).optional(), - focusManifest: z.record(z.string(), z.unknown()).optional(), + focusManifest: focusManifestInputSchema.optional(), branchEligibility: branchEligibilitySchema.optional(), }) .strict(); @@ -2457,7 +2472,18 @@ export function createApp() { }); app.post("/v1/local/branch-analysis", async (c) => { - const body = await c.req.json().catch(() => null); + const contentLength = parsePositiveInt(c.req.header("content-length")); + if (contentLength !== null && contentLength > LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES) { + return c.json({ error: "payload_too_large", maxBytes: LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES }, 413); + } + const rawBody = await readRequestBodyWithLimit(c.req.raw, LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES); + if (rawBody === null) return c.json({ error: "payload_too_large", maxBytes: LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES }, 413); + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + body = null; + } const parsed = localBranchAnalysisSchema.safeParse(body); if (!parsed.success) return c.json({ error: "invalid_local_branch_analysis_request", issues: parsed.error.issues }, 400); const unauthorized = await requireContributorAccess(c, parsed.data.login); @@ -2532,7 +2558,18 @@ export function createApp() { }); app.post("/v1/local/remediation-plan", async (c) => { - const body = await c.req.json().catch(() => null); + const contentLength = parsePositiveInt(c.req.header("content-length")); + if (contentLength !== null && contentLength > LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES) { + return c.json({ error: "payload_too_large", maxBytes: LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES }, 413); + } + const rawBody = await readRequestBodyWithLimit(c.req.raw, LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES); + if (rawBody === null) return c.json({ error: "payload_too_large", maxBytes: LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES }, 413); + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + body = null; + } const parsed = localBranchAnalysisSchema.safeParse(body); if (!parsed.success) return c.json({ error: "invalid_local_branch_analysis_request", issues: parsed.error.issues }, 400); const unauthorized = await requireContributorAccess(c, parsed.data.login); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ec60cea129..eab83b6b46 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -104,6 +104,7 @@ import { import { applyStepResult, buildPlanDag, nextReadySteps, planProgress, validatePlanDag, type PlanDag } from "../services/plan-dag"; import { isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "../settings/agent-execution"; import { AGENT_ACTION_CLASSES, isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy"; +import { MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildPredictedGateVerdict } from "../rules/predicted-gate"; import { buildIssueSlopAssessment, buildSlopAssessment, ISSUE_SLOP_RUBRIC_MARKDOWN, SLOP_RUBRIC_MARKDOWN } from "../signals/slop"; @@ -357,6 +358,20 @@ const automationStateOutputSchema = { pendingActionCount: z.number().optional(), }; +const focusManifestInputSchema = z + .record(z.string(), z.unknown()) + .refine((manifest) => isJsonByteLengthWithinLimit(manifest, MAX_FOCUS_MANIFEST_BYTES), { + message: `focusManifest must serialize to ${MAX_FOCUS_MANIFEST_BYTES} bytes or fewer`, + }); + +function isJsonByteLengthWithinLimit(value: unknown, maxBytes: number): boolean { + try { + return new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxBytes; + } catch { + return false; + } +} + const localBranchAnalysisShape = { login: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS), repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), @@ -380,7 +395,7 @@ const localBranchAnalysisShape = { expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), projectedCredibility: z.number().min(0).max(1).optional(), scenarioNotes: z.array(z.string()).max(20).optional(), - focusManifest: z.record(z.string(), z.unknown()).optional(), + focusManifest: focusManifestInputSchema.optional(), branchEligibility: z.object(branchEligibilityShape).strict().optional(), localScorer: z .object({ From 248e2c2a709212d34e5bc37030c06682127758d4 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:02:14 -0700 Subject: [PATCH 2/2] test: cover focus-manifest byte-cap rejection on local branch routes --- test/unit/routes-remediation-plan.test.ts | 91 +++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/test/unit/routes-remediation-plan.test.ts b/test/unit/routes-remediation-plan.test.ts index 143b67ca44..8013d8d296 100644 --- a/test/unit/routes-remediation-plan.test.ts +++ b/test/unit/routes-remediation-plan.test.ts @@ -4,8 +4,13 @@ import { createSessionForGitHubUser } from "../../src/auth/security"; import { upsertInstallation, upsertRepositoryFromGitHub } from "../../src/db/repositories"; import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; import { createTestEnv } from "../helpers/d1"; +import { MAX_FOCUS_MANIFEST_BYTES } from "../../src/signals/focus-manifest"; const PATH = "/v1/local/remediation-plan"; +const BRANCH_ANALYSIS_PATH = "/v1/local/branch-analysis"; + +// Mirrors LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES in src/api/routes.ts (1 MiB). +const MAX_BODY_BYTES = 1024 * 1024; function apiHeaders(env: Env): Record { return { @@ -116,3 +121,89 @@ describe("remediation-plan route", () => { }); }); }); + +describe("local branch routes byte-cap ingestion bound", () => { + for (const path of [PATH, BRANCH_ANALYSIS_PATH]) { + it(`rejects an oversized request body with 413 payload_too_large via Content-Length (${path})`, async () => { + const app = createApp(); + const env = createTestEnv(); + await seedRepo(env, "miner", "demo", 301); + const response = await app.request( + path, + { + method: "POST", + headers: { ...apiHeaders(env), "content-length": String(MAX_BODY_BYTES + 1) }, + body: JSON.stringify(branchPayload("oktofeesh1", "miner/demo")), + }, + env, + ); + expect(response.status).toBe(413); + await expect(response.json()).resolves.toMatchObject({ error: "payload_too_large", maxBytes: MAX_BODY_BYTES }); + }); + + it(`rejects an oversized streamed request body with 413 payload_too_large (${path})`, async () => { + const app = createApp(); + const env = createTestEnv(); + await seedRepo(env, "miner", "demo", 301); + // A body that exceeds the cap forces the streaming reader (readRequestBodyWithLimit) to bail + // out even when the Content-Length header is absent/under the limit. + const oversizedBody = JSON.stringify( + branchPayload("oktofeesh1", "miner/demo", { body: "x".repeat(MAX_BODY_BYTES + 16) }), + ); + const response = await app.request( + path, + { + method: "POST", + headers: apiHeaders(env), + body: oversizedBody, + }, + env, + ); + expect(response.status).toBe(413); + await expect(response.json()).resolves.toMatchObject({ error: "payload_too_large", maxBytes: MAX_BODY_BYTES }); + }); + + it(`rejects a focusManifest that serializes beyond the byte cap with 400 (${path})`, async () => { + const app = createApp(); + const env = createTestEnv(); + await seedRepo(env, "miner", "demo", 301); + // Stays under the 1 MiB request-body cap but blows past the focus-manifest serialized byte cap, + // so it must trip the focusManifestInputSchema refinement (not the body limit). + const oversizedManifest = { present: true, note: "a".repeat(MAX_FOCUS_MANIFEST_BYTES + 1) }; + const requestBody = JSON.stringify(branchPayload("oktofeesh1", "miner/demo", { focusManifest: oversizedManifest })); + expect(requestBody.length).toBeLessThanOrEqual(MAX_BODY_BYTES); + const response = await app.request( + path, + { + method: "POST", + headers: apiHeaders(env), + body: requestBody, + }, + env, + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_local_branch_analysis_request" }); + }); + + it(`accepts a normal-size body and within-cap focusManifest with 200 (${path})`, async () => { + const app = createApp(); + const env = createTestEnv(); + await seedRepo(env, "miner", "demo", 301); + const response = await app.request( + path, + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify( + branchPayload("oktofeesh1", "miner/demo", { + focusManifest: { present: true, wantedPaths: ["src/"], source: "caller" }, + }), + ), + }, + env, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ login: "oktofeesh1", repoFullName: "miner/demo" }); + }); + } +});