Skip to content

Commit cd19834

Browse files
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 paperclipai#1892 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent eefe9f3 commit cd19834

5 files changed

Lines changed: 109 additions & 15 deletions

File tree

packages/shared/src/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ export type RoutineCatchUpPolicy = (typeof ROUTINE_CATCH_UP_POLICIES)[number];
165165
export const ROUTINE_TRIGGER_KINDS = ["schedule", "webhook", "api"] as const;
166166
export type RoutineTriggerKind = (typeof ROUTINE_TRIGGER_KINDS)[number];
167167

168-
export const ROUTINE_TRIGGER_SIGNING_MODES = ["bearer", "hmac_sha256"] as const;
168+
export const ROUTINE_TRIGGER_SIGNING_MODES = ["bearer", "hmac_sha256", "github_hmac", "none"] as const;
169169
export type RoutineTriggerSigningMode = (typeof ROUTINE_TRIGGER_SIGNING_MODES)[number];
170170

171171
export const ROUTINE_VARIABLE_TYPES = ["text", "textarea", "number", "boolean", "select"] as const;

server/src/__tests__/routines-service.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,4 +617,72 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
617617
expect(run.status).toBe("issue_created");
618618
expect(run.linkedIssueId).toBeTruthy();
619619
});
620+
621+
it("accepts GitHub-style X-Hub-Signature-256 with github_hmac signing mode", async () => {
622+
const { routine, svc } = await seedFixture();
623+
const { trigger, secretMaterial } = await svc.createTrigger(
624+
routine.id,
625+
{
626+
kind: "webhook",
627+
signingMode: "github_hmac",
628+
},
629+
{},
630+
);
631+
632+
const payload = { action: "opened", pull_request: { number: 1 } };
633+
const rawBody = Buffer.from(JSON.stringify(payload));
634+
const signature = `sha256=${createHmac("sha256", secretMaterial!.webhookSecret)
635+
.update(rawBody)
636+
.digest("hex")}`;
637+
638+
const run = await svc.firePublicTrigger(trigger.publicId!, {
639+
hubSignatureHeader: signature,
640+
rawBody,
641+
payload,
642+
});
643+
644+
expect(run.source).toBe("webhook");
645+
expect(run.status).toBe("issue_created");
646+
});
647+
648+
it("rejects invalid signature for github_hmac signing mode", async () => {
649+
const { routine, svc } = await seedFixture();
650+
const { trigger } = await svc.createTrigger(
651+
routine.id,
652+
{
653+
kind: "webhook",
654+
signingMode: "github_hmac",
655+
},
656+
{},
657+
);
658+
659+
const rawBody = Buffer.from(JSON.stringify({ ok: true }));
660+
661+
await expect(
662+
svc.firePublicTrigger(trigger.publicId!, {
663+
hubSignatureHeader: "sha256=0000000000000000000000000000000000000000000000000000000000000000",
664+
rawBody,
665+
payload: { ok: true },
666+
}),
667+
).rejects.toThrow();
668+
});
669+
670+
it("accepts any request with none signing mode", async () => {
671+
const { routine, svc } = await seedFixture();
672+
const { trigger } = await svc.createTrigger(
673+
routine.id,
674+
{
675+
kind: "webhook",
676+
signingMode: "none",
677+
},
678+
{},
679+
);
680+
681+
const run = await svc.firePublicTrigger(trigger.publicId!, {
682+
payload: { event: "error.created" },
683+
});
684+
685+
expect(run.source).toBe("webhook");
686+
expect(run.status).toBe("issue_created");
687+
});
620688
});

