Skip to content

Commit 3b0593d

Browse files
committed
feat(webhook): optional request signing (HMAC or Ed25519)
Add optional signing to the webhook destination via a `signatureMethod` choice: none (default), hmac, or ed25519. - hmac: symmetric HMAC-SHA256 with a shared secret (Stripe/GitHub style). - ed25519: asymmetric signature — Jitsu signs with a private key, the receiver verifies with the public key and cannot forge messages. The private key is accepted as PEM or a bare base64 body. Both emit the hex signature in a configurable header (default `Jitsu-Signature`, validated as a legal HTTP header name). Replay protection is on by default: the unix-seconds timestamp is folded into the signed payload (`<timestamp>.<body>`) and sent in a companion `<header>-Timestamp` header; it can be turned off to sign the body only. Misconfigurations fail as NoRetryError instead of retrying forever: a malformed key/secret, or a method selected with no key. Signing covers stream-mode delivery only; batch mode (Bulker) is unsigned, warned in both the destination form and the connection editor. The console form shows only the fields relevant to the selected method, and gains a reusable `placeholder` prop for config fields.
1 parent a80ac05 commit 3b0593d

9 files changed

Lines changed: 581 additions & 31 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { createHmac, generateKeyPairSync, verify } 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", ...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("hmac: signs timestamp + body and sends a timestamp header by default", async () => {
44+
const { signature, timestamp, body } = await sign({ signatureMethod: "hmac", signatureSecret: secret });
45+
expect(timestamp).toMatch(/^\d+$/);
46+
expect(signature).toBe(createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex"));
47+
});
48+
49+
test("hmac: signs the body only and omits the timestamp header when replay protection is off", async () => {
50+
const { signature, timestamp, body } = await sign({
51+
signatureMethod: "hmac",
52+
signatureSecret: secret,
53+
signatureIncludeTimestamp: false,
54+
});
55+
expect(timestamp).toBeNull();
56+
expect(signature).toBe(createHmac("sha256", secret).update(body).digest("hex"));
57+
});
58+
59+
test("ed25519: signature verifies against the public key", async () => {
60+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
61+
const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
62+
63+
const { signature, timestamp, body } = await sign({
64+
signatureMethod: "ed25519",
65+
signaturePrivateKey: privatePem,
66+
});
67+
68+
expect(timestamp).toMatch(/^\d+$/);
69+
const ok = verify(null, Buffer.from(`${timestamp}.${body}`), publicKey, Buffer.from(signature, "hex"));
70+
expect(ok).toBe(true);
71+
});
72+
73+
test("a signing method with no key configured fails without retrying", async () => {
74+
server.use(http.post(url, () => HttpResponse.text("ok")));
75+
await expect(
76+
testJitsuFunction<WebhookDestinationConfig>({
77+
func: WebhookDestination,
78+
config: { url, method: "POST", signatureMethod: "hmac" } as WebhookDestinationConfig,
79+
events: [{ type: "track", event: "test_event", messageId: "m1" } as AnalyticsServerEvent],
80+
})
81+
).rejects.toMatchObject({ name: "NoRetryError" });
82+
});
83+
84+
test("schema rejects an invalid signature header name", () => {
85+
expect(
86+
WebhookDestinationConfig.safeParse({ url: "https://example.com", signatureHeader: "Bad Header" }).success
87+
).toBe(false);
88+
expect(
89+
WebhookDestinationConfig.safeParse({ url: "https://example.com", signatureHeader: "X-My-Signature" }).success
90+
).toBe(true);
91+
});
92+
93+
test("ed25519: a malformed private key fails without retrying", async () => {
94+
server.use(http.post(url, () => HttpResponse.text("ok")));
95+
await expect(
96+
testJitsuFunction<WebhookDestinationConfig>({
97+
func: WebhookDestination,
98+
config: {
99+
url,
100+
method: "POST",
101+
signatureMethod: "ed25519",
102+
signaturePrivateKey: "not-a-real-key",
103+
} as WebhookDestinationConfig,
104+
events: [{ type: "track", event: "test_event", messageId: "m1" } as AnalyticsServerEvent],
105+
})
106+
).rejects.toMatchObject({ name: "NoRetryError" });
107+
});
108+
109+
test("ed25519: accepts a bare base64 key body with surrounding whitespace", async () => {
110+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
111+
const bareBase64 = privateKey.export({ type: "pkcs8", format: "der" }).toString("base64");
112+
113+
const { signature, timestamp, body } = await sign({
114+
signatureMethod: "ed25519",
115+
signaturePrivateKey: `\n ${bareBase64} \n`,
116+
});
117+
118+
const ok = verify(null, Buffer.from(`${timestamp}.${body}`), publicKey, Buffer.from(signature, "hex"));
119+
expect(ok).toBe(true);
120+
});

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: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { createHmac, createPrivateKey, sign } from "crypto";
12
import { JitsuFunction } from "@jitsu/protocols/functions";
2-
import { HTTPError, RetryError } from "@jitsu/functions-lib";
3+
import { HTTPError, NoRetryError, NoRetryErrorName, RetryError } from "@jitsu/functions-lib";
34
import type { AnalyticsServerEvent } from "@jitsu/protocols/analytics";
45
import { WebhookDestinationConfig } from "../meta";
56
import { MetricsMeta } from "@jitsu/core-functions-lib";
@@ -10,6 +11,21 @@ const bulkerAuthKey = process.env.BULKER_AUTH_KEY;
1011

