From a8d1c4b59644b3ea6b5d20caa466e033c397e920 Mon Sep 17 00:00:00 2001 From: Antonio Date: Mon, 6 Apr 2026 16:26:24 -0300 Subject: [PATCH] fix(server): use Buffer.length for timing-safe HMAC comparison and document header fallback Compare Buffer byte lengths instead of string character lengths before timingSafeEqual to avoid potential mismatch with multi-byte input. Add comment explaining the hubSignatureHeader ?? signatureHeader fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/src/services/routines.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 720b064d..86cc69cb 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -1272,6 +1272,9 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup } else if (trigger.signingMode === "github_hmac") { const secretValue = await resolveTriggerSecret(trigger, routine.companyId); const rawBody = input.rawBody ?? Buffer.from(JSON.stringify(input.payload ?? {})); + // Accept X-Hub-Signature-256 (GitHub/Sentry) or fall back to the + // generic X-Paperclip-Signature header so operators can use github_hmac + // mode with either header convention. const providedSignature = (input.hubSignatureHeader ?? input.signatureHeader)?.trim() ?? ""; if (!providedSignature) throw unauthorized(); const expectedHmac = crypto @@ -1279,9 +1282,11 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup .update(rawBody) .digest("hex"); const normalizedSignature = providedSignature.replace(/^sha256=/, ""); + const normalizedBuf = Buffer.from(normalizedSignature); + const expectedBuf = Buffer.from(expectedHmac); const valid = - normalizedSignature.length === expectedHmac.length && - crypto.timingSafeEqual(Buffer.from(normalizedSignature), Buffer.from(expectedHmac)); + normalizedBuf.length === expectedBuf.length && + crypto.timingSafeEqual(normalizedBuf, expectedBuf); if (!valid) throw unauthorized(); } else if (trigger.signingMode === "bearer") { const secretValue = await resolveTriggerSecret(trigger, routine.companyId);