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 e02a24932e..97dd66c63c 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -2362,6 +2362,107 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(run.status).toBe("issue_created"); }); + 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, + { + 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 resolvedPayload = { + ...payload, + action: "resolved", + actor: { type: "application", name: "Sentry" }, + }; + const resolvedRawBody = Buffer.from(JSON.stringify(resolvedPayload)); + const resolvedRequest = { + sentrySignatureHeader: createHmac("sha256", secretMaterial!.webhookSecret) + .update(resolvedRawBody) + .digest("hex"), + rawBody: resolvedRawBody, + payload: resolvedPayload, + }; + + const first = await svc.firePublicTrigger(trigger.publicId!, request); + 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(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(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 () => { 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 9414d5ea45..8b91ce032f 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -318,6 +318,19 @@ 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 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; @@ -2876,6 +2889,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; @@ -2897,10 +2911,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) @@ -2913,6 +2929,20 @@ 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) { + 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}:${deliveryIdentity}`) + .digest("hex")}`; + } + } } else if (trigger.signingMode === "bearer") { const secretValue = await resolveTriggerSecret(trigger, routine.companyId); const expected = `Bearer ${secretValue}`; @@ -2975,7 +3005,8 @@ export function routineService( ? input.payload.variables : null, idempotencyKey: hmacReplayKey ?? input.idempotencyKey, - rejectIdempotencyReplay: hmacReplayKey !== null, + rejectIdempotencyReplay: + hmacReplayKey !== null && !hmacReplayKey.startsWith("webhook-sentry-event:"), }); },