1112
const macrosPattern = /\{\{\s*([\w.-]+)\s*}}/g;
1213

14+
// Accepts an Ed25519 private key as full PEM or as the bare base64 PKCS#8 body
15+
// (with any surrounding whitespace), returning a PEM string createPrivateKey parses.
16+
function toPrivateKeyPem(input: string): string {
17+
const trimmed = input.trim();
18+
if (trimmed.includes("-----BEGIN")) {
19+
return trimmed;
20+
}
21+
const body =
22+
trimmed
23+
.replace(/\s+/g, "")
24+
.match(/.{1,64}/g)
25+
?.join("\n") ?? trimmed;
26+
return `-----BEGIN PRIVATE KEY-----\n${body}\n-----END PRIVATE KEY-----`;
27+
}
28+
1329
const WebhookDestination: JitsuFunction<AnalyticsServerEvent, WebhookDestinationConfig> = async (event, ctx) => {
1430
if (ctx["connectionOptions"]?.mode === "batch" && bulkerBase) {
1531
const metricsMeta: Omit<MetricsMeta, "messageId"> = {
@@ -79,6 +95,38 @@ const WebhookDestination: JitsuFunction<AnalyticsServerEvent, WebhookDestination
7995
payload = JSON.stringify(event);
8096
}
8197
const headers = ctx.props.headers || [];
98+
const signatureHeaders: Record<string, string> = {};
99+
const method = ctx.props.signatureMethod || "none";
100+
if (method !== "none") {
101+
const secret = method === "hmac" ? ctx.props.signatureSecret : undefined;
102+
const privateKey = method === "ed25519" ? ctx.props.signaturePrivateKey : undefined;
103+
if (!secret && !privateKey) {
104+
// Method selected but no key — fail loudly instead of silently sending unsigned.
105+
throw new NoRetryError(
106+
`Webhook signing method "${method}" is enabled but no ${
107+
method === "hmac" ? "secret" : "private key"
108+
} is configured`
109+
);
110+
}
111+
const headerName = ctx.props.signatureHeader || "Jitsu-Signature";
112+
let signedData = payload;
113+
let timestamp: string | undefined;
114+
if (ctx.props.signatureIncludeTimestamp ?? true) {
115+
timestamp = String(Math.floor(Date.now() / 1000));
116+
signedData = `${timestamp}.${payload}`;
117+
}
118+
try {
119+
signatureHeaders[headerName] = secret
120+
? createHmac("sha256", secret).update(signedData).digest("hex")
121+
: sign(null, Buffer.from(signedData), createPrivateKey(toPrivateKeyPem(privateKey!))).toString("hex");
122+
} catch (e: any) {
123+
// A malformed key/secret can never succeed on retry — drop instead of retrying forever.
124+
throw new NoRetryError(`Webhook signing failed, check the signing key/secret: ${e.message}`);
125+
}
126+
if (timestamp) {
127+
signatureHeaders[`${headerName}-Timestamp`] = timestamp;
128+
}
129+
}
82130
const res = await ctx.fetch(ctx.props.url, {
83131
method: ctx.props.method || "POST",
84132
body: payload,
@@ -88,6 +136,7 @@ const WebhookDestination: JitsuFunction<AnalyticsServerEvent, WebhookDestination
88136
const [key, value] = header.split(":");
89137
return { ...res, [key]: value };
90138
}, {}),
139+
...signatureHeaders,
91140
},
92141
});
93142
if (!res.ok) {
@@ -103,6 +152,9 @@ const WebhookDestination: JitsuFunction<AnalyticsServerEvent, WebhookDestination
103152
}
104153
return event;
105154
} catch (e: any) {
155+
if (e.name === NoRetryErrorName) {
156+
throw e;
157+
}
106158
throw new RetryError(e.message);
107159
}
108160
}

