From 188bebc8ba9c8c8940972c437eec0e5f21cd5b22 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 26 Jul 2026 21:56:45 +0800 Subject: [PATCH] fix(github): add the Content-Length DoS pre-check to handleOrbRelay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleGitHubWebhook rejects an oversized request via a content-length header check (413 fast-path) before touching the body (#197). handleOrbRelay, processing the identical GitHub payload shape, went straight to readBodyWithLimit — enforcing the cap only after buffering up to it. Add the same content-length pre-check so an oversized relay request is rejected at the header stage, matching handleGitHubWebhook's DoS-hardening pattern exactly. Closes #8888 Co-Authored-By: Claude Opus 4.8 --- src/github/webhook.ts | 6 ++++++ test/unit/webhook.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/github/webhook.ts b/src/github/webhook.ts index ca70301b72..c7fda17a78 100644 --- a/src/github/webhook.ts +++ b/src/github/webhook.ts @@ -237,6 +237,12 @@ export async function handleOrbRelay(c: Context<{ Bindings: Env }>): Promise 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); if (!(await relayVerify(secret, rawBody, c.req.header("x-orb-signature-256") ?? null))) { diff --git a/test/unit/webhook.test.ts b/test/unit/webhook.test.ts index 20e7910be4..9bb38745be 100644 --- a/test/unit/webhook.test.ts +++ b/test/unit/webhook.test.ts @@ -691,6 +691,38 @@ describe("handleOrbRelay (brokered self-host relay receiver)", () => { await expect(resp.json()).resolves.toMatchObject({ ok: true, status: "queued", deliveryId: "relay-ok-1" }); expect(sent).toBe(1); // routed to the WEBHOOKS queue }); + + it("returns 413 from the Content-Length pre-check before the body is read/verified (#8888)", async () => { + const env = createTestEnv({ ORB_ENROLLMENT_SECRET: "orbenr_testsecret", GITHUB_WEBHOOK_MAX_BODY_BYTES: "1024" }); + // content-length exceeds the cap; a deliberately-bad signature would produce 401 IF the body were read and + // verified. Getting 413 (not 401) proves the header pre-check short-circuited before any body handling. + const ctx = makeRelayContext(env, '{"action":"opened"}', { + "x-github-delivery": "relay-big-1", + "x-github-event": "pull_request", + "x-orb-signature-256": "sha256=badbadbad", + "content-length": "2048", + }); + const resp = await handleOrbRelay(ctx); + expect(resp.status).toBe(413); + await expect(resp.json()).resolves.toMatchObject({ error: "payload_too_large", maxBytes: 1024 }); + }); + + it("still accepts a request whose Content-Length is within the cap (#8888)", async () => { + const env = createTestEnv({ ORB_ENROLLMENT_SECRET: "orbenr_testsecret" }); + let sent = 0; + env.WEBHOOKS = { send: async () => void (sent += 1) } as unknown as Queue; + const body = JSON.stringify({ action: "opened", repository: { full_name: "acme/widgets" }, installation: { id: 99 } }); + const sig = `sha256=${await relaySignature("orbenr_testsecret", body)}`; + const ctx = makeRelayContext(env, body, { + "x-github-delivery": "relay-ok-cl", + "x-github-event": "pull_request", + "x-orb-signature-256": sig, + "content-length": String(body.length), + }); + const resp = await handleOrbRelay(ctx); + expect(resp.status).toBe(202); // within-cap content-length must not trip the pre-check + expect(sent).toBe(1); + }); }); async function signWebhook(body: string, secret: string | undefined): Promise {