diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index e9bc3491..521ccf38 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -177,7 +177,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..86cc69cb 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,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() ?? ""; 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 }))} /> +
+ )} )}