Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9219,6 +9219,9 @@
},
"401": {
"description": "Invalid webhook signature"
},
"413": {
"description": "Webhook payload too large"
}
}
}
Expand Down
57 changes: 56 additions & 1 deletion src/github/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
const deliveryId = c.req.header("x-github-delivery") ?? null;
const eventName = c.req.header("x-github-event") ?? null;
Expand All @@ -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);
Expand Down Expand Up @@ -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";
}
1 change: 1 addition & 0 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
});
Expand Down
139 changes: 139 additions & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -2876,6 +2995,26 @@ async function signWebhook(body: string, secret: string): Promise<string> {
return `sha256=${[...new Uint8Array(signed)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
}

function oversizedWebhookBodyStream(): ReadableStream<Uint8Array> {
const fullChunk = new Uint8Array(1024 * 1024);
const targetBytes = MAX_GITHUB_WEBHOOK_BODY_BYTES + 1;
let sentBytes = 0;

return new ReadableStream<Uint8Array>({
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<string, string> {
return {
authorization: `Bearer ${env.GITTENSORY_MCP_TOKEN}`,
Expand Down
20 changes: 20 additions & 0 deletions test/unit/webhook.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});