Skip to content

Commit a1352d3

Browse files
committed
fix(webhooks): harden verify, create, replay, and relay edge cases
Adversarial audit follow-ups on the unreleased webhooks group: - verify: reject an explicit empty --payload as a usage error instead of hashing an `undefined` pre-image and silently failing (exit 2, not 1). - create: propagate an AuthError from the post-create secret fetch instead of masking it as "secret unavailable"; tag the genuine partial-failure with the new webhook_secret_fetch_failed code for agent branching. - replay: `--until` alone now points at the missing --since rather than emitting the vaguer "pass <msg_id> or --since" hint. - relay-client: route the 1008 token-collision redial through the standard reconnect backoff (no zero-delay storm) and guard onopen against a stop() that races socket construction. - README: document at-least-once redelivery on reconnect (handlers must key on svix-id). Adds tests for each fix. Full suite 1934 pass / 0 fail. Claude-Session: https://claude.ai/code/session_015J6Sduw5KeHz6SxLEBfViF
1 parent 9889ce1 commit a1352d3

10 files changed

Lines changed: 112 additions & 13 deletions

File tree

packages/cli-core/src/commands/webhooks/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ Behavior notes:
250250
- **Keepalive**: the relay server pings ~every 21s, but Bun's client WebSocket auto-pongs below the JS API (no ping events). A probe timer fires every `RELAY_SILENCE_TIMEOUT_MS / 2` (~15s) and, once at least `RELAY_SILENCE_TIMEOUT_MS` (30s) has elapsed with no inbound message, sends an active `ws.ping()` (so a probe lands 30–45s into a silence, depending on timer phase) — writes to a dead link surface as close/error, which redials with the same token. Reconnects never change the relay URL.
251251
- **Per-delivery output**: human mode prints `time --> event_type msg_…` then `<-- status method path ms` via `log.ui` (bypasses the stderr throttle). Diagnostics: 401 → `clerkMiddleware` public-route hint; 400 → raw-body/`verifyWebhook()` order hint; 5xx → response body inline plus the exact `clerk webhooks replay <msg_id>` line; unreachable handler → synthetic **502** framed back to the relay.
252252
- **Verification**: deliveries failing HMAC are warned about and still forwarded (the mismatch means the relay secret diverged, not that the local handler should silently miss events).
253+
- **At-least-once**: forwarding is at-least-once, like any webhook stream. If the relay socket drops while a delivery is mid-forward, its response frame is sent on the closed socket and dropped, so Svix may redeliver it (and the new inbox URL after a 1008 rotation only appears in the next `ready` line on restart). Local handlers must key on `svix-id` and be idempotent.
253254
- **Agent/`--json` mode**: NDJSON on stdout. Every line carries a `type` discriminator: one `{ "type": "ready", ... }` line (`relay_url`, `signing_secret`, `endpoint_id`, `events_filter`), then one `{ "type": "event", ... }` line per delivery (`svix_id`, `event_type`, `headers`, `body_b64`, `forward_status`, `latency_ms`). An event line saved to a file is directly consumable by `verify --delivery @file`.
254255
- **SIGINT**: `listen` replaces the global cleanup-free handler before opening the socket: close socket, drain in-flight forwards, exit 130. The relay endpoint is **never** deleted on exit — its URL and `whsec_` stay stable across restarts. `listen` never exits 0.
255256

packages/cli-core/src/commands/webhooks/create.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test";
2-
import { CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts";
2+
import { AuthError, CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts";
33
import { useCaptureLog } from "../../test/lib/stubs.ts";
44

55
const mockCreateWebhookEndpoint = mock();
@@ -134,9 +134,26 @@ describe("webhooks create", () => {
134134
const promise = webhooksCreate({ url: "https://example.com/webhooks" });
135135

136136
await expect(promise).rejects.toBeInstanceOf(CliError);
137+
await expect(promise).rejects.toMatchObject({
138+
code: ERROR_CODE.WEBHOOK_SECRET_FETCH_FAILED,
139+
});
137140
await expect(promise).rejects.toThrow(
138141
"Endpoint created (id: ep_new) but the signing secret could not be fetched. " +
139142
"Run 'clerk webhooks secret ep_new' to retrieve it.",
140143
);
141144
});
145+
146+
test("partial failure: an auth error on the secret fetch is not masked as a secret-fetch failure", async () => {
147+
mockGetWebhookEndpointSecret.mockRejectedValue(
148+
new AuthError({
149+
reason: "session_expired",
150+
message: "Session expired. Run `clerk auth login`.",
151+
}),
152+
);
153+
154+
const promise = webhooksCreate({ url: "https://example.com/webhooks" });
155+
156+
await expect(promise).rejects.toBeInstanceOf(AuthError);
157+
await expect(promise).rejects.toThrow("Session expired");
158+
});
142159
});

