Merge pull request #1961 from antonio-mello-ai/fix/webhook-github-sentry-signing-modes

feat(server): add github_hmac and none webhook signing modes
This commit is contained in:
Dotta 2026-04-07 22:58:14 -05:00 committed by GitHub
commit 9cfa37fce3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 114 additions and 15 deletions

View file

@ -617,4 +617,72 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(run.status).toBe("issue_created");
expect(run.linkedIssueId).toBeTruthy();
});
it("accepts GitHub-style X-Hub-Signature-256 with github_hmac signing mode", async () => {
const { routine, svc } = await seedFixture();
const { trigger, secretMaterial } = await svc.createTrigger(
routine.id,
{
kind: "webhook",
signingMode: "github_hmac",
},
{},
);
const payload = { action: "opened", pull_request: { number: 1 } };
const rawBody = Buffer.from(JSON.stringify(payload));
const signature = `sha256=${createHmac("sha256", secretMaterial!.webhookSecret)
.update(rawBody)
.digest("hex")}`;
const run = await svc.firePublicTrigger(trigger.publicId!, {
hubSignatureHeader: signature,
rawBody,
payload,
});
expect(run.source).toBe("webhook");
expect(run.status).toBe("issue_created");
});
it("rejects invalid signature for github_hmac signing mode", async () => {
const { routine, svc } = await seedFixture();
const { trigger } = await svc.createTrigger(
routine.id,
{
kind: "webhook",
signingMode: "github_hmac",
},
{},
);
const rawBody = Buffer.from(JSON.stringify({ ok: true }));
await expect(
svc.firePublicTrigger(trigger.publicId!, {
hubSignatureHeader: "sha256=0000000000000000000000000000000000000000000000000000000000000000",
rawBody,
payload: { ok: true },
}),
).rejects.toThrow();
});
it("accepts any request with none signing mode", async () => {
const { routine, svc } = await seedFixture();
const { trigger } = await svc.createTrigger(
routine.id,
{
kind: "webhook",
signingMode: "none",
},
{},
);
const run = await svc.firePublicTrigger(trigger.publicId!, {
payload: { event: "error.created" },
});
expect(run.source).toBe("webhook");
expect(run.status).toBe("issue_created");
});
});

View file

@ -293,6 +293,7 @@ export function routineRoutes(db: Db) {
const result = await svc.firePublicTrigger(req.params.publicId as string, {
authorizationHeader: req.header("authorization"),
signatureHeader: req.header("x-paperclip-signature"),
hubSignatureHeader: req.header("x-hub-signature-256"),
timestampHeader: req.header("x-paperclip-timestamp"),
idempotencyKey: req.header("idempotency-key"),
rawBody: (req as { rawBody?: Buffer }).rawBody ?? null,

View file

@ -1251,6 +1251,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
firePublicTrigger: async (publicId: string, input: {
authorizationHeader?: string | null;
signatureHeader?: string | null;
hubSignatureHeader?: string | null;
timestampHeader?: string | null;
idempotencyKey?: string | null;
rawBody?: Buffer | null;
@ -1266,8 +1267,29 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
if (!routine) throw notFound("Routine not found");
if (!trigger.enabled || routine.status !== "active") throw conflict("Routine trigger is not active");
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
if (trigger.signingMode === "bearer") {
if (trigger.signingMode === "none") {
// No authentication — the publicId in the URL acts as a shared secret.
} 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() ?? "";
if (!providedSignature) throw unauthorized();
const expectedHmac = crypto
.createHmac("sha256", secretValue)
.update(rawBody)
.digest("hex");
const normalizedSignature = providedSignature.replace(/^sha256=/, "");
const normalizedBuf = Buffer.from(normalizedSignature);
const expectedBuf = Buffer.from(expectedHmac);
const valid =
normalizedBuf.length === expectedBuf.length &&
crypto.timingSafeEqual(normalizedBuf, expectedBuf);
if (!valid) throw unauthorized();
} else if (trigger.signingMode === "bearer") {
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
const expected = `Bearer ${secretValue}`;
const provided = input.authorizationHeader?.trim() ?? "";
const expectedBuf = Buffer.from(expected);
@ -1280,6 +1302,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
throw unauthorized();
}
} else {
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
const rawBody = input.rawBody ?? Buffer.from(JSON.stringify(input.payload ?? {}));
const providedSignature = input.signatureHeader?.trim() ?? "";
const providedTimestamp = input.timestampHeader?.trim() ?? "";