@multiplier-labs/stepflow 0.3.3 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +9 -0
  2. package/dist/{chunk-GYPNJBDP.js → chunk-D5HW7VEG.js} +269 -48
  3. package/dist/chunk-D5HW7VEG.js.map +1 -0
  4. package/dist/{chunk-KHTUWLVZ.js → chunk-FMPJBK2N.js} +55 -10
  5. package/dist/chunk-FMPJBK2N.js.map +1 -0
  6. package/dist/{chunk-ML35PIHX.js → chunk-JPXPB7U5.js} +52 -2
  7. package/dist/chunk-JPXPB7U5.js.map +1 -0
  8. package/dist/{chunk-4VOKMGP6.js → chunk-JUPZEBR3.js} +519 -522
  9. package/dist/chunk-JUPZEBR3.js.map +1 -0
  10. package/dist/events/index.d.ts +58 -3
  11. package/dist/events/index.js +1 -1
  12. package/dist/{index-Dzr437iW.d.ts → index-Cd0IZ40L.d.ts} +50 -11
  13. package/dist/index.d.ts +2 -12
  14. package/dist/index.js +764 -485
  15. package/dist/index.js.map +1 -1
  16. package/dist/scheduler/index.d.ts +1 -1
  17. package/dist/scheduler/index.js +2 -2
  18. package/dist/storage/index.d.ts +313 -47
  19. package/dist/storage/index.js +4 -2
  20. package/package.json +27 -13
  21. package/.codekin/reports/comments/2026-04-04_comment-assessment.md +0 -179
  22. package/.codekin/reports/complexity/2026-04-03_complexity-report.md +0 -152
  23. package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +0 -94
  24. package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +0 -10
  25. package/.codekin/reports/security/2026-04-03_security-audit.md +0 -208
  26. package/.github/CODEOWNERS +0 -15
  27. package/.github/dependabot.yml +0 -23
  28. package/.github/workflows/ci.yml +0 -38
  29. package/.github/workflows/publish.yml +0 -36
  30. package/.github/workflows/security.yml +0 -29
  31. package/CHANGELOG.md +0 -34
  32. package/SECURITY.md +0 -40
  33. package/dist/chunk-4VOKMGP6.js.map +0 -1
  34. package/dist/chunk-GYPNJBDP.js.map +0 -1
  35. package/dist/chunk-KHTUWLVZ.js.map +0 -1
  36. package/dist/chunk-ML35PIHX.js.map +0 -1
  37. package/docs/github-packages-setup.md +0 -97
  38. package/docs/stepflow-api-reference.md +0 -1661
  39. package/src/core/engine.test.ts +0 -1365
  40. package/src/core/engine.ts +0 -692
  41. package/src/core/index.ts +0 -7
  42. package/src/core/orchestrator.ts +0 -571
  43. package/src/core/types.ts +0 -220
  44. package/src/events/index.ts +0 -8
  45. package/src/events/memory.test.ts +0 -107
  46. package/src/events/memory.ts +0 -92
  47. package/src/events/socketio.test.ts +0 -378
  48. package/src/events/socketio.ts +0 -262
  49. package/src/events/types.ts +0 -118
  50. package/src/events/webhook.test.ts +0 -657
  51. package/src/events/webhook.ts +0 -564
  52. package/src/index.ts +0 -270
  53. package/src/planning/index.ts +0 -55
  54. package/src/planning/planner.ts +0 -576
  55. package/src/planning/planning.test.ts +0 -727
  56. package/src/planning/registry.test.ts +0 -398
  57. package/src/planning/registry.ts +0 -365
  58. package/src/planning/types.ts +0 -566
  59. package/src/scheduler/cron.test.ts +0 -471
  60. package/src/scheduler/cron.ts +0 -414
  61. package/src/scheduler/index.ts +0 -8
  62. package/src/scheduler/postgres-persistence.ts +0 -518
  63. package/src/scheduler/sqlite-persistence.test.ts +0 -235
  64. package/src/scheduler/sqlite-persistence.ts +0 -261
  65. package/src/scheduler/types.ts +0 -73
  66. package/src/storage/index.ts +0 -27
  67. package/src/storage/memory.test.ts +0 -349
  68. package/src/storage/memory.ts +0 -227
  69. package/src/storage/postgres.ts +0 -1354
  70. package/src/storage/sqlite.test.ts +0 -466
  71. package/src/storage/sqlite.ts +0 -627
  72. package/src/storage/types.ts +0 -343
  73. package/src/utils/errors.test.ts +0 -150
  74. package/src/utils/errors.ts +0 -161
  75. package/src/utils/id.ts +0 -21
  76. package/src/utils/index.ts +0 -8
  77. package/src/utils/logger.test.ts +0 -121
  78. package/src/utils/logger.ts +0 -96
  79. package/src/utils/postgres-deps.ts +0 -63
  80. package/src/utils/retry.test.ts +0 -177
  81. package/src/utils/retry.ts +0 -121
  82. package/tsconfig.json +0 -21
  83. package/tsup.config.ts +0 -14
  84. package/vitest.config.ts +0 -9
