diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 8a6679b774..77a418458a 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -9219,6 +9219,9 @@ }, "401": { "description": "Invalid webhook signature" + }, + "413": { + "description": "Webhook payload too large" } } } diff --git a/src/github/webhook.ts b/src/github/webhook.ts index e44a21e964..856a585726 100644 --- a/src/github/webhook.ts +++ b/src/github/webhook.ts @@ -3,6 +3,8 @@ import { getWebhookEvent, recordWebhookEvent } from "../db/repositories"; import type { GitHubWebhookPayload, JobMessage } from "../types"; import { sha256Hex, verifyGitHubSignature } from "../utils/crypto"; +export const MAX_GITHUB_WEBHOOK_BODY_BYTES = 10 * 1024 * 1024; + export async function handleGitHubWebhook(c: Context<{ Bindings: Env }>): Promise { const deliveryId = c.req.header("x-github-delivery") ?? null; const eventName = c.req.header("x-github-event") ?? null; @@ -11,7 +13,20 @@ export async function handleGitHubWebhook(c: Context<{ Bindings: Env }>): Promis return c.json({ error: "missing_github_headers" }, 400); } - const rawBody = await c.req.text(); + const contentLength = parseContentLength(c.req.header("content-length") ?? null); + if (contentLength === "invalid") { + return c.json({ error: "invalid_content_length" }, 400); + } + if (contentLength !== null && contentLength > MAX_GITHUB_WEBHOOK_BODY_BYTES) { + return c.json({ error: "webhook_body_too_large" }, 413); + } + + const bodyRead = await readRequestTextWithinLimit(c.req.raw, MAX_GITHUB_WEBHOOK_BODY_BYTES); + if (!bodyRead.ok) { + return c.json({ error: "webhook_body_too_large" }, 413); + } + + const rawBody = bodyRead.text; const verified = await verifyGitHubSignature(rawBody, signature, c.env.GITHUB_WEBHOOK_SECRET); if (!verified) { return c.json({ error: "invalid_signature" }, 401); @@ -50,3 +65,43 @@ export async function handleGitHubWebhook(c: Context<{ Bindings: Env }>): Promis return c.json({ ok: true, deliveryId, eventName, status: "queued" }, 202); } + +export async function readRequestTextWithinLimit( + request: Request, + maxBytes: number = MAX_GITHUB_WEBHOOK_BODY_BYTES, +): Promise<{ ok: true; text: string } | { ok: false }> { + if (!request.body) return { ok: true, text: "" }; + + const reader = request.body.getReader(); + const decoder = new TextDecoder(); + let bytesRead = 0; + let text = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + bytesRead += value.byteLength; + if (bytesRead > maxBytes) { + await reader.cancel(); + return { ok: false }; + } + + text += decoder.decode(value, { stream: true }); + } + + text += decoder.decode(); + return { ok: true, text }; + } finally { + reader.releaseLock(); + } +} + +function parseContentLength(value: string | null): number | "invalid" | null { + if (value === null) return null; + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) return "invalid"; + const parsed = Number.parseInt(trimmed, 10); + return Number.isSafeInteger(parsed) ? parsed : "invalid"; +} diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 0c80a19118..720744e9e4 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -465,6 +465,7 @@ export function buildOpenApiSpec() { path: "/v1/github/webhook", responses: { 202: { description: "Webhook queued" }, + 413: { description: "Webhook payload too large" }, 401: { description: "Invalid webhook signature" }, }, }); diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 967e77a86a..5e2230039e 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -25,6 +25,7 @@ import { upsertRepositorySettings, } from "../../src/db/repositories"; import { createApp } from "../../src/api/routes"; +import { MAX_GITHUB_WEBHOOK_BODY_BYTES } from "../../src/github/webhook"; import { BURDEN_FORECAST_MAX_AGE_MS } from "../../src/services/burden-forecast"; import { normalizeRegistryPayload } from "../../src/registry/normalize"; import { persistRegistrySnapshot } from "../../src/registry/sync"; @@ -232,6 +233,124 @@ describe("api routes", () => { expect(rejected.status).toBe(401); }); + it("rejects signed GitHub webhooks with invalid JSON", async () => { + const app = createApp(); + const env = createTestEnv(); + const body = "{"; + const signature = await signWebhook(body, env.GITHUB_WEBHOOK_SECRET); + + const rejected = await app.request( + "/v1/github/webhook", + { + method: "POST", + body, + headers: { + "x-github-delivery": "delivery-invalid-json", + "x-github-event": "pull_request", + "x-hub-signature-256": signature, + }, + }, + env, + ); + + expect(rejected.status).toBe(400); + await expect(rejected.json()).resolves.toMatchObject({ error: "invalid_json" }); + }); + + it("rejects oversized GitHub webhook bodies before signature verification", async () => { + const app = createApp(); + const env = createTestEnv(); + const body = JSON.stringify({ action: "opened" }); + const oversizedContentLength = String(MAX_GITHUB_WEBHOOK_BODY_BYTES + 1); + + const rejected = await app.request( + "/v1/github/webhook", + { + method: "POST", + body, + headers: { + "content-length": oversizedContentLength, + "x-github-delivery": "delivery-large", + "x-github-event": "pull_request", + "x-hub-signature-256": "sha256=bad", + }, + }, + env, + ); + + expect(rejected.status).toBe(413); + await expect(rejected.json()).resolves.toMatchObject({ error: "webhook_body_too_large" }); + }); + + it("rejects malformed GitHub webhook content length headers", async () => { + const app = createApp(); + const env = createTestEnv(); + + const rejected = await app.request( + "/v1/github/webhook", + { + method: "POST", + body: JSON.stringify({ action: "opened" }), + headers: { + "content-length": "not-a-number", + "x-github-delivery": "delivery-bad-length", + "x-github-event": "pull_request", + "x-hub-signature-256": "sha256=bad", + }, + }, + env, + ); + + expect(rejected.status).toBe(400); + await expect(rejected.json()).resolves.toMatchObject({ error: "invalid_content_length" }); + }); + + it("rejects unsafe GitHub webhook content length values", async () => { + const app = createApp(); + const env = createTestEnv(); + + const rejected = await app.request( + "/v1/github/webhook", + { + method: "POST", + body: JSON.stringify({ action: "opened" }), + headers: { + "content-length": String(Number.MAX_SAFE_INTEGER + 1), + "x-github-delivery": "delivery-unsafe-length", + "x-github-event": "pull_request", + "x-hub-signature-256": "sha256=bad", + }, + }, + env, + ); + + expect(rejected.status).toBe(400); + await expect(rejected.json()).resolves.toMatchObject({ error: "invalid_content_length" }); + }); + + it("rejects streamed oversized GitHub webhook bodies without content length", async () => { + const app = createApp(); + const env = createTestEnv(); + + const rejected = await app.request( + "/v1/github/webhook", + { + method: "POST", + body: oversizedWebhookBodyStream(), + duplex: "half", + headers: { + "x-github-delivery": "delivery-stream-large", + "x-github-event": "pull_request", + "x-hub-signature-256": "sha256=bad", + }, + } as RequestInit, + env, + ); + + expect(rejected.status).toBe(413); + await expect(rejected.json()).resolves.toMatchObject({ error: "webhook_body_too_large" }); + }); + it("serves deterministic signal endpoints from cached registry and GitHub metadata", async () => { const app = createApp(); const env = createTestEnv(); @@ -2876,6 +2995,26 @@ async function signWebhook(body: string, secret: string): Promise { return `sha256=${[...new Uint8Array(signed)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`; } +function oversizedWebhookBodyStream(): ReadableStream { + const fullChunk = new Uint8Array(1024 * 1024); + const targetBytes = MAX_GITHUB_WEBHOOK_BODY_BYTES + 1; + let sentBytes = 0; + + return new ReadableStream({ + pull(controller) { + const remaining = targetBytes - sentBytes; + if (remaining <= 0) { + controller.close(); + return; + } + + const nextSize = Math.min(fullChunk.byteLength, remaining); + controller.enqueue(nextSize === fullChunk.byteLength ? fullChunk : new Uint8Array(nextSize)); + sentBytes += nextSize; + }, + }); +} + function mcpHeaders(env: Env, sessionId?: string): Record { return { authorization: `Bearer ${env.GITTENSORY_MCP_TOKEN}`, diff --git a/test/unit/webhook.test.ts b/test/unit/webhook.test.ts new file mode 100644 index 0000000000..128715768d --- /dev/null +++ b/test/unit/webhook.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { readRequestTextWithinLimit } from "../../src/github/webhook"; + +describe("GitHub webhook body limits", () => { + it("treats requests without a body as an empty string", async () => { + const request = new Request("https://example.test/webhook", { method: "POST" }); + + await expect(readRequestTextWithinLimit(request, 3)).resolves.toEqual({ ok: true, text: "" }); + }); + + it("stops reading streamed bodies once the byte limit is exceeded", async () => { + const request = new Request("https://example.test/webhook", { + method: "POST", + body: new Blob(["ab", "cd"]).stream(), + duplex: "half", + } as RequestInit); + + await expect(readRequestTextWithinLimit(request, 3)).resolves.toEqual({ ok: false }); + }); +});