fix(routines): accept signed Sentry webhooks
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
dda4dff645
commit
6b2017612e
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -311,6 +311,14 @@ 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 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:"),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue