diff --git a/src/commands/webhooks/listen.ts b/src/commands/webhooks/listen.ts index f71ba161..3dabb5f4 100644 --- a/src/commands/webhooks/listen.ts +++ b/src/commands/webhooks/listen.ts @@ -75,11 +75,33 @@ function statusText(code: number): string { return map[code] ?? ''; } -async function forwardPayload( +const verifyPayload = ( + resend: Resend, + rawBody: string, + headers: Record, + signingSecret: string, +): boolean => { + try { + resend.webhooks.verify({ + payload: rawBody, + headers: { + id: headers['svix-id'] ?? '', + timestamp: headers['svix-timestamp'] ?? '', + signature: headers['svix-signature'] ?? '', + }, + webhookSecret: signingSecret, + }); + return true; + } catch { + return false; + } +}; + +const forwardPayload = async ( forwardTo: string, rawBody: string, headers: Record, -): Promise<{ status: number }> { +): Promise<{ status: number }> => { const forwardHeaders: Record = { 'content-type': 'application/json', }; @@ -97,7 +119,7 @@ async function forwardPayload( body: rawBody, }); return { status: resp.status }; -} +}; function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { @@ -127,6 +149,10 @@ export const listenWebhookCommand = new Command('listen') .description('Listen for webhook events locally during development') .option('--url ', 'Public URL for receiving webhooks (your tunnel URL)') .option('--forward-to ', 'Forward payloads to this local URL') + .option( + '--insecure-forward', + 'Skip signature verification before forwarding (not recommended)', + ) .option('--events ', 'Event types to listen for (default: all)') .option('--port ', 'Local server port', '4318') .addHelpText( @@ -140,7 +166,8 @@ points to the local server port. The CLI will: 2. Register a temporary Resend webhook pointing at --url 3. Display incoming events in the terminal 4. Optionally forward payloads to --forward-to (with original Svix headers) - 5. Delete the temporary webhook on exit (Ctrl+C) + 5. Verify Svix signatures before forwarding (use --insecure-forward to skip) + 6. Delete the temporary webhook on exit (Ctrl+C) Important: your tunnel must forward traffic to the same port as --port (default 4318). For example, if using ngrok: ngrok http 4318`, @@ -221,6 +248,29 @@ For example, if using ngrok: ngrok http 4318`, svixHeaders[h] = Array.isArray(val) ? val[0] : val; } + const shouldVerify = !opts.insecureForward && !!signingSecret; + const verified = shouldVerify + ? verifyPayload(resend, rawBody, svixHeaders, signingSecret) + : true; + + if (!verified) { + if (jsonMode) { + console.log( + JSON.stringify({ + timestamp: new Date().toISOString(), + error: 'signature_verification_failed', + }), + ); + } else { + const ts = pc.dim(`[${timestamp()}]`); + process.stderr.write( + `${ts} ${pc.red('Signature verification failed — request rejected')}\n`, + ); + } + res.writeHead(401).end('Signature verification failed'); + return; + } + const { type, resourceId, detail } = summarizeEvent(body); if (jsonMode) { @@ -307,6 +357,7 @@ For example, if using ngrok: ngrok http 4318`, ); let webhookId: string; + let signingSecret: string; try { const { data, error } = await resend.webhooks.create({ endpoint: url, @@ -324,6 +375,7 @@ For example, if using ngrok: ngrok http 4318`, ); } webhookId = data.id; + signingSecret = data.signing_secret; } catch (err) { spinner.fail('Failed to create webhook'); server.close(); @@ -360,6 +412,12 @@ For example, if using ngrok: ngrok http 4318`, : `http://${opts.forwardTo}`; process.stderr.write(` ${pc.bold('Forward:')} ${fwd}\n`); } + if (opts.insecureForward) { + process.stderr.write( + `\n ${pc.yellow(pc.bold('⚠ --insecure-forward: signature verification is disabled.'))} + ${pc.yellow('Payloads will be forwarded without verifying Svix signatures.')}\n`, + ); + } process.stderr.write( `\nReady! Listening for webhook events. Press Ctrl+C to stop.\n\n`, ); diff --git a/tests/commands/webhooks/listen.test.ts b/tests/commands/webhooks/listen.test.ts new file mode 100644 index 00000000..ff4fa63a --- /dev/null +++ b/tests/commands/webhooks/listen.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { captureTestEnv, setNonInteractive } from '../../helpers'; + +const mockCreate = vi.fn(async () => ({ + data: { + object: 'webhook' as const, + id: 'wh_listen_test', + signing_secret: 'whsec_test_secret', + }, + error: null, +})); + +const mockRemove = vi.fn(async () => ({ + data: { object: 'webhook' as const, id: 'wh_listen_test', deleted: true }, + error: null, +})); + +const mockVerify = vi.fn((_payload: unknown) => ({ + type: 'email.sent', + created_at: '2026-01-01T00:00:00.000Z', + data: { id: 'email_123' }, +})); + +vi.mock('resend', () => ({ + Resend: class MockResend { + constructor(public key: string) {} + webhooks = { + create: mockCreate, + remove: mockRemove, + verify: mockVerify, + }; + }, +})); + +let nextPort = 24900; + +const postJSON = async ( + port: number, + body: Record, + headers: Record = {}, +): Promise<{ status: number; body: string }> => { + const resp = await fetch(`http://localhost:${port}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); + return { status: resp.status, body: await resp.text() }; +}; + +const waitForReady = async (port: number, retries = 40): Promise => { + for (let i = 0; i < retries; i++) { + try { + await fetch(`http://localhost:${port}`, { method: 'GET' }); + await new Promise((r) => setTimeout(r, 50)); + if (mockCreate.mock.calls.length > 0) { + return; + } + } catch { + // noop + } + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error(`Server on port ${port} did not become ready`); +}; + +describe('webhooks listen command', () => { + const restoreEnv = captureTestEnv(); + let logSpy: ReturnType; + let stderrSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + process.env.RESEND_API_KEY = 're_test_key'; + setNonInteractive(); + mockCreate.mockClear(); + mockRemove.mockClear(); + mockVerify.mockClear(); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + }); + + afterEach(() => { + logSpy.mockRestore(); + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + restoreEnv(); + vi.resetModules(); + }); + + const startListener = async (extraArgs: readonly string[] = []) => { + const port = nextPort++; + const { listenWebhookCommand } = await import( + '../../../src/commands/webhooks/listen' + ); + listenWebhookCommand.parseAsync( + [ + '--url', + 'https://tunnel.example.com', + '--port', + String(port), + '--events', + 'email.sent', + ...extraArgs, + ], + { from: 'user' }, + ); + await waitForReady(port); + return port; + }; + + it('rejects requests with invalid signature (returns 401)', async () => { + mockVerify.mockImplementation(() => { + throw new Error('Invalid signature'); + }); + const port = await startListener([ + '--forward-to', + 'localhost:19999/webhook', + ]); + + const result = await postJSON( + port, + { type: 'email.sent', data: { id: 'email_fake' } }, + { + 'svix-id': 'msg_fake', + 'svix-timestamp': '1234567890', + 'svix-signature': 'v1,fakesignature', + }, + ); + + expect(result.status).toBe(401); + expect(result.body).toBe('Signature verification failed'); + }); + + it('accepts requests with valid signature (returns 200)', async () => { + mockVerify.mockReturnValue({ + type: 'email.sent', + created_at: '2026-01-01T00:00:00.000Z', + data: { id: 'email_123' }, + }); + const port = await startListener(); + + const result = await postJSON( + port, + { type: 'email.sent', data: { id: 'email_123' } }, + { + 'svix-id': 'msg_valid', + 'svix-timestamp': '1234567890', + 'svix-signature': 'v1,validsignature', + }, + ); + + expect(result.status).toBe(200); + }); + + it('calls resend.webhooks.verify with correct parameters', async () => { + const port = await startListener(); + + const payload = { type: 'email.sent', data: { id: 'email_123' } }; + await postJSON(port, payload, { + 'svix-id': 'msg_abc', + 'svix-timestamp': '9999999999', + 'svix-signature': 'v1,testsig', + }); + + expect(mockVerify).toHaveBeenCalledTimes(1); + expect(mockVerify).toHaveBeenCalledWith({ + payload: JSON.stringify(payload), + headers: { + id: 'msg_abc', + timestamp: '9999999999', + signature: 'v1,testsig', + }, + webhookSecret: 'whsec_test_secret', + }); + }); + + it('skips verification with --insecure-forward flag', async () => { + mockVerify.mockImplementation(() => { + throw new Error('Invalid signature'); + }); + const port = await startListener(['--insecure-forward']); + + const result = await postJSON( + port, + { type: 'email.sent', data: { id: 'email_123' } }, + { + 'svix-id': 'msg_fake', + 'svix-timestamp': '1234567890', + 'svix-signature': 'v1,badsig', + }, + ); + + expect(result.status).toBe(200); + expect(mockVerify).not.toHaveBeenCalled(); + }); + + it('outputs verification failure in JSON mode', async () => { + mockVerify.mockImplementation(() => { + throw new Error('Invalid signature'); + }); + const port = await startListener(); + + await postJSON( + port, + { type: 'email.sent', data: { id: 'email_fake' } }, + { + 'svix-id': 'msg_fake', + 'svix-timestamp': '1234567890', + 'svix-signature': 'v1,badsig', + }, + ); + + const jsonOutputCalls = logSpy.mock.calls.filter((c) => { + try { + const parsed = JSON.parse(c[0] as string); + return parsed.error === 'signature_verification_failed'; + } catch { + return false; + } + }); + expect(jsonOutputCalls).toHaveLength(1); + }); + + it('captures signing_secret from webhook creation', async () => { + await startListener(); + expect(mockCreate).toHaveBeenCalledTimes(1); + }); + + it('does not forward when verification fails', async () => { + mockVerify.mockImplementation(() => { + throw new Error('Invalid signature'); + }); + const receivedRequests: unknown[] = []; + const { createServer } = await import('node:http'); + const targetPort = nextPort++; + const targetServer = createServer((_req, res) => { + receivedRequests.push(true); + res.writeHead(200).end('OK'); + }); + await new Promise((resolve) => + targetServer.listen(targetPort, resolve), + ); + + try { + const port = await startListener([ + '--forward-to', + `localhost:${targetPort}/webhook`, + ]); + + await postJSON( + port, + { type: 'email.sent', data: { id: 'email_fake' } }, + { + 'svix-id': 'msg_fake', + 'svix-timestamp': '1234567890', + 'svix-signature': 'v1,fakesig', + }, + ); + + expect(receivedRequests).toHaveLength(0); + } finally { + targetServer.close(); + } + }); +});