libs/destination-functions/src/meta.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,41 @@ 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+
signatureMethod: z
51+
.enum(["none", "hmac", "ed25519"])
52+
.optional()
53+
.default("none")
54+
.describe(
55+
"Signature Method::How outgoing requests are signed so your endpoint can verify them.<ul><li><code>none</code> — requests are not signed.</li><li><code>hmac</code> — symmetric HMAC-SHA256 with a shared secret (like Stripe/GitHub). Simple, but anyone who can verify can also forge, so only use it for endpoints you fully trust.</li><li><code>ed25519</code> — asymmetric signature. Jitsu signs with a private key; your endpoint verifies with the public key and cannot forge messages. Prefer this when many or untrusted endpoints receive the webhook.</li></ul><b>Note:</b> signing applies to <b>stream</b>-mode delivery only. In <b>batch</b> mode events are delivered by Bulker and are <b>not</b> signed — use stream mode if you need signed webhooks."
56+
),
57+
signatureSecret: z
58+
.string()
59+
.optional()
60+
.describe(
61+
"Signing Secret::Shared secret for <code>HMAC-SHA256</code> signing (symmetric — there is no key pair). Put the <b>same</b> value here and in your receiving endpoint. Generate a random one with <code>openssl rand -hex 32</code> (any high-entropy string of 32+ bytes works). To verify a request, your endpoint recomputes the HMAC over the signed payload with this secret and compares the hex digest to the <code>Jitsu-Signature</code> header using a constant-time comparison. Keep it secret."
62+
),
63+
signaturePrivateKey: z
64+
.string()
65+
.optional()
66+
.describe(
67+
"Private Key (PEM)::Ed25519 private key used to sign requests. Generate a key pair:<br/><code>openssl genpkey -algorithm ed25519 -out jitsu_private.pem</code><br/><code>openssl pkey -in jitsu_private.pem -pubout -out jitsu_public.pem</code><br/>Paste the contents of <code>jitsu_private.pem</code> here and install <code>jitsu_public.pem</code> on your endpoint. To verify a request, your endpoint checks the <code>Jitsu-Signature</code> header (a hex Ed25519 signature) against the signed payload using the public key. Jitsu holds only the private key — keep it secret; the public key is safe to distribute."
68+
),
69+
signatureHeader: z
70+
.string()
71+
.regex(
72+
/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/,
73+
"Must be a valid HTTP header name (letters, digits, and !#$%&'*+-.^_`|~ only)"
74+
)
75+
.optional()
76+
.default("Jitsu-Signature")
77+
.describe("Signature Header::Name of the header carrying the signature. Only used when signing is enabled."),
78+
signatureIncludeTimestamp: z
79+
.boolean()
80+
.optional()
81+
.default(true)
82+
.describe(
83+
"Replay Protection::When enabled (recommended), the request timestamp (unix seconds) is folded into the signed payload — the signature covers <code>&lt;timestamp&gt;.&lt;body&gt;</code> — and sent in a companion <code>&lt;Signature Header&gt;-Timestamp</code> header. Your endpoint 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 signing is enabled."
84+
),
5085
});
5186

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

0 commit comments

Comments
 (0)