From 6b2017612e1686cce1967dc2b2ac6ed65e1eca62 Mon Sep 17 00:00:00 2001 From: Platform Engineer Date: Mon, 31 Aug 2026 06:14:38 +0000 Subject: [PATCH] fix(routines): accept signed Sentry webhooks Co-Authored-By: Paperclip --- server/src/__tests__/routines-routes.test.ts | 27 +++++++++ server/src/__tests__/routines-service.test.ts | 57 +++++++++++++++++++ server/src/routes/routines.ts | 1 + server/src/services/routines.ts | 31 ++++++++-- 4 files changed, 111 insertions(+), 5 deletions(-) diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index 15263c9b1f..dec0c53f01 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -325,6 +325,33 @@ describe("routine routes", () => { expect(mockRoutineService.list).toHaveBeenCalledWith(companyId, { projectId }); }); + it("forwards the Sentry signature header to public webhook verification", async () => { + mockRoutineService.firePublicTrigger.mockResolvedValue({ + id: "run-1", + source: "webhook", + status: "issue_created", + }); + const app = await createApp({ type: "public" }); + const payload = { + action: "created", + data: { issue: { id: "7625432288", project: { slug: "api" } } }, + }; + + const res = await request(app) + .post("/api/routine-triggers/public/sentry-trigger/fire") + .set("Sentry-Hook-Signature", "signed-digest") + .send(payload); + + expect(res.status).toBe(202); + expect(mockRoutineService.firePublicTrigger).toHaveBeenCalledWith( + "sentry-trigger", + expect.objectContaining({ + sentrySignatureHeader: "signed-digest", + payload, + }), + ); + }); + it("lists routine revisions for a board member in newest-first service order", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index cbdf42ee70..8886c75c60 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -2363,6 +2363,63 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(run.status).toBe("issue_created"); }); + it("accepts Sentry signatures and deduplicates retries by immutable issue id", async () => { + const { routine, svc } = await seedFixture(); + const { trigger, secretMaterial } = await svc.createTrigger( + routine.id, + { + kind: "webhook", + signingMode: "github_hmac", + }, + {}, + ); + + const payload = { + action: "created", + data: { + issue: { + id: "7625432288", + shortId: "API-FX", + project: { slug: "api" }, + }, + }, + }; + const rawBody = Buffer.from(JSON.stringify(payload)); + const signature = createHmac("sha256", secretMaterial!.webhookSecret) + .update(rawBody) + .digest("hex"); + const request = { + sentrySignatureHeader: signature, + rawBody, + payload, + }; + + const retryPayload = { + ...payload, + action: "resolved", + actor: { type: "application", name: "Sentry" }, + }; + const retryRawBody = Buffer.from(JSON.stringify(retryPayload)); + const retryRequest = { + sentrySignatureHeader: createHmac("sha256", secretMaterial!.webhookSecret) + .update(retryRawBody) + .digest("hex"), + rawBody: retryRawBody, + payload: retryPayload, + }; + + const first = await svc.firePublicTrigger(trigger.publicId!, request); + const retry = await svc.firePublicTrigger(trigger.publicId!, retryRequest); + + expect(first).toMatchObject({ source: "webhook", status: "issue_created" }); + expect(retry.id).toBe(first.id); + expect(retry.linkedIssueId).toBe(first.linkedIssueId); + expect(await db.select().from(routineRuns).where(eq(routineRuns.triggerId, trigger.id))).toHaveLength(1); + expect( + await db.select().from(issues).where(eq(issues.originId, routine.id)), + ).toHaveLength(1); + }); + it("rejects invalid signature for github_hmac signing mode", async () => { const { routine, svc } = await seedFixture(); const { trigger } = await svc.createTrigger( diff --git a/server/src/routes/routines.ts b/server/src/routes/routines.ts index c8c94335dd..7c26c30ae3 100644 --- a/server/src/routes/routines.ts +++ b/server/src/routes/routines.ts @@ -658,6 +658,7 @@ export function routineRoutes( authorizationHeader: req.header("authorization"), signatureHeader: req.header("x-paperclip-signature"), hubSignatureHeader: req.header("x-hub-signature-256"), + sentrySignatureHeader: req.header("sentry-hook-signature"), timestampHeader: req.header("x-paperclip-timestamp"), idempotencyKey: req.header("idempotency-key"), rawBody: (req as { rawBody?: Buffer }).rawBody ?? null, diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index f09ac75821..30666297ee 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -311,6 +311,14 @@ function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function sentryIssueIdFromWebhookPayload(payload: Record | null | undefined) { + if (!payload) return null; + const data = isPlainRecord(payload.data) ? payload.data : null; + const issue = data && isPlainRecord(data.issue) ? data.issue : null; + const id = issue?.id; + return typeof id === "string" || typeof id === "number" ? String(id) : null; +} + function parseBooleanVariableValue(name: string, raw: unknown) { if (typeof raw === "boolean") return raw; if (typeof raw === "number" && (raw === 0 || raw === 1)) return raw === 1; @@ -2869,6 +2877,7 @@ export function routineService( authorizationHeader?: string | null; signatureHeader?: string | null; hubSignatureHeader?: string | null; + sentrySignatureHeader?: string | null; timestampHeader?: string | null; idempotencyKey?: string | null; rawBody?: Buffer | null; @@ -2890,10 +2899,12 @@ export function routineService( } 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() ?? ""; + // GitHub prefixes its digest in X-Hub-Signature-256. Sentry sends the + // unprefixed digest in Sentry-Hook-Signature. Keep the generic header + // fallback for existing integrations using this raw-body HMAC mode. + const providedSignature = ( + input.hubSignatureHeader ?? input.sentrySignatureHeader ?? input.signatureHeader + )?.trim() ?? ""; if (!providedSignature) throw unauthorized(); const expectedHmac = crypto .createHmac("sha256", secretValue) @@ -2906,6 +2917,15 @@ export function routineService( normalizedBuf.length === expectedBuf.length && crypto.timingSafeEqual(normalizedBuf, expectedBuf); if (!valid) throw unauthorized(); + if (input.sentrySignatureHeader) { + const sentryIssueId = sentryIssueIdFromWebhookPayload(input.payload); + if (sentryIssueId) { + hmacReplayKey = `webhook-sentry-issue:${crypto + .createHash("sha256") + .update(`${trigger.id}:${sentryIssueId}`) + .digest("hex")}`; + } + } } else if (trigger.signingMode === "bearer") { const secretValue = await resolveTriggerSecret(trigger, routine.companyId); const expected = `Bearer ${secretValue}`; @@ -2968,7 +2988,8 @@ export function routineService( ? input.payload.variables : null, idempotencyKey: hmacReplayKey ?? input.idempotencyKey, - rejectIdempotencyReplay: hmacReplayKey !== null, + rejectIdempotencyReplay: + hmacReplayKey !== null && !hmacReplayKey.startsWith("webhook-sentry-issue:"), }); },