This commit is contained in:
Jonathan Reyes 2026-09-13 12:08:45 +00:00 committed by GitHub
commit d0980b38de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 165 additions and 5 deletions

View File

@ -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",

View File

@ -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<string, unknown>) => {
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(

View File

@ -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,

View File

@ -318,6 +318,19 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function sentryIssueIdFromWebhookPayload(payload: Record<string, unknown> | 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<string, unknown> | 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:"),
});
},