From 568cd8d299ea4fb4ec9521e2623d0eaee2e00dbe Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sun, 5 Jul 2026 13:54:37 +0000 Subject: [PATCH] =?UTF-8?q?Fix=20webhook=20signature=20verification=20?= =?UTF-8?q?=E2=80=94=20raw=20body=20capture=20+=20Forgejo=20header=20suppo?= =?UTF-8?q?rt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fastify-raw-body was never registered, so HMAC ran over re-serialised JSON and every Forgejo delivery failed with 401 - accept X-Forgejo-Signature / X-Gitea-Signature (bare hex) as well as the GitHub-style sha256= prefix, and reject length mismatches instead of crashing timingSafeEqual with a 500 Co-Authored-By: Claude Fable 5 --- updater/src/index.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/updater/src/index.js b/updater/src/index.js index cbe3d8c..139ad37 100644 --- a/updater/src/index.js +++ b/updater/src/index.js @@ -6,6 +6,13 @@ import { promisify } from 'util' const execAsync = promisify(exec) const app = Fastify({ logger: true }) + +// Capture the exact request bytes for webhook HMAC verification — +// re-serialising the parsed body does not round-trip Forgejo's payload. +app.addContentTypeParser('application/json', { parseAs: 'string' }, (req, body, done) => { + req.rawBody = body + try { done(null, JSON.parse(body)) } catch (err) { done(err) } +}) const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '' const SSH_KEY = process.env.SSH_KEY_PATH || '/root/.ssh/id_ed25519' const FORGEJO_URL = process.env.FORGEJO_URL || 'https://git.pterois.co.uk' @@ -75,8 +82,11 @@ const CACHE_TTL = 5 * 60 * 1000 function verifySignature(body, signature) { if (!WEBHOOK_SECRET) return true - const expected = `sha256=${crypto.createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex')}` - return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature || '')) + // Forgejo/Gitea send a bare hex digest; GitHub prefixes it with "sha256=" + const provided = (signature || '').replace(/^sha256=/, '') + const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex') + if (provided.length !== expected.length) return false + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided)) } async function sshGet(host, cmd) { @@ -203,6 +213,8 @@ app.post('/deploy/:repo', async (request, reply) => { app.post('/webhook', { config: { rawBody: true } }, async (request, reply) => { const sig = request.headers['x-hub-signature-256'] + || request.headers['x-forgejo-signature'] + || request.headers['x-gitea-signature'] const rawBody = request.rawBody || JSON.stringify(request.body) if (!verifySignature(rawBody, sig)) return reply.status(401).send({ error: 'Invalid signature' })