server/src/routes/routines.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ export function routineRoutes(db: Db) {
293293
const result = await svc.firePublicTrigger(req.params.publicId as string, {
294294
authorizationHeader: req.header("authorization"),
295295
signatureHeader: req.header("x-paperclip-signature"),
296+
hubSignatureHeader: req.header("x-hub-signature-256"),
296297
timestampHeader: req.header("x-paperclip-timestamp"),
297298
idempotencyKey: req.header("idempotency-key"),
298299
rawBody: (req as { rawBody?: Buffer }).rawBody ?? null,

server/src/services/routines.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1251,6 +1251,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
12511251
firePublicTrigger: async (publicId: string, input: {
12521252
authorizationHeader?: string | null;
12531253
signatureHeader?: string | null;
1254+
hubSignatureHeader?: string | null;
12541255
timestampHeader?: string | null;
12551256
idempotencyKey?: string | null;
12561257
rawBody?: Buffer | null;
@@ -1266,8 +1267,24 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
12661267
if (!routine) throw notFound("Routine not found");
12671268
if (!trigger.enabled || routine.status !== "active") throw conflict("Routine trigger is not active");
12681269

1269-
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
1270-
if (trigger.signingMode === "bearer") {
1270+
if (trigger.signingMode === "none") {
1271+
// No authentication — the publicId in the URL acts as a shared secret.
1272+
} else if (trigger.signingMode === "github_hmac") {
1273+
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
1274+
const rawBody = input.rawBody ?? Buffer.from(JSON.stringify(input.payload ?? {}));
1275+
const providedSignature = (input.hubSignatureHeader ?? input.signatureHeader)?.trim() ?? "";
1276+
if (!providedSignature) throw unauthorized();
1277+
const expectedHmac = crypto
1278+
.createHmac("sha256", secretValue)
1279+
.update(rawBody)
1280+
.digest("hex");
1281+
const normalizedSignature = providedSignature.replace(/^sha256=/, "");
1282+
const valid =
1283+
normalizedSignature.length === expectedHmac.length &&
1284+
crypto.timingSafeEqual(Buffer.from(normalizedSignature), Buffer.from(expectedHmac));
1285+
if (!valid) throw unauthorized();
1286+
} else if (trigger.signingMode === "bearer") {
1287+
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
12711288
const expected = `Bearer ${secretValue}`;
12721289
const provided = input.authorizationHeader?.trim() ?? "";
12731290
const expectedBuf = Buffer.from(expected);
@@ -1280,6 +1297,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
12801297
throw unauthorized();
12811298
}
12821299
} else {
1300+
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
12831301
const rawBody = input.rawBody ?? Buffer.from(JSON.stringify(input.payload ?? {}));
12841302
const providedSignature = input.signatureHeader?.trim() ?? "";
12851303
const providedTimestamp = input.timestampHeader?.trim() ?? "";

ui/src/pages/RoutineDetail.tsx

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import type { RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
6161
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
6262
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
6363
const triggerKinds = ["schedule", "webhook"];
64-
const signingModes = ["bearer", "hmac_sha256"];
64+
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"];
6565
const routineTabs = ["triggers", "runs", "activity"] as const;
6666
const concurrencyPolicyDescriptions: Record<string, string> = {
6767
coalesce_if_active: "Keep one follow-up run queued while an active run is still working.",
@@ -75,7 +75,10 @@ const catchUpPolicyDescriptions: Record<string, string> = {
7575
const signingModeDescriptions: Record<string, string> = {
7676
bearer: "Expect a shared bearer token in the Authorization header.",
7777
hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.",
78+
github_hmac: "Accept GitHub-style X-Hub-Signature-256 header (HMAC over raw body, no timestamp).",
79+
none: "No authentication — the webhook URL itself acts as a shared secret.",
7880
};
81+
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]);
7982

8083
type RoutineTab = (typeof routineTabs)[number];
8184

@@ -198,13 +201,15 @@ function TriggerEditor({
198201
</SelectContent>
199202
</Select>
200203
</div>
201-
<div className="space-y-1.5">
202-
<Label className="text-xs">Replay window (seconds)</Label>
203-
<Input
204-
value={draft.replayWindowSec}
205-
onChange={(event) => setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))}
206-
/>
207-
</div>
204+
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && (
205+
<div className="space-y-1.5">
206+
<Label className="text-xs">Replay window (seconds)</Label>
207+
<Input
208+
value={draft.replayWindowSec}
209+
onChange={(event) => setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))}
210+
/>
211+
</div>
212+
)}
208213
</>
209214
)}
210215
</div>
@@ -987,10 +992,12 @@ export function RoutineDetail() {
987992
</Select>
988993
<p className="text-xs text-muted-foreground">{signingModeDescriptions[newTrigger.signingMode]}</p>
989994
</div>
990-
<div className="space-y-1.5">
991-
<Label className="text-xs">Replay window (seconds)</Label>
992-
<Input value={newTrigger.replayWindowSec} onChange={(event) => setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} />
993-
</div>
995+
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(newTrigger.signingMode) && (
996+
<div className="space-y-1.5">
997+
<Label className="text-xs">Replay window (seconds)</Label>
998+
<Input value={newTrigger.replayWindowSec} onChange={(event) => setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} />
999+
</div>
1000+
)}
9941001
</>
9951002
)}
9961003
</div>

0 commit comments

Comments
 (0)