Skip to content

Commit 9cfa37f

Browse files
authored
Merge pull request paperclipai#1961 from antonio-mello-ai/fix/webhook-github-sentry-signing-modes
feat(server): add github_hmac and none webhook signing modes
2 parents 943b851 + a8d1c4b commit 9cfa37f

5 files changed

Lines changed: 114 additions & 15 deletions

File tree

packages/shared/src/constants.ts

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

180-
export const ROUTINE_TRIGGER_SIGNING_MODES = ["bearer", "hmac_sha256"] as const;
180+
export const ROUTINE_TRIGGER_SIGNING_MODES = ["bearer", "hmac_sha256", "github_hmac", "none"] as const;
181181
export type RoutineTriggerSigningMode = (typeof ROUTINE_TRIGGER_SIGNING_MODES)[number];
182182

183183
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: 25 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,29 @@ 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+
// Accept X-Hub-Signature-256 (GitHub/Sentry) or fall back to the
1276+
// generic X-Paperclip-Signature header so operators can use github_hmac
1277+
// mode with either header convention.
1278+
const providedSignature = (input.hubSignatureHeader ?? input.signatureHeader)?.trim() ?? "";
1279+
if (!providedSignature) throw unauthorized();
1280+
const expectedHmac = crypto
1281+
.createHmac("sha256", secretValue)
1282+
.update(rawBody)
1283+
.digest("hex");
1284+
const normalizedSignature = providedSignature.replace(/^sha256=/, "");
1285+
const normalizedBuf = Buffer.from(normalizedSignature);
1286+
const expectedBuf = Buffer.from(expectedHmac);
1287+
const valid =
1288+
normalizedBuf.length === expectedBuf.length &&
1289+
crypto.timingSafeEqual(normalizedBuf, expectedBuf);
1290+
if (!valid) throw unauthorized();
1291+
} else if (trigger.signingMode === "bearer") {
1292+
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
12711293
const expected = `Bearer ${secretValue}`;
12721294
const provided = input.authorizationHeader?.trim() ?? "";
12731295
const expectedBuf = Buffer.from(expected);
@@ -1280,6 +1302,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
12801302
throw unauthorized();
12811303
}
12821304
} else {
1305+
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
12831306
const rawBody = input.rawBody ?? Buffer.from(JSON.stringify(input.payload ?? {}));
12841307
const providedSignature = input.signatureHeader?.trim() ?? "";
12851308
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)