Skip to content

Commit 7312ab2

Browse files
committed
feat(webhook): optional HMAC-SHA256 request signing
Add optional signing to the webhook destination. When a signing secret is set, each request is signed with HMAC-SHA256 and the hex signature is sent in a configurable header (default `Jitsu-Signature`). Replay protection is on by default: the unix-seconds timestamp is folded into the signed payload (HMAC over `<timestamp>.<body>`) and sent in a companion `<header>-Timestamp` header, so the receiver can reject stale requests. It can be turned off to sign the raw body only.
1 parent a80ac05 commit 7312ab2

6 files changed

Lines changed: 416 additions & 17 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { createHmac } from "crypto";
2+
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
3+
import { http, HttpResponse } from "msw";
4+
import { setupServer } from "msw/node";
5+
import { AnalyticsServerEvent } from "@jitsu/protocols/analytics";
6+
import { testJitsuFunction } from "./lib/testing-lib";
7+
import WebhookDestination from "../src/functions/webhook-destination";
8+
import { WebhookDestinationConfig } from "../src/meta";
9+
10+
// MSW intercepts the real fetch so we assert on the actual signed request.
11+
const url = "http://webhook.test.local/hook";
12+
const secret = "super-secret-key";
13+
const server = setupServer();
14+
15+
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
16+
afterEach(() => server.resetHandlers());
17+
afterAll(() => server.close());
18+
19+
// Runs the webhook destination once and returns the signature headers + sent body.
20+
async function sign(
21+
config: Partial<WebhookDestinationConfig>
22+
): Promise<{ signature: string; timestamp: string | null; body: string }> {
23+
let captured: { signature: string; timestamp: string | null; body: string } | undefined;
24+
server.use(
25+
http.post(url, async ({ request }) => {
26+
captured = {
27+
signature: request.headers.get("Jitsu-Signature") ?? "",
28+
timestamp: request.headers.get("Jitsu-Signature-Timestamp"),
29+
body: await request.text(),
30+
};
31+
return HttpResponse.text("ok");
32+
})
33+
);
34+
await testJitsuFunction<WebhookDestinationConfig>({
35+
func: WebhookDestination,
36+
config: { url, method: "POST", signatureSecret: secret, ...config } as WebhookDestinationConfig,
37+
events: [{ type: "track", event: "test_event", messageId: "m1" } as AnalyticsServerEvent],
38+
});
39+
if (!captured) throw new Error("webhook was never called");
40+
return captured;
41+
}
42+
43+
test("signs timestamp + body and sends a timestamp header by default", async () => {
44+
const { signature, timestamp, body } = await sign({});
45+
expect(timestamp).toMatch(/^\d+$/);
46+
expect(signature).toBe(createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex"));
47+
});
48+
49+
test("signs the body only and omits the timestamp header when replay protection is off", async () => {
50+
const { signature, timestamp, body } = await sign({ signatureIncludeTimestamp: false });
51+
expect(timestamp).toBeNull();
52+
expect(signature).toBe(createHmac("sha256", secret).update(body).digest("hex"));
53+
});

libs/destination-functions/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@types/node": "catalog:",
2929
"@vitest/ui": "catalog:",
3030
"json5": "^2.1.0",
31+
"msw": "^2.15.0",
3132
"vitest": "catalog:",
3233
"vite": "^6.4.3"
3334
},

libs/destination-functions/src/functions/webhook-destination.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHmac } from "crypto";
12
import { JitsuFunction } from "@jitsu/protocols/functions";
23
import { HTTPError, RetryError } from "@jitsu/functions-lib";
34
import type { AnalyticsServerEvent } from "@jitsu/protocols/analytics";
@@ -79,6 +80,19 @@ const WebhookDestination: JitsuFunction<AnalyticsServerEvent, WebhookDestination
7980
payload = JSON.stringify(event);
8081
}
8182
const headers = ctx.props.headers || [];
83+
const signatureHeaders: Record<string, string> = {};
84+
if (ctx.props.signatureSecret) {
85+
const headerName = ctx.props.signatureHeader || "Jitsu-Signature";
86+
if (ctx.props.signatureIncludeTimestamp ?? true) {
87+
const t = Math.floor(Date.now() / 1000);
88+
signatureHeaders[headerName] = createHmac("sha256", ctx.props.signatureSecret)
89+
.update(`${t}.${payload}`)
90+
.digest("hex");
91+
signatureHeaders[`${headerName}-Timestamp`] = String(t);
92+
} else {
93+
signatureHeaders[headerName] = createHmac("sha256", ctx.props.signatureSecret).update(payload).digest("hex");
94+
}
95+
}
8296
const res = await ctx.fetch(ctx.props.url, {
8397
method: ctx.props.method || "POST",
8498
body: payload,
@@ -88,6 +102,7 @@ const WebhookDestination: JitsuFunction<AnalyticsServerEvent, WebhookDestination
88102
const [key, value] = header.split(":");
89103
return { ...res, [key]: value };
90104
}, {}),
105+
...signatureHeaders,
91106
},
92107
});
93108
if (!res.ok) {

libs/destination-functions/src/meta.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,24 @@ export const WebhookDestinationConfig = z.object({
4747
.describe(
4848
"Payload Template::Template for the webhook payload. The following macros are supported:<ul><li><code>{{ EVENT }}</code> - event json object for stream mode or batches with size=1</li><li><code>{{ EVENTS }}</code> - for batch mode - json array of events</li><li><code>{{ EVENTS_COUNT }}</code> - count of events in batch</li><li><code>{{ NAME }}</code> - event name</li><li><code>{{ env.VAR_NAME }}</code> - value of VAR_NAME environment variable</li></ul>"
4949
),
50+
signatureSecret: z
51+
.string()
52+
.optional()
53+
.describe(
54+
"Signing Secret::If set, every request is signed with <code>HMAC-SHA256</code> using this secret and the hex signature is sent in a header (see below). Your receiver recomputes the same HMAC and compares it to reject forged or tampered requests. Leave empty to disable signing."
55+
),
56+
signatureHeader: z
57+
.string()
58+
.optional()
59+
.default("Jitsu-Signature")
60+
.describe("Signature Header::Name of the header carrying the signature. Only used when a signing secret is set."),
61+
signatureIncludeTimestamp: z
62+
.boolean()
63+
.optional()
64+
.default(true)
65+
.describe(
66+
"Replay Protection::When enabled (recommended), the request timestamp (unix seconds) is folded into the signed payload — the HMAC is computed over <code>&lt;timestamp&gt;.&lt;body&gt;</code> — and sent in a companion <code>&lt;Signature Header&gt;-Timestamp</code> header. Your receiver can then reject requests with an old timestamp to block replays of a captured, still-valid request. When disabled, only the raw body is signed and no timestamp header is sent: simpler to verify, but a captured request stays valid forever. Only used when a signing secret is set."
67+
),
5068
});
5169

5270
export type WebhookDestinationConfig = z.infer<typeof WebhookDestinationConfig>;

0 commit comments

Comments
 (0)