From cd19834fab3bd15577a57147736b8dcce06e0bf9 Mon Sep 17 00:00:00 2001 From: Antonio Date: Fri, 27 Mar 2026 23:15:55 -0300 Subject: [PATCH] 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) --- packages/shared/src/constants.ts | 2 +- server/src/__tests__/routines-service.test.ts | 68 +++++++++++++++++++ server/src/routes/routines.ts | 1 + server/src/services/routines.ts | 22 +++++- ui/src/pages/RoutineDetail.tsx | 31 +++++---- 5 files changed, 109 insertions(+), 15 deletions(-) diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 61023830..e1ff17df 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -165,7 +165,7 @@ export type RoutineCatchUpPolicy = (typeof ROUTINE_CATCH_UP_POLICIES)[number]; export const ROUTINE_TRIGGER_KINDS = ["schedule", "webhook", "api"] as const; export type RoutineTriggerKind = (typeof ROUTINE_TRIGGER_KINDS)[number]; -export const ROUTINE_TRIGGER_SIGNING_MODES = ["bearer", "hmac_sha256"] as const; +export const ROUTINE_TRIGGER_SIGNING_MODES = ["bearer", "hmac_sha256", "github_hmac", "none"] as const; export type RoutineTriggerSigningMode = (typeof ROUTINE_TRIGGER_SIGNING_MODES)[number]; export const ROUTINE_VARIABLE_TYPES = ["text", "textarea", "number", "boolean", "select"] as const; diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 8eee43ce..5363fa83 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -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"); + }); }); diff --git a/server/src/routes/routines.ts b/server/src/routes/routines.ts index 7045a52d..fc237a51 100644 --- a/server/src/routes/routines.ts +++ b/server/src/routes/routines.ts @@ -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, diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index f1f9e1ef..720b064d 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -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() ?? ""; diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index c1ca92e7..aeaba763 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -61,7 +61,7 @@ import type { RoutineTrigger, RoutineVariable } from "@paperclipai/shared"; const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"]; const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"]; const triggerKinds = ["schedule", "webhook"]; -const signingModes = ["bearer", "hmac_sha256"]; +const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"]; const routineTabs = ["triggers", "runs", "activity"] as const; const concurrencyPolicyDescriptions: Record = { coalesce_if_active: "Keep one follow-up run queued while an active run is still working.", @@ -75,7 +75,10 @@ const catchUpPolicyDescriptions: Record = { const signingModeDescriptions: Record = { bearer: "Expect a shared bearer token in the Authorization header.", hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.", + github_hmac: "Accept GitHub-style X-Hub-Signature-256 header (HMAC over raw body, no timestamp).", + none: "No authentication — the webhook URL itself acts as a shared secret.", }; +const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]); type RoutineTab = (typeof routineTabs)[number]; @@ -198,13 +201,15 @@ function TriggerEditor({ -
- - setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))} - /> -
+ {!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && ( +
+ + setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))} + /> +
+ )} )} @@ -987,10 +992,12 @@ export function RoutineDetail() {

{signingModeDescriptions[newTrigger.signingMode]}

-
- - setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} /> -
+ {!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(newTrigger.signingMode) && ( +
+ + setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} /> +
+ )} )}