From 0747356639a84d534cc8afad11b8a476ae2bb567 Mon Sep 17 00:00:00 2001 From: Zachary Blasczyk Date: Fri, 20 Mar 2026 10:08:42 -0500 Subject: [PATCH] fix: use raw request body for webhook signature verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both GitHub and TFE webhook handlers computed HMACs over JSON.stringify(req.body) — the re-serialized parsed body rather than the original bytes. If the upstream ever changes its JSON serialization (whitespace, key order, number formatting), signature verification would silently break. Switch webhook routes to express.raw() so the handler receives the original Buffer. The global express.json() middleware now skips webhook paths to avoid consuming the stream first. --- apps/api/src/routes/github/index.ts | 31 +++++++++++++++++------------ apps/api/src/server.ts | 11 +++++++++- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/apps/api/src/routes/github/index.ts b/apps/api/src/routes/github/index.ts index 2dda59f6a6..7e28c530b0 100644 --- a/apps/api/src/routes/github/index.ts +++ b/apps/api/src/routes/github/index.ts @@ -1,16 +1,19 @@ import type { WorkflowRunEvent } from "@octokit/webhooks-types"; import type { Request, Response } from "express"; import { env } from "@/config.js"; -import { asyncHandler } from "@/types/api.js"; import { Webhooks } from "@octokit/webhooks"; -import { Router } from "express"; +import express, { Router } from "express"; import { logger } from "@ctrlplane/logger"; import { handleWorkflowRunEvent } from "./workflow_run.js"; export const createGithubRouter = (): Router => - Router().post("/webhook", asyncHandler(handleWebhookRequest)); + Router().post( + "/webhook", + express.raw({ type: "application/json" }), + handleWebhookRequest, + ); const getGithubWebhooksObject = (): Webhooks => { const secret = env.GITHUB_WEBHOOK_SECRET; @@ -18,27 +21,29 @@ const getGithubWebhooksObject = (): Webhooks => { return new Webhooks({ secret }); }; -const verifyRequest = async (req: Request): Promise => { +const verifyRequest = async ( + body: Buffer, + signature: string, +): Promise => { const webhooks = getGithubWebhooksObject(); - const signature = req.headers["x-hub-signature-256"]?.toString(); - if (signature == null) return false; - - const { body } = req; - const json = JSON.stringify(body); - return webhooks.verify(json, signature); + return webhooks.verify(body.toString("utf8"), signature); }; const handleWebhookRequest = async (req: Request, res: Response) => { try { - const isVerified = await verifyRequest(req); - if (!isVerified) { + const signature = req.headers["x-hub-signature-256"]?.toString(); + if ( + signature == null || + !(await verifyRequest(req.body as Buffer, signature)) + ) { res.status(401).json({ message: "Unauthorized" }); return; } + const payload = JSON.parse((req.body as Buffer).toString("utf8")); const eventType = req.headers["x-github-event"]?.toString(); if (eventType === "workflow_run") - await handleWorkflowRunEvent(req.body as WorkflowRunEvent); + await handleWorkflowRunEvent(payload as WorkflowRunEvent); res.status(200).json({ message: "OK" }); } catch (error: unknown) { diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index a247086435..43c124777d 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -62,7 +62,16 @@ const app = express() .use(cors({ credentials: true })) .use(helmet()) .use(express.urlencoded({ extended: true, limit: "100mb" })) - .use(express.json({ limit: "100mb" })) + .use((req, res, next) => { + if ( + req.path.startsWith("/api/github/webhook") || + req.path.startsWith("/api/tfe/webhook") + ) { + next(); + return; + } + express.json({ limit: "100mb" })(req, res, next); + }) .use(cookieParser()) .use(loggerMiddleware)