packages/cli-core/src/commands/webhooks/create.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { resolveAppContext } from "../../lib/config.ts";
2-
import { CliError, throwUsageError } from "../../lib/errors.ts";
2+
import { AuthError, CliError, ERROR_CODE, throwUsageError } from "../../lib/errors.ts";
33
import { log } from "../../lib/log.ts";
44
import {
55
createWebhookEndpoint,
@@ -46,12 +46,17 @@ export async function webhooksCreate(options: WebhooksCreateOptions = {}): Promi
4646
let secret: string;
4747
try {
4848
({ secret } = await getWebhookEndpointSecret(ctx.appId, ctx.instanceId, endpoint.id));
49-
} catch {
49+
} catch (error) {
50+
// An auth failure on the second call is the real problem — let it surface
51+
// with its own reason and docs URL instead of being masked as a transient
52+
// secret-fetch hiccup.
53+
if (error instanceof AuthError) throw error;
5054
// Create is atomic; the secret fetch is a second call. Never leave a
5155
// silent orphan — surface the new ID and the exact recovery command.
5256
throw new CliError(
5357
`Endpoint created (id: ${endpoint.id}) but the signing secret could not be fetched. ` +
5458
`Run 'clerk webhooks secret ${endpoint.id}' to retrieve it.`,
59+
{ code: ERROR_CODE.WEBHOOK_SECRET_FETCH_FAILED },
5560
);
5661
}
5762

packages/cli-core/src/commands/webhooks/relay-client.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ describe("RelayClient", () => {
219219
expect(harness.rotated).toHaveLength(0);
220220
});
221221

222-
test("a 1008 collision rotates to a fresh c_ token, persists it, and redials", async () => {
222+
test("a 1008 collision rotates to a fresh c_ token, persists it, and redials after the reconnect delay", async () => {
223223
const harness = await openClient();
224224
wsAt(0).fireClose(RELAY_CLOSE_TOKEN_COLLISION);
225225
await flush();
@@ -228,12 +228,31 @@ describe("RelayClient", () => {
228228
expect(harness.client.token).toMatch(/^c_[0-9A-Za-z]{10}$/);
229229
expect(harness.rotated).toEqual([harness.client.token]);
230230

231-
expect(FakeWebSocket.instances).toHaveLength(2); // immediate redial, no reconnect-delay timer
231+
// Redial is deferred through the reconnect backoff so a relay that rejects
232+
// every fresh token can't drive a zero-delay reconnect storm.
233+
expect(FakeWebSocket.instances).toHaveLength(1);
234+
expect(timeoutDelay).toBe(RELAY_RECONNECT_DELAY_MS);
235+
236+
timeoutCallback?.();
237+
expect(FakeWebSocket.instances).toHaveLength(2);
232238
wsAt(1).open();
233239
expect(wsAt(1).sent[0]).toBe(encodeStartFrame(harness.client.token));
234240
expect(harness.reconnects).toBe(0); // collision is not a generic reconnect
235241
});
236242

243+
test("stop() before the socket opens suppresses the start frame and the keepalive probe", async () => {
244+
const harness = makeClient();
245+
void harness.client.start(); // never resolves; the socket never finishes opening
246+
const ws = wsAt(0);
247+
248+
harness.client.stop();
249+
ws.open();
250+
251+
expect(ws.sent).toHaveLength(0); // no start frame on a stopped client
252+
expect(ws.closedWith).toBe(1000);
253+
expect(intervalCallback).toBeUndefined(); // probe timer never armed
254+
});
255+
237256
test("stop() closes with 1000 and suppresses any further reconnect", async () => {
238257
const harness = await openClient();
239258
const ws = wsAt(0);

packages/cli-core/src/commands/webhooks/relay-client.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ export class RelayClient {
6363
this.ws = ws;
6464

6565
ws.onopen = () => {
66+
// stop() may have raced in between `new WebSocket` and this open; if so,
67+
// don't send the start frame, arm the probe timer, or resolve start().
68+
if (this.stopped) {
69+
ws.close(1000);
70+
return;
71+
}
6672
log.debug(`relay: connected, sending start frame (token=${this.token})`);
6773
ws.send(encodeStartFrame(this.token));
6874
this.lastActivityAt = Date.now();
@@ -93,10 +99,15 @@ export class RelayClient {
9399
if (this.stopped) return;
94100

95101
if (event.code === RELAY_CLOSE_TOKEN_COLLISION) {
96-
// Another listener holds this token: rotate, persist, redial.
102+
// Another listener holds this token: rotate, persist, redial. Reconnect
103+
// through the same backoff as a normal drop so a relay that rejects
104+
// every fresh token can't spin a zero-delay reconnect storm.
97105
this.token = generateRelayToken();
98106
log.debug("relay: token collision (1008), rotating token");
99-
void this.options.onTokenRotated(this.token).then(() => this.connect());
107+
void this.options.onTokenRotated(this.token).then(() => {
108+
if (this.stopped) return;
109+
setTimeout(() => this.connect(), RELAY_RECONNECT_DELAY_MS);
110+
});
100111
return;
101112
}
102113

packages/cli-core/src/commands/webhooks/replay.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ describe("webhooks replay", () => {
9191
expect(mockRecoverWebhookMessages).not.toHaveBeenCalled();
9292
});
9393

94+
test("--until alone points at the missing --since instead of a vaguer hint", async () => {
95+
await expect(webhooksReplay({ until: "2026-05-01T00:00:00Z" })).rejects.toThrow(
96+
"--until requires --since.",
97+
);
98+
});
99+
94100
test("resends one message to an explicit --endpoint without prompting", async () => {
95101
await webhooksReplay({ msgId: "msg_1", endpoint: "ep_1" });
96102

packages/cli-core/src/commands/webhooks/replay.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@ function validateReplayMode(options: WebhooksReplayOptions): "resend" | "recover
2828
if (options.msgId && options.since) {
2929
throwUsageError("Pass either a <msg_id> or --since, not both.");
3030
}
31-
if (!options.msgId && !options.since) {
32-
throwUsageError("Pass a <msg_id> to resend one delivery, or --since <ISO> to bulk-recover.");
33-
}
31+
// Check the orphaned-`--until` case before the generic "neither" message, so
32+
// `replay --until <ISO>` points at the real problem instead of a vaguer hint.
3433
if (options.until && !options.since) {
3534
throwUsageError("--until requires --since.");
3635
}
36+
if (!options.msgId && !options.since) {
37+
throwUsageError("Pass a <msg_id> to resend one delivery, or --since <ISO> to bulk-recover.");
38+
}
3739
if (options.since) {
3840
assertRfc3339(options.since, "--since");
3941
if (options.until) assertRfc3339(options.until, "--until");

packages/cli-core/src/commands/webhooks/verify.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,26 @@ describe("webhooks verify command", () => {
179179
expect(captured.err).toContain("Signature verified.");
180180
});
181181

182+
test("a multi-line --delivery file verifies against the first non-empty line", async () => {
183+
const firstLine = JSON.stringify({
184+
headers: { "svix-id": ID, "svix-timestamp": TIMESTAMP, "svix-signature": VALID_SIGNATURE },
185+
body_b64: Buffer.from(PAYLOAD, "utf8").toString("base64"),
186+
});
187+
const secondLine = JSON.stringify({
188+
headers: {
189+
"svix-id": "msg_later",
190+
"svix-timestamp": TIMESTAMP,
191+
"svix-signature": "v1,deadbeef",
192+
},
193+
body_b64: Buffer.from('{"type":"user.deleted"}', "utf8").toString("base64"),
194+
});
195+
const deliveryPath = await writeTempFile("events.ndjson", `${firstLine}\n${secondLine}\n`);
196+
197+
await webhooksVerify({ secret: SECRET, delivery: `@${deliveryPath}` });
198+
199+
expect(captured.err).toContain("Signature verified.");
200+
});
201+
182202
test("explicit flags override --delivery fields", async () => {
183203
const line = JSON.stringify({
184204
headers: {
@@ -274,6 +294,18 @@ describe("webhooks verify command", () => {
274294
payload: "{}",
275295
},
276296
},
297+
{
298+
// An explicit empty --payload must surface as a usage error, not fall
299+
// through to the --delivery body or hash an `undefined` pre-image.
300+
label: "empty --payload",
301+
options: {
302+
secret: SECRET,
303+
id: ID,
304+
timestamp: TIMESTAMP,
305+
signature: VALID_SIGNATURE,
306+
payload: "",
307+
},
308+
},
277309
])("$label is a usage error", async ({ options }) => {
278310
await expect(webhooksVerify(options)).rejects.toMatchObject({
279311
code: ERROR_CODE.USAGE_ERROR,

packages/cli-core/src/commands/webhooks/verify.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,13 @@ export async function webhooksVerify(options: WebhooksVerifyOptions = {}): Promi
162162
);
163163
}
164164

165-
const payload = options.payload
166-
? await readFileOrStdin(options.payload, "--payload")
167-
: fields.payload;
165+
// Nullish-coalesce, not truthiness: an explicit empty `--payload` must reach
166+
// readFileOrStdin (which rejects it as neither @file nor -) instead of
167+
// silently falling through to the --delivery body or an `undefined` HMAC.
168+
const payload =
169+
options.payload !== undefined
170+
? await readFileOrStdin(options.payload, "--payload")
171+
: fields.payload;
168172

169173
const valid = verifyWebhookSignature({
170174
secret: options.secret,

packages/cli-core/src/lib/errors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ export const ERROR_CODE = {
6363
UNKNOWN_EVENT_TYPE: "unknown_event_type",
6464
/** Offline webhook signature verification found no matching entry. */
6565
INVALID_WEBHOOK_SIGNATURE: "invalid_webhook_signature",
66+
/** Endpoint was created but its signing secret could not be fetched. */
67+
WEBHOOK_SECRET_FETCH_FAILED: "webhook_secret_fetch_failed",
6668
} as const;
6769

6870
export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE];

0 commit comments

Comments
 (0)