@@ -1,657 +0,0 @@
1
- import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
- import * as dns from 'node:dns';
3
- import { WebhookEventTransport, WebhookEndpoint } from './webhook';
4
- import type { WorkflowEvent } from './types';
5
-
6
- vi.mock('node:dns', () => ({
7
- promises: {
8
- lookup: vi.fn().mockResolvedValue({ address: '93.184.216.34', family: 4 }),
9
- },
10
- }));
11
-
12
- function createTestEvent(overrides: Partial<WorkflowEvent> = {}): WorkflowEvent {
13
- return {
14
- eventType: 'run.started',
15
- runId: 'run-123',
16
- kind: 'test.workflow',
17
- timestamp: new Date('2024-01-01T12:00:00Z'),
18
- ...overrides,
19
- };
20
- }
21
-
22
- describe('WebhookEventTransport', () => {
23
- let transport: WebhookEventTransport;
24
- let mockFetch: ReturnType<typeof vi.fn>;
25
-
26
- beforeEach(() => {
27
- mockFetch = vi.fn().mockResolvedValue({ ok: true });
28
- transport = new WebhookEventTransport({
29
- fetchFn: mockFetch,
30
- defaultTimeout: 1000,
31
- defaultRetries: 0, // Disable retries for faster tests
32
- });
33
- });
34
-
35
- afterEach(() => {
36
- transport.close();
37
- });
38
-
39
- describe('addEndpoint / removeEndpoint', () => {
40
- it('should add an endpoint', () => {
41
- const endpoint: WebhookEndpoint = {
42
- id: 'test-1',
43
- url: 'https://example.com/webhook',
44
- };
45
-
46
- transport.addEndpoint(endpoint);
47
-
48
- expect(transport.getEndpoints()).toHaveLength(1);
49
- expect(transport.getEndpoints()[0].id).toBe('test-1');
50
- });
51
-
52
- it('should add endpoint with enabled=true by default', () => {
53
- transport.addEndpoint({
54
- id: 'test-1',
55
- url: 'https://example.com/webhook',
56
- });
57
-
58
- expect(transport.getEndpoints()[0].enabled).toBe(true);
59
- });
60
-
61
- it('should remove an endpoint', () => {
62
- transport.addEndpoint({
63
- id: 'test-1',
64
- url: 'https://example.com/webhook',
65
- });
66
-
67
- const removed = transport.removeEndpoint('test-1');
68
-
69
- expect(removed).toBe(true);
70
- expect(transport.getEndpoints()).toHaveLength(0);
71
- });
72
-
73
- it('should return false when removing non-existent endpoint', () => {
74
- const removed = transport.removeEndpoint('nonexistent');
75
- expect(removed).toBe(false);
76
- });
77
- });
78
-
79
- describe('setEndpointEnabled', () => {
80
- it('should enable/disable an endpoint', () => {
81
- transport.addEndpoint({
82
- id: 'test-1',
83
- url: 'https://example.com/webhook',
84
- enabled: true,
85
- });
86
-
87
- transport.setEndpointEnabled('test-1', false);
88
- expect(transport.getEndpoints()[0].enabled).toBe(false);
89
-
90
- transport.setEndpointEnabled('test-1', true);
91
- expect(transport.getEndpoints()[0].enabled).toBe(true);
92
- });
93
-
94
- it('should do nothing for non-existent endpoint', () => {
95
- transport.setEndpointEnabled('nonexistent', false);
96
- // Should not throw
97
- });
98
- });
99
-
100
- describe('emit', () => {
101
- it('should send event to enabled endpoints', async () => {
102
- transport.addEndpoint({
103
- id: 'test-1',
104
- url: 'https://example.com/webhook',
105
- });
106
-
107
- const event = createTestEvent();
108
- transport.emit(event);
109
-
110
- // Wait for async webhook
111
- await new Promise((r) => setTimeout(r, 50));
112
-
113
- expect(mockFetch).toHaveBeenCalledTimes(1);
114
- expect(mockFetch).toHaveBeenCalledWith(
115
- 'https://example.com/webhook',
116
- expect.objectContaining({
117
- method: 'POST',
118
- headers: expect.objectContaining({
119
- 'Content-Type': 'application/json',
120
- }),
121
- })
122
- );
123
- });
124
-
125
- it('should not send to disabled endpoints', async () => {
126
- transport.addEndpoint({
127
- id: 'test-1',
128
- url: 'https://example.com/webhook',
129
- enabled: false,
130
- });
131
-
132
- const event = createTestEvent();
133
- transport.emit(event);
134
-
135
- await new Promise((r) => setTimeout(r, 50));
136
-
137
- expect(mockFetch).not.toHaveBeenCalled();
138
- });
139
-
140
- it('should include custom headers', async () => {
141
- transport.addEndpoint({
142
- id: 'test-1',
143
- url: 'https://example.com/webhook',
144
- headers: { 'X-Custom-Header': 'custom-value' },
145
- });
146
-
147
- transport.emit(createTestEvent());
148
-
149
- await new Promise((r) => setTimeout(r, 50));
150
-
151
- const callArgs = mockFetch.mock.calls[0];
152
- expect(callArgs[1].headers['X-Custom-Header']).toBe('custom-value');
153
- });
154
-
155
- it('should serialize payload correctly', async () => {
156
- transport.addEndpoint({
157
- id: 'test-1',
158
- url: 'https://example.com/webhook',
159
- });
160
-
161
- const event = createTestEvent({
162
- runId: 'run-456',
163
- kind: 'my.workflow',
164
- });
165
- transport.emit(event);
166
-
167
- await new Promise((r) => setTimeout(r, 50));
168
-
169
- const callArgs = mockFetch.mock.calls[0];
170
- const body = JSON.parse(callArgs[1].body);
171
-
172
- expect(body.event.runId).toBe('run-456');
173
- expect(body.event.kind).toBe('my.workflow');
174
- expect(body.event.timestamp).toBe('2024-01-01T12:00:00.000Z');
175
- expect(body.webhookId).toBe('test-1');
176
- expect(body.deliveredAt).toBeDefined();
177
- });
178
- });
179
-
180
- describe('event filtering', () => {
181
- it('should filter by event type', async () => {
182
- transport.addEndpoint({
183
- id: 'test-1',
184
- url: 'https://example.com/webhook',
185
- eventTypes: ['run.completed', 'run.failed'],
186
- });
187
-
188
- // Should be filtered out
189
- transport.emit(createTestEvent({ eventType: 'run.started' }));
190
-
191
- // Should be sent
192
- transport.emit(createTestEvent({ eventType: 'run.completed' }));
193
-
194
- await new Promise((r) => setTimeout(r, 50));
195
-
196
- expect(mockFetch).toHaveBeenCalledTimes(1);
197
- });
198
-
199
- it('should filter by workflow kind', async () => {
200
- transport.addEndpoint({
201
- id: 'test-1',
202
- url: 'https://example.com/webhook',
203
- workflowKinds: ['order.process', 'order.ship'],
204
- });
205
-
206
- // Should be filtered out
207
- transport.emit(createTestEvent({ kind: 'user.notify' }));
208
-
209
- // Should be sent
210
- transport.emit(createTestEvent({ kind: 'order.process' }));
211
-
212
- await new Promise((r) => setTimeout(r, 50));
213
-
214
- expect(mockFetch).toHaveBeenCalledTimes(1);
215
- });
216
-
217
- it('should apply both filters (AND logic)', async () => {
218
- transport.addEndpoint({
219
- id: 'test-1',
220
- url: 'https://example.com/webhook',
221
- eventTypes: ['run.completed'],
222
- workflowKinds: ['order.process'],
223
- });
224
-
225
- // Wrong event type
226
- transport.emit(
227
- createTestEvent({
228
- eventType: 'run.started',
229
- kind: 'order.process',
230
- })
231
- );
232
-
233
- // Wrong workflow kind
234
- transport.emit(
235
- createTestEvent({
236
- eventType: 'run.completed',
237
- kind: 'user.notify',
238
- })
239
- );
240
-
241
- // Both match
242
- transport.emit(
243
- createTestEvent({
244
- eventType: 'run.completed',
245
- kind: 'order.process',
246
- })
247
- );
248
-
249
- await new Promise((r) => setTimeout(r, 50));
250
-
251
- expect(mockFetch).toHaveBeenCalledTimes(1);
252
- });
253
-
254
- it('should send to all endpoints when no filters', async () => {
255
- transport.addEndpoint({
256
- id: 'test-1',
257
- url: 'https://example.com/webhook1',
258
- });
259
- transport.addEndpoint({
260
- id: 'test-2',
261
- url: 'https://example.com/webhook2',
262
- });
263
-
264
- transport.emit(createTestEvent());
265
-
266
- await new Promise((r) => setTimeout(r, 50));
267
-
268
- expect(mockFetch).toHaveBeenCalledTimes(2);
269
- });
270
- });
271
-
272
- describe('HMAC signing', () => {
273
- it('should sign payload when secret is provided', async () => {
274
- transport.addEndpoint({
275
- id: 'test-1',
276
- url: 'https://example.com/webhook',
277
- secret: 'my-secret-key',
278
- });
279
-
280
- transport.emit(createTestEvent());
281
-
282
- await new Promise((r) => setTimeout(r, 50));
283
-
284
- const callArgs = mockFetch.mock.calls[0];
285
- const signature = callArgs[1].headers['X-Webhook-Signature'];
286
-
287
- expect(signature).toBeDefined();
288
- expect(signature).toMatch(/^sha256=[a-f0-9]{64}$/);
289
- });
290
-
291
- it('should not include signature header when no secret', async () => {
292
- transport.addEndpoint({
293
- id: 'test-1',
294
- url: 'https://example.com/webhook',
295
- });
296
-
297
- transport.emit(createTestEvent());
298
-
299
- await new Promise((r) => setTimeout(r, 50));
300
-
301
- const callArgs = mockFetch.mock.calls[0];
302
- expect(callArgs[1].headers['X-Webhook-Signature']).toBeUndefined();
303
- });
304
- });
305
-
306
- describe('retries', () => {
307
- it('should retry on failure', async () => {
308
- mockFetch
309
- .mockRejectedValueOnce(new Error('Network error'))
310
- .mockRejectedValueOnce(new Error('Network error'))
311
- .mockResolvedValueOnce({ ok: true });
312
-
313
- transport = new WebhookEventTransport({
314
- fetchFn: mockFetch,
315
- defaultTimeout: 1000,
316
- defaultRetries: 3,
317
- retryDelay: 10, // Fast retries for tests
318
- });
319
-
320
- transport.addEndpoint({
321
- id: 'test-1',
322
- url: 'https://example.com/webhook',
323
- });
324
-
325
- transport.emit(createTestEvent());
326
-
327
- // Wait for retries
328
- await new Promise((r) => setTimeout(r, 200));
329
-
330
- expect(mockFetch).toHaveBeenCalledTimes(3);
331
- });
332
-
333
- it('should retry on non-ok response', async () => {
334
- mockFetch
335
- .mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Internal Server Error' })
336
- .mockResolvedValueOnce({ ok: true });
337
-
338
- transport = new WebhookEventTransport({
339
- fetchFn: mockFetch,
340
- defaultTimeout: 1000,
341
- defaultRetries: 3,
342
- retryDelay: 10,
343
- });
344
-
345
- transport.addEndpoint({
346
- id: 'test-1',
347
- url: 'https://example.com/webhook',
348
- });
349
-
350
- transport.emit(createTestEvent());
351
-
352
- await new Promise((r) => setTimeout(r, 200));
353
-
354
- expect(mockFetch).toHaveBeenCalledTimes(2);
355
- });
356
- });
357
-
358
- describe('subscribe / subscribeAll', () => {
359
- it('should call local subscribers on emit', () => {
360
- const callback = vi.fn();
361
- transport.subscribe('run-123', callback);
362
-
363
- const event = createTestEvent({ runId: 'run-123' });
364
- transport.emit(event);
365
-
366
- expect(callback).toHaveBeenCalledWith(event);
367
- });
368
-
369
- it('should not call subscriber for different runId', () => {
370
- const callback = vi.fn();
371
- transport.subscribe('run-123', callback);
372
-
373
- const event = createTestEvent({ runId: 'run-456' });
374
- transport.emit(event);
375
-
376
- expect(callback).not.toHaveBeenCalled();
377
- });
378
-
379
- it('should call global subscribers for all events', () => {
380
- const callback = vi.fn();
381
- transport.subscribeAll(callback);
382
-
383
- transport.emit(createTestEvent({ runId: 'run-1' }));
384
- transport.emit(createTestEvent({ runId: 'run-2' }));
385
-
386
- expect(callback).toHaveBeenCalledTimes(2);
387
- });
388
-
389
- it('should unsubscribe correctly', () => {
390
- const callback = vi.fn();
391
- const unsubscribe = transport.subscribe('run-123', callback);
392
-
393
- unsubscribe();
394
-
395
- transport.emit(createTestEvent({ runId: 'run-123' }));
396
- expect(callback).not.toHaveBeenCalled();
397
- });
398
-
399
- it('should handle callback errors gracefully', () => {
400
- const errorCallback = vi.fn().mockImplementation(() => {
401
- throw new Error('Callback error');
402
- });
403
- const normalCallback = vi.fn();
404
-
405
- transport.subscribe('run-123', errorCallback);
406
- transport.subscribe('run-123', normalCallback);
407
-
408
- transport.emit(createTestEvent({ runId: 'run-123' }));
409
-
410
- expect(errorCallback).toHaveBeenCalled();
411
- expect(normalCallback).toHaveBeenCalled();
412
- });
413
- });
414
-
415
- describe('close', () => {
416
- it('should clear all endpoints and subscribers', () => {
417
- transport.addEndpoint({
418
- id: 'test-1',
419
- url: 'https://example.com/webhook',
420
- });
421
-
422
- const callback = vi.fn();
423
- transport.subscribe('run-123', callback);
424
- transport.subscribeAll(vi.fn());
425
-
426
- transport.close();
427
-
428
- expect(transport.getEndpoints()).toHaveLength(0);
429
-
430
- // Callback should not be called after close
431
- transport.emit(createTestEvent({ runId: 'run-123' }));
432
- expect(callback).not.toHaveBeenCalled();
433
- });
434
- });
435
-
436
- describe('SSRF protection - isBlockedHost', () => {
437
- it('should block 10.x.x.x private range', () => {
438
- expect(() =>
439
- transport.addEndpoint({ id: 'ssrf-1', url: 'https://10.0.0.1/hook' })
440
- ).toThrow(/blocked/);
441
- });
442
-
443
- it('should block 172.16-31.x.x private range', () => {
444
- expect(() =>
445
- transport.addEndpoint({ id: 'ssrf-2', url: 'https://172.16.0.1/hook' })
446
- ).toThrow(/blocked/);
447
- expect(() =>
448
- transport.addEndpoint({ id: 'ssrf-3', url: 'https://172.31.255.255/hook' })
449
- ).toThrow(/blocked/);
450
- // 172.32 should NOT be blocked
451
- transport.addEndpoint({ id: 'ssrf-ok', url: 'https://172.32.0.1/hook' });
452
- expect(transport.getEndpoints().find(e => e.id === 'ssrf-ok')).toBeDefined();
453
- });
454
-
455
- it('should block 192.168.x.x private range', () => {
456
- expect(() =>
457
- transport.addEndpoint({ id: 'ssrf-4', url: 'https://192.168.1.1/hook' })
458
- ).toThrow(/blocked/);
459
- });
460
-
461
- it('should block 169.254.x.x link-local range', () => {
462
- expect(() =>
463
- transport.addEndpoint({ id: 'ssrf-5', url: 'https://169.254.1.1/hook' })
464
- ).toThrow(/blocked/);
465
- });
466
-
467
- it('should block 0.0.0.0', () => {
468
- expect(() =>
469
- transport.addEndpoint({ id: 'ssrf-6', url: 'https://0.0.0.0/hook' })
470
- ).toThrow(/blocked/);
471
- });
472
-
473
- it('should block IPv6 link-local (fe80::)', () => {
474
- expect(() =>
475
- transport.addEndpoint({ id: 'ssrf-7', url: 'https://[fe80::1]/hook' })
476
- ).toThrow(/blocked/);
477
- });
478
-
479
- it('should block localhost and 127.x.x.x', () => {
480
- expect(() =>
481
- transport.addEndpoint({ id: 'ssrf-8', url: 'https://localhost/hook' })
482
- ).toThrow(/blocked/);
483
- expect(() =>
484
- transport.addEndpoint({ id: 'ssrf-9', url: 'https://127.0.0.1/hook' })
485
- ).toThrow(/blocked/);
486
- expect(() =>
487
- transport.addEndpoint({ id: 'ssrf-10', url: 'https://127.0.0.2/hook' })
488
- ).toThrow(/blocked/);
489
- });
490
-
491
- it('should block cloud metadata endpoint', () => {
492
- expect(() =>
493
- transport.addEndpoint({ id: 'ssrf-11', url: 'https://169.254.169.254/latest/meta-data' })
494
- ).toThrow(/blocked/);
495
- });
496
- });
497
-
498
- describe('emit subscriber error isolation', () => {
499
- it('should catch subscriber error and still call other subscribers', () => {
500
- const errorCallback = vi.fn().mockImplementation(() => {
501
- throw new Error('Subscriber exploded');
502
- });
503
- const normalCallback = vi.fn();
504
-
505
- transport.subscribeAll(errorCallback);
506
- transport.subscribeAll(normalCallback);
507
-
508
- const event = createTestEvent();
509
- transport.emit(event);
510
-
511
- expect(errorCallback).toHaveBeenCalledWith(event);
512
- expect(normalCallback).toHaveBeenCalledWith(event);
513
- });
514
-
515
- it('should catch run-subscriber error and still call other run-subscribers', () => {
516
- const errorCallback = vi.fn().mockImplementation(() => {
517
- throw new Error('Run subscriber failed');
518
- });
519
- const normalCallback = vi.fn();
520
-
521
- transport.subscribe('run-123', errorCallback);
522
- transport.subscribe('run-123', normalCallback);
523
-
524
- const event = createTestEvent({ runId: 'run-123' });
525
- transport.emit(event);
526
-
527
- expect(errorCallback).toHaveBeenCalled();
528
- expect(normalCallback).toHaveBeenCalled();
529
- });
530
- });
531
-
532
- describe('close clears all state', () => {
533
- it('should clear endpoints, run subscribers, and global subscribers after close', () => {
534
- transport.addEndpoint({ id: 'ep-1', url: 'https://example.com/hook' });
535
- const runCb = vi.fn();
536
- const globalCb = vi.fn();
537
- transport.subscribe('run-123', runCb);
538
- transport.subscribeAll(globalCb);
539
-
540
- transport.close();
541
-
542
- expect(transport.getEndpoints()).toHaveLength(0);
543
-
544
- // Subscribers should no longer receive events
545
- transport.emit(createTestEvent({ runId: 'run-123' }));
546
- expect(runCb).not.toHaveBeenCalled();
547
- expect(globalCb).not.toHaveBeenCalled();
548
- });
549
- });
550
-
551
- describe('constructor with initial endpoints', () => {
552
- it('should register initial endpoints', () => {
553
- transport = new WebhookEventTransport({
554
- fetchFn: mockFetch,
555
- endpoints: [
556
- { id: 'ep-1', url: 'https://example.com/1' },
557
- { id: 'ep-2', url: 'https://example.com/2' },
558
- ],
559
- });
560
-
561
- expect(transport.getEndpoints()).toHaveLength(2);
562
- });
563
- });
564
-
565
- describe('SSRF protection - DNS resolution', () => {
566
- it('should block webhooks when hostname resolves to private IP', async () => {
567
- const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
568
- dnsLookup.mockResolvedValueOnce({ address: '10.0.0.5', family: 4 });
569
-
570
- const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
571
- transport = new WebhookEventTransport({
572
- fetchFn: mockFetch,
573
- defaultRetries: 0,
574
- logger,
575
- });
576
-
577
- transport.addEndpoint({
578
- id: 'ssrf-dns',
579
- url: 'https://evil.example.com/hook',
580
- });
581
-
582
- transport.emit(createTestEvent());
583
-
584
- await new Promise((r) => setTimeout(r, 50));
585
-
586
- expect(mockFetch).not.toHaveBeenCalled();
587
- expect(logger.error).toHaveBeenCalledWith(
588
- expect.stringContaining('ssrf-dns'),
589
- expect.objectContaining({
590
- message: expect.stringContaining('resolves to blocked IP'),
591
- })
592
- );
593
- });
594
-
595
- it('should block webhooks when hostname resolves to loopback', async () => {
596
- const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
597
- dnsLookup.mockResolvedValueOnce({ address: '127.0.0.1', family: 4 });
598
-
599
- const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
600
- transport = new WebhookEventTransport({
601
- fetchFn: mockFetch,
602
- defaultRetries: 0,
603
- logger,
604
- });
605
-
606
- transport.addEndpoint({
607
- id: 'ssrf-loopback',
608
- url: 'https://loopback.example.com/hook',
609
- });
610
-
611
- transport.emit(createTestEvent());
612
-
613
- await new Promise((r) => setTimeout(r, 50));
614
-
615
- expect(mockFetch).not.toHaveBeenCalled();
616
- });
617
-
618
- it('should block webhooks when hostname resolves to cloud metadata IP', async () => {
619
- const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
620
- dnsLookup.mockResolvedValueOnce({ address: '169.254.169.254', family: 4 });
621
-
622
- const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
623
- transport = new WebhookEventTransport({
624
- fetchFn: mockFetch,
625
- defaultRetries: 0,
626
- logger,
627
- });
628
-
629
- transport.addEndpoint({
630
- id: 'ssrf-metadata',
631
- url: 'https://metadata.example.com/hook',
632
- });
633
-
634
- transport.emit(createTestEvent());
635
-
636
- await new Promise((r) => setTimeout(r, 50));
637
-
638
- expect(mockFetch).not.toHaveBeenCalled();
639
- });
640
-
641
- it('should allow webhooks when hostname resolves to public IP', async () => {
642
- const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
643
- dnsLookup.mockResolvedValueOnce({ address: '93.184.216.34', family: 4 });
644
-
645
- transport.addEndpoint({
646
- id: 'public-ok',
647
- url: 'https://example.com/hook',
648
- });
649
-
650
- transport.emit(createTestEvent());
651
-
652
- await new Promise((r) => setTimeout(r, 50));
653
-
654
- expect(mockFetch).toHaveBeenCalledTimes(1);
655
- });
656
- });
657
- });