From da1b417a9e46be6caf71e1cc0d62da15a080d436 Mon Sep 17 00:00:00 2001 From: Jonathanchang31 <55106972+jonathanchang31@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:42:17 +0200 Subject: [PATCH 1/2] fix: unauthenticated github webhook Dos --- src/api/routes.ts | 2 +- src/auth/rate-limit.ts | 1 + src/env.d.ts | 1 + src/github/webhook.ts | 39 +++++++++++++++++++- test/integration/api.test.ts | 70 ++++++++++++++++++++++++++++++++++++ test/unit/auth.test.ts | 1 + wrangler.jsonc | 1 + 7 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/api/routes.ts b/src/api/routes.ts index fbb12f7f32..66d67b1e15 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -342,7 +342,7 @@ export function createApp() { return next(); }); app.use("*", async (c, next) => { - if (c.req.method === "OPTIONS" || c.req.path === "/health" || c.req.path === "/v1/github/webhook") return next(); + if (c.req.method === "OPTIONS" || c.req.path === "/health") return next(); const limited = await enforceRateLimit(c, routeClassForPath(c.req.path)); if (limited) return limited; return next(); diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts index b7adb13da7..ae42fd1406 100644 --- a/src/auth/rate-limit.ts +++ b/src/auth/rate-limit.ts @@ -95,6 +95,7 @@ export async function enforceRateLimit(c: Context<{ Bindings: Env }>, routeClass } export function routeClassForPath(path: string): RateLimitClass { + if (path === "/v1/github/webhook") return "strict"; if (path === "/v1/auth/session" || path === "/v1/auth/logout") return "normal"; if (path.startsWith("/v1/auth/")) return "strict"; if ( diff --git a/src/env.d.ts b/src/env.d.ts index 94101f6bd3..080195854f 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -13,6 +13,7 @@ declare global { AI_MAX_OUTPUT_TOKENS?: string; ADMIN_GITHUB_LOGINS?: string; GITHUB_WEBHOOK_SECRET: string; + GITHUB_WEBHOOK_MAX_BODY_BYTES?: string; GITHUB_APP_PRIVATE_KEY: string; GITHUB_APP_ID: string; GITHUB_APP_SLUG: string; diff --git a/src/github/webhook.ts b/src/github/webhook.ts index e44a21e964..39dad893e1 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"; +const DEFAULT_MAX_WEBHOOK_BODY_BYTES = 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,16 @@ export async function handleGitHubWebhook(c: Context<{ Bindings: Env }>): Promis return c.json({ error: "missing_github_headers" }, 400); } - const rawBody = await c.req.text(); + const maxBodyBytes = parsePositiveInt(c.env.GITHUB_WEBHOOK_MAX_BODY_BYTES) ?? DEFAULT_MAX_WEBHOOK_BODY_BYTES; + const contentLength = parsePositiveInt(c.req.header("content-length")); + if (contentLength !== null && contentLength > maxBodyBytes) { + return c.json({ error: "payload_too_large", maxBytes: maxBodyBytes }, 413); + } + + const rawBody = await readBodyWithLimit(c.req.raw, maxBodyBytes); + if (rawBody === null) { + return c.json({ error: "payload_too_large", maxBytes: maxBodyBytes }, 413); + } const verified = await verifyGitHubSignature(rawBody, signature, c.env.GITHUB_WEBHOOK_SECRET); if (!verified) { return c.json({ error: "invalid_signature" }, 401); @@ -50,3 +61,29 @@ export async function handleGitHubWebhook(c: Context<{ Bindings: Env }>): Promis return c.json({ ok: true, deliveryId, eventName, status: "queued" }, 202); } + +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 readBodyWithLimit(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) return null; + chunks.push(decoder.decode(value, { stream: true })); + } + chunks.push(decoder.decode()); + return chunks.join(""); +} diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 967e77a86a..d37442ecc7 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -232,6 +232,76 @@ describe("api routes", () => { expect(rejected.status).toBe(401); }); + it("rejects oversized webhook payloads and rate limits repeated invalid webhook traffic", async () => { + const counters = new Map(); + const app = createApp(); + const env = createTestEnv({ + GITHUB_WEBHOOK_MAX_BODY_BYTES: "1024", + RATE_LIMITER: { + idFromName(name: string) { + return name as unknown as DurableObjectId; + }, + get(id: DurableObjectId) { + const key = String(id); + return { + async fetch() { + const count = (counters.get(key) ?? 0) + 1; + counters.set(key, count); + if (count <= 10) return Response.json({ allowed: true, limit: 10, remaining: 10 - count, resetAt: "2099-01-01T00:00:00.000Z" }); + return Response.json({ allowed: false, limit: 10, remaining: 0, retryAfterSeconds: 60, resetAt: "2099-01-01T00:00:00.000Z" }, { status: 429 }); + }, + } as DurableObjectStub; + }, + } as DurableObjectNamespace, + }); + const oversizedBody = JSON.stringify({ + action: "opened", + repository: { full_name: "JSONbored/gittensory" }, + blob: "x".repeat(3_000), + }); + const tooLarge = await app.request( + "/v1/github/webhook", + { + method: "POST", + body: oversizedBody, + headers: { + "x-github-delivery": "oversized-1", + "x-github-event": "push", + }, + }, + env, + ); + expect(tooLarge.status).toBe(413); + await expect(tooLarge.json()).resolves.toMatchObject({ error: "payload_too_large", maxBytes: 1024 }); + + const invalidBody = JSON.stringify({ action: "opened", repository: { full_name: "JSONbored/gittensory" } }); + let sawUnauthorized = false; + let sawRateLimited = false; + for (let index = 0; index < 12; index += 1) { + const response = await app.request( + "/v1/github/webhook", + { + method: "POST", + body: invalidBody, + headers: { + "x-github-delivery": `invalid-${index}`, + "x-github-event": "push", + "x-hub-signature-256": "sha256=bad", + }, + }, + env, + ); + if (response.status === 401) sawUnauthorized = true; + if (response.status === 429) { + sawRateLimited = true; + await expect(response.json()).resolves.toMatchObject({ error: "rate_limited", routeClass: "strict" }); + break; + } + } + expect(sawUnauthorized).toBe(true); + expect(sawRateLimited).toBe(true); + }); + it("serves deterministic signal endpoints from cached registry and GitHub metadata", async () => { const app = createApp(); const env = createTestEnv(); diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index 6179dcb501..e419058eb1 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -79,6 +79,7 @@ describe("private-beta auth and rate limiting", () => { }); it("classifies rate-limit route costs", () => { + expect(routeClassForPath("/v1/github/webhook")).toBe("strict"); expect(routeClassForPath("/v1/auth/github/device/start")).toBe("strict"); expect(routeClassForPath("/v1/local/branch-analysis")).toBe("expensive"); expect(routeClassForPath("/v1/scoring/preview")).toBe("expensive"); diff --git a/wrangler.jsonc b/wrangler.jsonc index 5f452023e9..6b2eb26d6d 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -24,6 +24,7 @@ "GITHUB_APP_ID": "3824093", "GITHUB_APP_SLUG": "gittensory", "GITHUB_OAUTH_CLIENT_ID": "Iv23li574mpdLo2PnVN4", + "GITHUB_WEBHOOK_MAX_BODY_BYTES": "1048576", "GITTENSOR_UPSTREAM_REPO": "entrius/gittensor", "GITTENSOR_UPSTREAM_REF": "test", "GITTENSOR_REGISTRY_URL": "https://raw.githubusercontent.com/entrius/gittensor/test/gittensor/validator/weights/master_repositories.json", From 223d3a67c84e92f8a87c65fdb0dd7e2db475a164 Mon Sep 17 00:00:00 2001 From: Jonathanchang31 <55106972+jonathanchang31@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:17:42 +0200 Subject: [PATCH 2/2] fix: ci test failed --- test/integration/api.test.ts | 82 +++++++++++++++++++++++++++++++++++- test/unit/webhook.test.ts | 38 +++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 test/unit/webhook.test.ts diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index d37442ecc7..9439626883 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -250,7 +250,7 @@ describe("api routes", () => { if (count <= 10) return Response.json({ allowed: true, limit: 10, remaining: 10 - count, resetAt: "2099-01-01T00:00:00.000Z" }); return Response.json({ allowed: false, limit: 10, remaining: 0, retryAfterSeconds: 60, resetAt: "2099-01-01T00:00:00.000Z" }, { status: 429 }); }, - } as DurableObjectStub; + } as unknown as DurableObjectStub; }, } as DurableObjectNamespace, }); @@ -302,6 +302,86 @@ describe("api routes", () => { expect(sawRateLimited).toBe(true); }); + it("rejects oversized webhook requests from content-length and signed invalid JSON payloads", async () => { + const app = createApp(); + const env = createTestEnv({ GITHUB_WEBHOOK_MAX_BODY_BYTES: "1024" }); + + const contentLengthRejected = await app.request( + "/v1/github/webhook", + { + method: "POST", + body: "{}", + headers: { + "x-github-delivery": "oversized-content-length", + "x-github-event": "push", + "content-length": "2048", + }, + }, + env, + ); + expect(contentLengthRejected.status).toBe(413); + await expect(contentLengthRejected.json()).resolves.toMatchObject({ error: "payload_too_large", maxBytes: 1024 }); + + const malformedBody = "{"; + const malformedSignature = await signWebhook(malformedBody, env.GITHUB_WEBHOOK_SECRET); + const malformedJson = await app.request( + "/v1/github/webhook", + { + method: "POST", + body: malformedBody, + headers: { + "x-github-delivery": "invalid-json-signed", + "x-github-event": "push", + "x-hub-signature-256": malformedSignature, + }, + }, + env, + ); + expect(malformedJson.status).toBe(400); + await expect(malformedJson.json()).resolves.toMatchObject({ error: "invalid_json" }); + }); + + it("handles webhook size parsing fallbacks for invalid env/header values and empty request bodies", async () => { + const app = createApp(); + const env = createTestEnv({ + GITHUB_WEBHOOK_MAX_BODY_BYTES: "0", + }); + + const emptyBody = await app.request( + "/v1/github/webhook", + { + method: "POST", + headers: { + "x-github-delivery": "empty-body", + "x-github-event": "push", + "x-hub-signature-256": "sha256=bad", + }, + }, + env, + ); + expect(emptyBody.status).toBe(401); + await expect(emptyBody.json()).resolves.toMatchObject({ error: "invalid_signature" }); + + const validBody = JSON.stringify({ action: "opened", repository: { full_name: "JSONbored/gittensory", name: "gittensory" } }); + const validSignature = await signWebhook(validBody, env.GITHUB_WEBHOOK_SECRET); + const invalidLengthHeader = await app.request( + "/v1/github/webhook", + { + method: "POST", + body: validBody, + headers: { + "x-github-delivery": "invalid-content-length", + "x-github-event": "pull_request", + "x-hub-signature-256": validSignature, + "content-length": "not-a-number", + }, + }, + env, + ); + expect(invalidLengthHeader.status).toBe(202); + await expect(invalidLengthHeader.json()).resolves.toMatchObject({ status: "queued", deliveryId: "invalid-content-length" }); + }); + it("serves deterministic signal endpoints from cached registry and GitHub metadata", async () => { const app = createApp(); const env = createTestEnv(); diff --git a/test/unit/webhook.test.ts b/test/unit/webhook.test.ts new file mode 100644 index 0000000000..ca6632dd3e --- /dev/null +++ b/test/unit/webhook.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import type { Context } from "hono"; +import { handleGitHubWebhook } from "../../src/github/webhook"; +import { createTestEnv } from "../helpers/d1"; + +describe("github webhook body reader edge cases", () => { + it("skips undefined stream chunks and still rejects invalid signatures", async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(undefined as unknown as Uint8Array); + controller.close(); + }, + }); + const request = { body } as unknown as Request; + const env = createTestEnv(); + const headers: Record = { + "x-github-delivery": "stream-edge-case", + "x-github-event": "push", + "x-hub-signature-256": "sha256=bad", + }; + const context = { + req: { + raw: request, + header(name: string) { + return headers[name.toLowerCase()] ?? null; + }, + }, + env, + json(payload: unknown, status?: number) { + return Response.json(payload, status === undefined ? undefined : { status }); + }, + } as unknown as Context<{ Bindings: Env }>; + + const response = await handleGitHubWebhook(context); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_signature" }); + }); +});