mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50:38 +09:00
Merge pull request #1961 from antonio-mello-ai/fix/webhook-github-sentry-signing-modes
feat(server): add github_hmac and none webhook signing modes
This commit is contained in:
commit
9cfa37fce3
5 changed files with 114 additions and 15 deletions
|
|
@ -177,7 +177,7 @@ export type RoutineCatchUpPolicy = (typeof ROUTINE_CATCH_UP_POLICIES)[number];
|
||||||
export const ROUTINE_TRIGGER_KINDS = ["schedule", "webhook", "api"] as const;
|
export const ROUTINE_TRIGGER_KINDS = ["schedule", "webhook", "api"] as const;
|
||||||
export type RoutineTriggerKind = (typeof ROUTINE_TRIGGER_KINDS)[number];
|
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 type RoutineTriggerSigningMode = (typeof ROUTINE_TRIGGER_SIGNING_MODES)[number];
|
||||||
|
|
||||||
export const ROUTINE_VARIABLE_TYPES = ["text", "textarea", "number", "boolean", "select"] as const;
|
export const ROUTINE_VARIABLE_TYPES = ["text", "textarea", "number", "boolean", "select"] as const;
|
||||||
|
|
|
||||||
|
|
@ -617,4 +617,72 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||||
expect(run.status).toBe("issue_created");
|
expect(run.status).toBe("issue_created");
|
||||||
expect(run.linkedIssueId).toBeTruthy();
|
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, {
|
const result = await svc.firePublicTrigger(req.params.publicId as string, {
|
||||||
authorizationHeader: req.header("authorization"),
|
authorizationHeader: req.header("authorization"),
|
||||||
signatureHeader: req.header("x-paperclip-signature"),
|
signatureHeader: req.header("x-paperclip-signature"),
|
||||||
|
hubSignatureHeader: req.header("x-hub-signature-256"),
|
||||||
timestampHeader: req.header("x-paperclip-timestamp"),
|
timestampHeader: req.header("x-paperclip-timestamp"),
|
||||||
idempotencyKey: req.header("idempotency-key"),
|
idempotencyKey: req.header("idempotency-key"),
|
||||||
rawBody: (req as { rawBody?: Buffer }).rawBody ?? null,
|
rawBody: (req as { rawBody?: Buffer }).rawBody ?? null,
|
||||||
|
|
|
||||||
|
|
@ -1251,6 +1251,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
firePublicTrigger: async (publicId: string, input: {
|
firePublicTrigger: async (publicId: string, input: {
|
||||||
authorizationHeader?: string | null;
|
authorizationHeader?: string | null;
|
||||||
signatureHeader?: string | null;
|
signatureHeader?: string | null;
|
||||||
|
hubSignatureHeader?: string | null;
|
||||||
timestampHeader?: string | null;
|
timestampHeader?: string | null;
|
||||||
idempotencyKey?: string | null;
|
idempotencyKey?: string | null;
|
||||||
rawBody?: Buffer | null;
|
rawBody?: Buffer | null;
|
||||||
|
|
@ -1266,8 +1267,29 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
if (!routine) throw notFound("Routine not found");
|
if (!routine) throw notFound("Routine not found");
|
||||||
if (!trigger.enabled || routine.status !== "active") throw conflict("Routine trigger is not active");
|
if (!trigger.enabled || routine.status !== "active") throw conflict("Routine trigger is not active");
|
||||||
|
|
||||||
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
|
if (trigger.signingMode === "none") {
|
||||||
if (trigger.signingMode === "bearer") {
|
// 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 expected = `Bearer ${secretValue}`;
|
||||||
const provided = input.authorizationHeader?.trim() ?? "";
|
const provided = input.authorizationHeader?.trim() ?? "";
|
||||||
const expectedBuf = Buffer.from(expected);
|
const expectedBuf = Buffer.from(expected);
|
||||||
|
|
@ -1280,6 +1302,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
throw unauthorized();
|
throw unauthorized();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
|
||||||
const rawBody = input.rawBody ?? Buffer.from(JSON.stringify(input.payload ?? {}));
|
const rawBody = input.rawBody ?? Buffer.from(JSON.stringify(input.payload ?? {}));
|
||||||
const providedSignature = input.signatureHeader?.trim() ?? "";
|
const providedSignature = input.signatureHeader?.trim() ?? "";
|
||||||
const providedTimestamp = input.timestampHeader?.trim() ?? "";
|
const providedTimestamp = input.timestampHeader?.trim() ?? "";
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ import type { RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
|
||||||
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
||||||
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
|
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
|
||||||
const triggerKinds = ["schedule", "webhook"];
|
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 routineTabs = ["triggers", "runs", "activity"] as const;
|
||||||
const concurrencyPolicyDescriptions: Record<string, string> = {
|
const concurrencyPolicyDescriptions: Record<string, string> = {
|
||||||
coalesce_if_active: "Keep one follow-up run queued while an active run is still working.",
|
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> = {
|
||||||
const signingModeDescriptions: Record<string, string> = {
|
const signingModeDescriptions: Record<string, string> = {
|
||||||
bearer: "Expect a shared bearer token in the Authorization header.",
|
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.",
|
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];
|
type RoutineTab = (typeof routineTabs)[number];
|
||||||
|
|
||||||
|
|
@ -198,13 +201,15 @@ function TriggerEditor({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && (
|
||||||
<Label className="text-xs">Replay window (seconds)</Label>
|
<div className="space-y-1.5">
|
||||||
<Input
|
<Label className="text-xs">Replay window (seconds)</Label>
|
||||||
value={draft.replayWindowSec}
|
<Input
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))}
|
value={draft.replayWindowSec}
|
||||||
/>
|
onChange={(event) => setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -987,10 +992,12 @@ export function RoutineDetail() {
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-muted-foreground">{signingModeDescriptions[newTrigger.signingMode]}</p>
|
<p className="text-xs text-muted-foreground">{signingModeDescriptions[newTrigger.signingMode]}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(newTrigger.signingMode) && (
|
||||||
<Label className="text-xs">Replay window (seconds)</Label>
|
<div className="space-y-1.5">
|
||||||
<Input value={newTrigger.replayWindowSec} onChange={(event) => setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} />
|
<Label className="text-xs">Replay window (seconds)</Label>
|
||||||
</div>
|
<Input value={newTrigger.replayWindowSec} onChange={(event) => setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue