mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
feat(server): add github_hmac and none webhook signing modes
Adds two new webhook trigger signing modes for external provider compatibility: - github_hmac: accepts X-Hub-Signature-256 header with HMAC-SHA256(secret, rawBody), no timestamp prefix. Compatible with GitHub, Sentry, and services following the same standard. - none: no authentication; the 24-char hex publicId in the URL acts as the shared secret. For services that cannot add auth headers. The replay window UI field is hidden when these modes are selected since neither uses timestamp-based replay protection. Closes #1892 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
eefe9f39f1
commit
cd19834fab
5 changed files with 109 additions and 15 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,24 @@ 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 ?? {}));
|
||||
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 valid =
|
||||
normalizedSignature.length === expectedHmac.length &&
|
||||
crypto.timingSafeEqual(Buffer.from(normalizedSignature), Buffer.from(expectedHmac));
|
||||
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 +1297,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() ?? "";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue