Fix webhook signature verification — raw body capture + Forgejo header support

- 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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-05 13:54:37 +00:00
parent 2bc57ee89d
commit 568cd8d299

View file

@ -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' })