From 6b2017612e1686cce1967dc2b2ac6ed65e1eca62 Mon Sep 17 00:00:00 2001 From: Platform Engineer Date: Mon, 31 Aug 2026 06:14:38 +0000 Subject: [PATCH 1/2] 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:"), }); }, From 0ef779610cb263ca5142b79098e4dda007b462a1 Mon Sep 17 00:00:00 2001 From: Platform Engineer Date: Fri, 11 Sep 2026 11:30:27 +0000 Subject: [PATCH 2/2] fix(routines): preserve Sentry lifecycle deliveries Co-Authored-By: Paperclip --- server/src/__tests__/routines-service.test.ts | 64 ++++++++++++++++--- server/src/services/routines.ts | 16 ++++- 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 8886c75c60..aa5d16dd33 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -2363,7 +2363,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(run.status).toBe("issue_created"); }); - it("accepts Sentry signatures and deduplicates retries by immutable issue id", async () => { + it("deduplicates identical Sentry event retries but delivers distinct issue actions", async () => { const { routine, svc } = await seedFixture(); const { trigger, secretMaterial } = await svc.createTrigger( routine.id, @@ -2394,30 +2394,74 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { payload, }; - const retryPayload = { + const resolvedPayload = { ...payload, action: "resolved", actor: { type: "application", name: "Sentry" }, }; - const retryRawBody = Buffer.from(JSON.stringify(retryPayload)); - const retryRequest = { + const resolvedRawBody = Buffer.from(JSON.stringify(resolvedPayload)); + const resolvedRequest = { sentrySignatureHeader: createHmac("sha256", secretMaterial!.webhookSecret) - .update(retryRawBody) + .update(resolvedRawBody) .digest("hex"), - rawBody: retryRawBody, - payload: retryPayload, + rawBody: resolvedRawBody, + payload: resolvedPayload, }; const first = await svc.firePublicTrigger(trigger.publicId!, request); - const retry = await svc.firePublicTrigger(trigger.publicId!, retryRequest); + const retry = await svc.firePublicTrigger(trigger.publicId!, request); + const resolved = await svc.firePublicTrigger(trigger.publicId!, resolvedRequest); 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(resolved.id).not.toBe(first.id); + expect(resolved.linkedIssueId).not.toBe(first.linkedIssueId); + expect(await db.select().from(routineRuns).where(eq(routineRuns.triggerId, trigger.id))).toHaveLength(2); expect( await db.select().from(issues).where(eq(issues.originId, routine.id)), - ).toHaveLength(1); + ).toHaveLength(2); + }); + + it("prefers a provider delivery id when deduplicating Sentry webhook retries", 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", project: { slug: "api" } } }, + }; + const requestFor = (idempotencyKey: string, body: Record) => { + const rawBody = Buffer.from(JSON.stringify(body)); + return { + idempotencyKey, + sentrySignatureHeader: createHmac("sha256", secretMaterial!.webhookSecret) + .update(rawBody) + .digest("hex"), + rawBody, + payload: body, + }; + }; + + const first = await svc.firePublicTrigger( + trigger.publicId!, + requestFor("sentry-delivery-1", payload), + ); + const retry = await svc.firePublicTrigger( + trigger.publicId!, + requestFor("sentry-delivery-1", { ...payload, actor: { type: "application" } }), + ); + const separateDelivery = await svc.firePublicTrigger( + trigger.publicId!, + requestFor("sentry-delivery-2", payload), + ); + + expect(retry.id).toBe(first.id); + expect(separateDelivery.id).not.toBe(first.id); + expect(await db.select().from(routineRuns).where(eq(routineRuns.triggerId, trigger.id))).toHaveLength(2); }); it("rejects invalid signature for github_hmac signing mode", async () => { diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 30666297ee..e2006b39dc 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -319,6 +319,11 @@ function sentryIssueIdFromWebhookPayload(payload: Record | null return typeof id === "string" || typeof id === "number" ? String(id) : null; } +function sentryActionFromWebhookPayload(payload: Record | null | undefined) { + const action = payload?.action; + return typeof action === "string" && action.trim().length > 0 ? action.trim() : null; +} + function parseBooleanVariableValue(name: string, raw: unknown) { if (typeof raw === "boolean") return raw; if (typeof raw === "number" && (raw === 0 || raw === 1)) return raw === 1; @@ -2920,9 +2925,14 @@ export function routineService( if (input.sentrySignatureHeader) { const sentryIssueId = sentryIssueIdFromWebhookPayload(input.payload); if (sentryIssueId) { - hmacReplayKey = `webhook-sentry-issue:${crypto + const providerDeliveryId = input.idempotencyKey?.trim(); + const action = sentryActionFromWebhookPayload(input.payload); + const deliveryIdentity = providerDeliveryId + ? `delivery:${providerDeliveryId}` + : `issue:${sentryIssueId}:action:${action ?? "unknown"}`; + hmacReplayKey = `webhook-sentry-event:${crypto .createHash("sha256") - .update(`${trigger.id}:${sentryIssueId}`) + .update(`${trigger.id}:${deliveryIdentity}`) .digest("hex")}`; } } @@ -2989,7 +2999,7 @@ export function routineService( : null, idempotencyKey: hmacReplayKey ?? input.idempotencyKey, rejectIdempotencyReplay: - hmacReplayKey !== null && !hmacReplayKey.startsWith("webhook-sentry-issue:"), + hmacReplayKey !== null && !hmacReplayKey.startsWith("webhook-sentry-event:"), }); },