@multiplier-labs/stepflow 0.3.4 → 0.3.8

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 (109) hide show
  1. package/README.md +9 -0
  2. package/dist/{chunk-5XJBAPXF.js → chunk-4IDPWWGS.js} +15 -4
  3. package/dist/chunk-4IDPWWGS.js.map +1 -0
  4. package/dist/{chunk-U3PCVK6O.js → chunk-T6GAZBTQ.js} +70 -38
  5. package/dist/chunk-T6GAZBTQ.js.map +1 -0
  6. package/dist/{chunk-FMPJBK2N.js → chunk-TVSADUYU.js} +3 -5
  7. package/dist/{chunk-FMPJBK2N.js.map → chunk-TVSADUYU.js.map} +1 -1
  8. package/dist/events/index.d.ts +43 -3
  9. package/dist/events/index.js +7 -3
  10. package/dist/{index-Cd0IZ40L.d.ts → index-CdXDv30T.d.ts} +10 -2
  11. package/dist/index.d.ts +5 -4
  12. package/dist/index.js +159 -46
  13. package/dist/index.js.map +1 -1
  14. package/dist/scheduler/index.d.ts +2 -2
  15. package/dist/scheduler/index.js +1 -1
  16. package/dist/storage/index.d.ts +6 -4
  17. package/dist/storage/index.js +1 -1
  18. package/dist/{types-DSEjiLMj.d.ts → types-BfWe5yP6.d.ts} +2 -0
  19. package/package.json +26 -14
  20. package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +0 -146
  21. package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +0 -74
  22. package/.codekin/reports/comments/2026-04-04_comment-assessment.md +0 -179
  23. package/.codekin/reports/comments/2026-04-11_comment-assessment.md +0 -161
  24. package/.codekin/reports/comments/2026-04-14_comment-assessment.md +0 -34
  25. package/.codekin/reports/complexity/2026-04-03_complexity-report.md +0 -152
  26. package/.codekin/reports/complexity/2026-04-11_complexity-report.md +0 -152
  27. package/.codekin/reports/complexity/2026-04-14_complexity-report.md +0 -34
  28. package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +0 -94
  29. package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +0 -93
  30. package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +0 -10
  31. package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +0 -138
  32. package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +0 -121
  33. package/.codekin/reports/security/2026-04-03_security-audit.md +0 -208
  34. package/.codekin/reports/security/2026-04-11_security-audit.md +0 -297
  35. package/.github/CODEOWNERS +0 -15
  36. package/.github/dependabot.yml +0 -23
  37. package/.github/workflows/ci.yml +0 -38
  38. package/.github/workflows/publish.yml +0 -42
  39. package/.github/workflows/security.yml +0 -29
  40. package/CHANGELOG.md +0 -34
  41. package/SECURITY.md +0 -40
  42. package/dist/chunk-5XJBAPXF.js.map +0 -1
  43. package/dist/chunk-U3PCVK6O.js.map +0 -1
  44. package/docs/github-packages-setup.md +0 -97
  45. package/docs/stepflow-api-reference.md +0 -1663
  46. package/src/core/engine/concurrency.ts +0 -229
  47. package/src/core/engine/lifecycle.ts +0 -176
  48. package/src/core/engine.test.ts +0 -1435
  49. package/src/core/engine.ts +0 -598
  50. package/src/core/index.ts +0 -7
  51. package/src/core/orchestrator/persistence.ts +0 -165
  52. package/src/core/orchestrator/plan-resolution.ts +0 -140
  53. package/src/core/orchestrator/state-transitions.ts +0 -109
  54. package/src/core/orchestrator/step-runner.ts +0 -427
  55. package/src/core/orchestrator.ts +0 -252
  56. package/src/core/types.ts +0 -220
  57. package/src/events/index.ts +0 -8
  58. package/src/events/memory.test.ts +0 -107
  59. package/src/events/memory.ts +0 -92
  60. package/src/events/socketio.test.ts +0 -378
  61. package/src/events/socketio.ts +0 -262
  62. package/src/events/types.ts +0 -118
  63. package/src/events/webhook.test.ts +0 -949
  64. package/src/events/webhook.ts +0 -807
  65. package/src/index.ts +0 -270
  66. package/src/planning/evaluate-condition.test.ts +0 -153
  67. package/src/planning/evaluate-condition.ts +0 -105
  68. package/src/planning/index.ts +0 -55
  69. package/src/planning/planner.ts +0 -494
  70. package/src/planning/planning.test.ts +0 -727
  71. package/src/planning/registry.test.ts +0 -398
  72. package/src/planning/registry.ts +0 -283
  73. package/src/planning/types.ts +0 -566
  74. package/src/scheduler/cron.test.ts +0 -479
  75. package/src/scheduler/cron.ts +0 -438
  76. package/src/scheduler/index.ts +0 -8
  77. package/src/scheduler/postgres-persistence.ts +0 -530
  78. package/src/scheduler/sqlite-persistence.test.ts +0 -235
  79. package/src/scheduler/sqlite-persistence.ts +0 -286
  80. package/src/scheduler/types.ts +0 -73
  81. package/src/storage/index.ts +0 -27
  82. package/src/storage/memory.test.ts +0 -349
  83. package/src/storage/memory.ts +0 -227
  84. package/src/storage/postgres-core.ts +0 -550
  85. package/src/storage/postgres-migration.test.ts +0 -69
  86. package/src/storage/postgres-transaction-pagination.test.ts +0 -107
  87. package/src/storage/postgres.ts +0 -835
  88. package/src/storage/sqlite-core.ts +0 -151
  89. package/src/storage/sqlite.test.ts +0 -642
  90. package/src/storage/sqlite.ts +0 -717
  91. package/src/storage/types.ts +0 -343
  92. package/src/utils/errors.test.ts +0 -150
  93. package/src/utils/errors.ts +0 -161
  94. package/src/utils/id.ts +0 -21
  95. package/src/utils/index.ts +0 -10
  96. package/src/utils/logger.test.ts +0 -121
  97. package/src/utils/logger.ts +0 -96
  98. package/src/utils/pinned-fetch.test.ts +0 -84
  99. package/src/utils/pinned-fetch.ts +0 -118
  100. package/src/utils/postgres-deps.ts +0 -79
  101. package/src/utils/retry.test.ts +0 -177
  102. package/src/utils/retry.ts +0 -121
  103. package/src/utils/safe-json.test.ts +0 -166
  104. package/src/utils/safe-json.ts +0 -107
  105. package/src/utils/webhook-signature.test.ts +0 -111
  106. package/src/utils/webhook-signature.ts +0 -74
  107. package/tsconfig.json +0 -21
  108. package/tsup.config.ts +0 -14
  109. package/vitest.config.ts +0 -9
@@ -1,807 +0,0 @@
1
- /**
2
- * Webhook Event Transport
3
- *
4
- * Posts workflow events to HTTP endpoints via webhooks.
5
- * Supports multiple webhooks with optional filtering by event type.
6
- */
7
-
8
- import { promises as dns } from 'node:dns';
9
- import { isIPv4, isIPv6 } from 'node:net';
10
- import { pinnedFetch } from '../utils/pinned-fetch';
11
- import type { EventTransport, EventCallback, Unsubscribe, WorkflowEvent, WorkflowEventType } from './types';
12
- import type { Logger } from '../core/types';
13
-
14
- /**
15
- * Webhook endpoint configuration.
16
- */
17
- export interface WebhookEndpoint {
18
- /** Unique identifier for this endpoint */
19
- id: string;
20
-
21
- /** URL to POST events to */
22
- url: string;
23
-
24
- /** Optional secret for signing payloads (HMAC-SHA256) */
25
- secret?: string;
26
-
27
- /** Optional filter for specific event types (empty = all events) */
28
- eventTypes?: WorkflowEventType[];
29
-
30
- /** Optional filter for specific workflow kinds (empty = all kinds) */
31
- workflowKinds?: string[];
32
-
33
- /** Optional custom headers to include */
34
- headers?: Record<string, string>;
35
-
36
- /** Whether this endpoint is enabled (default: true) */
37
- enabled?: boolean;
38
-
39
- /** Timeout in milliseconds (default: 5000) */
40
- timeout?: number;
41
-
42
- /** Number of retry attempts on failure (default: 3) */
43
- retries?: number;
44
- }
45
-
46
- /**
47
- * Configuration for WebhookEventTransport.
48
- */
49
- export interface WebhookEventTransportConfig {
50
- /** Initial webhook endpoints */
51
- endpoints?: WebhookEndpoint[];
52
-
53
- /** Default timeout for all webhooks in ms (default: 5000) */
54
- defaultTimeout?: number;
55
-
56
- /** Default retry attempts (default: 3) */
57
- defaultRetries?: number;
58
-
59
- /** Retry delay in ms (default: 1000) */
60
- retryDelay?: number;
61
-
62
- /** Custom fetch function (for testing or custom HTTP clients) */
63
- fetchFn?: typeof fetch;
64
-
65
- /**
66
- * Whether to allow non-HTTPS URLs (default: false).
67
- * Set to true only in development environments.
68
- */
69
- allowInsecureUrls?: boolean;
70
-
71
- /**
72
- * Maximum payload size in bytes (default: 1048576 = 1 MB).
73
- * Payloads exceeding this limit will be rejected.
74
- */
75
- maxPayloadBytes?: number;
76
-
77
- /**
78
- * Maximum concurrent webhook requests across all endpoints (default: 50).
79
- * Requests beyond this limit are queued.
80
- */
81
- maxConcurrentRequests?: number;
82
-
83
- /**
84
- * Maximum number of queued webhook deliveries waiting for a concurrency slot
85
- * (default: 1000, env override: `STEPFLOW_WEBHOOK_MAX_QUEUE_DEPTH`).
86
- *
87
- * When the queue is full, incoming `emit()` deliveries are dropped to
88
- * protect the process from memory exhaustion under burst traffic. Drops
89
- * are logged via `logger.warn` with the endpoint id, current queue depth,
90
- * and a recommended `retryAfterMs`. A value of `0` disables the cap (not
91
- * recommended in production).
92
- */
93
- maxQueueDepth?: number;
94
-
95
- /**
96
- * Suggested back-off in milliseconds reported on dropped deliveries
97
- * (default: 1000). Surfaced in the warning log so external orchestrators
98
- * (sidecars, sweeper jobs) can respect it like an HTTP `Retry-After`.
99
- */
100
- queueDropRetryAfterMs?: number;
101
-
102
- /** Logger for webhook transport errors (default: console-based) */
103
- logger?: import('../core/types').Logger;
104
- }
105
-
106
- /**
107
- * Webhook payload structure sent to external endpoints.
108
- */
109
- export interface WebhookPayload {
110
- /**
111
- * The workflow event with `timestamp` serialized as an ISO 8601 string.
112
- * The `Date` is converted to a string before delivery so that JSON consumers
113
- * receive a portable, timezone-aware representation without needing Date parsing.
114
- */
115
- event: Omit<WorkflowEvent, 'timestamp'> & { timestamp: string };
116
- /** ISO 8601 timestamp of when this payload was delivered. */
117
- deliveredAt: string;
118
- /** ID of the webhook endpoint that received this delivery. */
119
- webhookId: string;
120
- }
121
-
122
- /**
123
- * Webhook-based event transport for integrating with external systems.
124
- *
125
- * @example
126
- * ```typescript
127
- * const webhookTransport = new WebhookEventTransport({
128
- * endpoints: [
129
- * {
130
- * id: 'slack-notifications',
131
- * url: 'https://hooks.slack.com/...',
132
- * eventTypes: ['run.completed', 'run.failed'],
133
- * },
134
- * {
135
- * id: 'analytics',
136
- * url: 'https://api.analytics.com/events',
137
- * secret: process.env.WEBHOOK_SECRET, // Use a cryptographically random secret (≥ 32 bytes)
138
- * },
139
- * ],
140
- * });
141
- *
142
- * const engine = new WorkflowEngine({
143
- * storage,
144
- * events: webhookTransport,
145
- * });
146
- * ```
147
- */
148
- export class WebhookEventTransport implements EventTransport {
149
- private endpoints = new Map<string, WebhookEndpoint>();
150
- private defaultTimeout: number;
151
- private defaultRetries: number;
152
- private retryDelay: number;
153
- private fetchFn: typeof fetch;
154
- /**
155
- * Whether `fetchFn` was caller-supplied. When `false` the default fetch is
156
- * replaced with `pinnedFetch` so the TCP connection can target the IP that
157
- * was just validated (closes the DNS-rebinding TOCTOU window). When `true`
158
- * the custom fetch is used as-is so test injection still works.
159
- */
160
- private hasCustomFetch: boolean;
161
- private allowInsecureUrls: boolean;
162
- private maxPayloadBytes: number;
163
- private maxConcurrentRequests: number;
164
- private maxQueueDepth: number;
165
- private queueDropRetryAfterMs: number;
166
- private activeRequests = 0;
167
- private requestQueue: Array<() => void> = [];
168
- private droppedRequests = 0;
169
- private logger: Logger;
170
-
171
- // DNS resolution cache with TTL — stores `family` so the pin can reuse it
172
- // without a second lookup.
173
- private dnsCache = new Map<string, { address: string; family: 4 | 6; expiresAt: number }>();
174
- private static readonly DNS_CACHE_TTL_MS = 30_000; // 30 seconds
175
- private static readonly DNS_TIMEOUT_MS = 5_000; // 5 seconds
176
-
177
- // Local subscribers for server-side subscriptions
178
- private runSubscribers = new Map<string, Set<EventCallback>>();
179
- private globalSubscribers = new Set<EventCallback>();
180
-
181
- constructor(config: WebhookEventTransportConfig = {}) {
182
- this.defaultTimeout = config.defaultTimeout ?? 5000;
183
- this.defaultRetries = config.defaultRetries ?? 3;
184
- this.retryDelay = config.retryDelay ?? 1000;
185
- this.hasCustomFetch = config.fetchFn !== undefined;
186
- this.fetchFn = config.fetchFn ?? globalThis.fetch;
187
- this.allowInsecureUrls = config.allowInsecureUrls ?? false;
188
- this.maxPayloadBytes = config.maxPayloadBytes ?? 1_048_576; // 1 MB
189
- this.maxConcurrentRequests = config.maxConcurrentRequests ?? 50;
190
- if (this.maxConcurrentRequests <= 0) {
191
- throw new Error('maxConcurrentRequests must be greater than 0');
192
- }
193
- this.maxQueueDepth = resolveMaxQueueDepth(config.maxQueueDepth);
194
- if (this.maxQueueDepth < 0) {
195
- throw new Error('maxQueueDepth must be >= 0 (0 disables the cap)');
196
- }
197
- this.queueDropRetryAfterMs = config.queueDropRetryAfterMs ?? 1000;
198
- this.logger = config.logger ?? {
199
- debug() {},
200
- info() {},
201
- warn() {},
202
- error(message: string, ...args: unknown[]) { console.error(message, ...args); },
203
- };
204
-
205
- if (this.allowInsecureUrls) {
206
- this.logger.warn('WebhookEventTransport: allowInsecureUrls is enabled. Webhook payloads will be sent over HTTP. Do not use this setting in production.');
207
- }
208
-
209
- // Register initial endpoints (validates URLs)
210
- if (config.endpoints) {
211
- for (const endpoint of config.endpoints) {
212
- this.addEndpoint(endpoint);
213
- }
214
- }
215
- }
216
-
217
- /**
218
- * Add a webhook endpoint.
219
- * Validates the URL to prevent SSRF attacks.
220
- * @throws Error if the URL scheme is not allowed
221
- */
222
- addEndpoint(endpoint: WebhookEndpoint): void {
223
- this.validateWebhookUrl(endpoint.url);
224
- this.endpoints.set(endpoint.id, { enabled: true, ...endpoint });
225
- }
226
-
227
- /**
228
- * Remove a webhook endpoint.
229
- */
230
- removeEndpoint(id: string): boolean {
231
- return this.endpoints.delete(id);
232
- }
233
-
234
- /**
235
- * Get all registered endpoints.
236
- */
237
- getEndpoints(): WebhookEndpoint[] {
238
- return Array.from(this.endpoints.values());
239
- }
240
-
241
- /**
242
- * Enable or disable an endpoint.
243
- */
244
- setEndpointEnabled(id: string, enabled: boolean): void {
245
- const endpoint = this.endpoints.get(id);
246
- if (endpoint) {
247
- endpoint.enabled = enabled;
248
- }
249
- }
250
-
251
- /**
252
- * Emit an event to all matching webhook endpoints.
253
- */
254
- emit(event: WorkflowEvent): void {
255
- // Notify local subscribers first (synchronously)
256
- const runSubs = this.runSubscribers.get(event.runId);
257
- if (runSubs) {
258
- for (const callback of runSubs) {
259
- try {
260
- callback(event);
261
- } catch (error) {
262
- this.logger.error('Event callback error:', error);
263
- }
264
- }
265
- }
266
-
267
- for (const callback of this.globalSubscribers) {
268
- try {
269
- callback(event);
270
- } catch (error) {
271
- this.logger.error('Event callback error:', error);
272
- }
273
- }
274
-
275
- // Send to webhooks asynchronously with concurrency control
276
- for (const endpoint of this.endpoints.values()) {
277
- if (!endpoint.enabled) continue;
278
- if (!this.matchesFilter(event, endpoint)) continue;
279
-
280
- const accepted = this.enqueueRequest(
281
- () =>
282
- this.sendWebhook(endpoint, event).catch((error) => {
283
- this.logger.error(`Webhook ${endpoint.id} failed:`, error);
284
- }),
285
- endpoint.id
286
- );
287
-
288
- if (!accepted) {
289
- // Queue overflow — equivalent to a server returning 429 with Retry-After.
290
- // The delivery is dropped to protect the process from memory exhaustion.
291
- this.droppedRequests++;
292
- this.logger.warn(
293
- `Webhook ${endpoint.id} delivery dropped: queue full ` +
294
- `(depth ${this.requestQueue.length}/${this.maxQueueDepth}, ` +
295
- `active ${this.activeRequests}/${this.maxConcurrentRequests}, ` +
296
- `total drops ${this.droppedRequests}, retryAfterMs ${this.queueDropRetryAfterMs})`
297
- );
298
- }
299
- }
300
- }
301
-
302
- /**
303
- * Number of webhook deliveries dropped due to queue overflow since startup.
304
- * Useful for surfacing back-pressure to operators (metrics/health checks).
305
- */
306
- getDroppedRequestCount(): number {
307
- return this.droppedRequests;
308
- }
309
-
310
- /**
311
- * Current depth of the pending request queue.
312
- */
313
- getQueueDepth(): number {
314
- return this.requestQueue.length;
315
- }
316
-
317
- /**
318
- * Subscribe to events for a specific run (server-side).
319
- */
320
- subscribe(runId: string, callback: EventCallback): Unsubscribe {
321
- let subs = this.runSubscribers.get(runId);
322
- if (!subs) {
323
- subs = new Set();
324
- this.runSubscribers.set(runId, subs);
325
- }
326
- subs.add(callback);
327
-
328
- return () => {
329
- subs!.delete(callback);
330
- if (subs!.size === 0) {
331
- this.runSubscribers.delete(runId);
332
- }
333
- };
334
- }
335
-
336
- /**
337
- * Subscribe to all events (server-side).
338
- */
339
- subscribeAll(callback: EventCallback): Unsubscribe {
340
- this.globalSubscribers.add(callback);
341
- return () => {
342
- this.globalSubscribers.delete(callback);
343
- };
344
- }
345
-
346
- /**
347
- * Close the transport.
348
- */
349
- close(): void {
350
- this.runSubscribers.clear();
351
- this.globalSubscribers.clear();
352
- this.endpoints.clear();
353
- }
354
-
355
- /**
356
- * Check if an event matches an endpoint's filters.
357
- */
358
- private matchesFilter(event: WorkflowEvent, endpoint: WebhookEndpoint): boolean {
359
- // Check event type filter
360
- if (endpoint.eventTypes && endpoint.eventTypes.length > 0) {
361
- if (!endpoint.eventTypes.includes(event.eventType)) {
362
- return false;
363
- }
364
- }
365
-
366
- // Check workflow kind filter
367
- if (endpoint.workflowKinds && endpoint.workflowKinds.length > 0) {
368
- if (!endpoint.workflowKinds.includes(event.kind)) {
369
- return false;
370
- }
371
- }
372
-
373
- return true;
374
- }
375
-
376
- /**
377
- * Send a webhook with retry support.
378
- */
379
- private async sendWebhook(endpoint: WebhookEndpoint, event: WorkflowEvent): Promise<void> {
380
- const timeout = endpoint.timeout ?? this.defaultTimeout;
381
- const maxRetries = endpoint.retries ?? this.defaultRetries;
382
-
383
- const payload: WebhookPayload = {
384
- event: {
385
- ...event,
386
- timestamp: event.timestamp.toISOString(),
387
- },
388
- deliveredAt: new Date().toISOString(),
389
- webhookId: endpoint.id,
390
- };
391
-
392
- const body = JSON.stringify(payload);
393
-
394
- // Enforce payload size limit to prevent excessive memory use and network abuse
395
- if (body.length > this.maxPayloadBytes) {
396
- throw new Error(
397
- `Webhook payload exceeds maximum size (${body.length} bytes > ${this.maxPayloadBytes} bytes)`
398
- );
399
- }
400
-
401
- const headers: Record<string, string> = {
402
- 'Content-Type': 'application/json',
403
- ...endpoint.headers,
404
- };
405
-
406
- // Sign payload if secret is provided
407
- if (endpoint.secret) {
408
- const signature = await this.signPayload(body, endpoint.secret);
409
- headers['X-Webhook-Signature'] = signature;
410
- }
411
-
412
- let lastError: Error | undefined;
413
-
414
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
415
- try {
416
- // Resolve the hostname once and validate the resulting IP is not
417
- // private/reserved (SSRF protection). When the default fetch is used,
418
- // the resolved IP is then pinned for the connection so a hostile DNS
419
- // server cannot rebind the hostname to a different address between
420
- // validation and connect (M1: rebinding TOCTOU). The lookup runs
421
- // inside the retry loop so DNS failures are retried along with
422
- // network errors.
423
- const pinned = await this.validateResolvedHost(endpoint.url);
424
-
425
- const controller = new AbortController();
426
- const timeoutId = setTimeout(() => controller.abort(), timeout);
427
-
428
- const response = this.hasCustomFetch || !pinned
429
- ? await this.fetchFn(endpoint.url, {
430
- method: 'POST',
431
- headers,
432
- body,
433
- signal: controller.signal,
434
- })
435
- : await pinnedFetch(
436
- endpoint.url,
437
- { method: 'POST', headers, body, signal: controller.signal },
438
- pinned.address,
439
- pinned.family,
440
- );
441
-
442
- clearTimeout(timeoutId);
443
-
444
- if (response.ok) {
445
- return; // Success
446
- }
447
-
448
- lastError = new Error(`Webhook returned ${response.status}: ${response.statusText}`);
449
- } catch (error) {
450
- lastError = error instanceof Error ? error : new Error(String(error));
451
- }
452
-
453
- // Wait before retrying (except on last attempt)
454
- if (attempt < maxRetries) {
455
- await this.sleep(this.retryDelay * Math.pow(2, attempt));
456
- }
457
- }
458
-
459
- throw lastError;
460
- }
461
-
462
- /**
463
- * Sign a payload using HMAC-SHA256.
464
- */
465
- private async signPayload(payload: string, secret: string): Promise<string> {
466
- // Use Web Crypto API for HMAC-SHA256
467
- const encoder = new TextEncoder();
468
- const keyData = encoder.encode(secret);
469
- const data = encoder.encode(payload);
470
-
471
- const key = await crypto.subtle.importKey(
472
- 'raw',
473
- keyData,
474
- { name: 'HMAC', hash: 'SHA-256' },
475
- false,
476
- ['sign']
477
- );
478
-
479
- const signature = await crypto.subtle.sign('HMAC', key, data);
480
- const hashArray = Array.from(new Uint8Array(signature));
481
- const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
482
-
483
- return `sha256=${hashHex}`;
484
- }
485
-
486
- /**
487
- * Sleep helper.
488
- */
489
- private sleep(ms: number): Promise<void> {
490
- return new Promise(resolve => setTimeout(resolve, ms));
491
- }
492
-
493
- /**
494
- * Resolve a webhook URL's hostname exactly once, verify the resolved IP is
495
- * not private/reserved, and return the address + family so the caller can
496
- * pin the connection to it.
497
- *
498
- * Returns `null` for raw-IP URLs (already validated by `validateWebhookUrl`).
499
- *
500
- * Pinning the resolved IP closes the DNS rebinding TOCTOU window (audit M1):
501
- * with the original implementation `fetch()` re-resolved the hostname,
502
- * giving a hostile DNS server a chance to flip the answer to an internal
503
- * address between SSRF validation and connect.
504
- *
505
- * Uses a short-TTL cache and per-call timeout to avoid blocking the event loop.
506
- */
507
- private async validateResolvedHost(
508
- url: string,
509
- ): Promise<{ address: string; family: 4 | 6 } | null> {
510
- const parsed = new URL(url);
511
- const hostname = parsed.hostname.toLowerCase();
512
-
513
- // Skip DNS resolution for raw IP literals — already checked by isBlockedHost in validateWebhookUrl.
514
- // Node's URL parser wraps IPv6 literals in brackets (e.g., "[2001:db8::1]").
515
- const stripped = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname;
516
- if (isIPv4(stripped) || isIPv6(stripped)) {
517
- return null;
518
- }
519
-
520
- // Check cache first
521
- const cached = this.dnsCache.get(hostname);
522
- if (cached && cached.expiresAt > Date.now()) {
523
- if (isBlockedIp(cached.address)) {
524
- throw new Error(
525
- `Webhook URL hostname "${hostname}" resolves to blocked IP ${cached.address}`
526
- );
527
- }
528
- return { address: cached.address, family: cached.family };
529
- }
530
-
531
- try {
532
- const ac = new AbortController();
533
- const timeoutId = setTimeout(() => ac.abort(), WebhookEventTransport.DNS_TIMEOUT_MS);
534
-
535
- let address: string;
536
- let family: 4 | 6;
537
- try {
538
- const result = await dns.lookup(hostname, { signal: ac.signal } as any);
539
- address = result.address;
540
- family = (result.family === 6 ? 6 : 4) as 4 | 6;
541
- } finally {
542
- clearTimeout(timeoutId);
543
- }
544
-
545
- // Cache the result
546
- this.dnsCache.set(hostname, {
547
- address,
548
- family,
549
- expiresAt: Date.now() + WebhookEventTransport.DNS_CACHE_TTL_MS,
550
- });
551
-
552
- if (isBlockedIp(address)) {
553
- throw new Error(
554
- `Webhook URL hostname "${hostname}" resolves to blocked IP ${address}`
555
- );
556
- }
557
-
558
- return { address, family };
559
- } catch (error) {
560
- if (error instanceof Error && error.message.includes('resolves to blocked IP')) {
561
- throw error;
562
- }
563
- if (error instanceof Error && error.name === 'AbortError') {
564
- throw new Error(`DNS lookup for webhook URL hostname "${hostname}" timed out`);
565
- }
566
- throw new Error(`Failed to resolve webhook URL hostname "${hostname}": ${error}`);
567
- }
568
- }
569
-
570
- /**
571
- * Validate a webhook URL to prevent SSRF attacks.
572
- * Blocks non-HTTPS schemes (unless allowInsecureUrls is set),
573
- * and rejects private/reserved IP ranges and cloud metadata endpoints.
574
- */
575
- private validateWebhookUrl(url: string): void {
576
- let parsed: URL;
577
- try {
578
- parsed = new URL(url);
579
- } catch {
580
- throw new Error(`Invalid webhook URL: ${url}`);
581
- }
582
-
583
- // Scheme check
584
- if (!this.allowInsecureUrls && parsed.protocol !== 'https:') {
585
- throw new Error(
586
- `Webhook URL must use HTTPS (got ${parsed.protocol}). Set allowInsecureUrls for development.`
587
- );
588
- }
589
-
590
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
591
- throw new Error(`Webhook URL must use HTTP(S) scheme (got ${parsed.protocol})`);
592
- }
593
-
594
- // Block dangerous hostnames
595
- const hostname = parsed.hostname.toLowerCase();
596
- if (isBlockedHost(hostname)) {
597
- throw new Error(`Webhook URL hostname is blocked (private/reserved): ${hostname}`);
598
- }
599
- }
600
-
601
- /**
602
- * Enqueue a webhook request with concurrency limiting and a queue-depth cap.
603
- * Returns `true` if the request was accepted (executed or queued), or
604
- * `false` if the queue is full and the request was rejected. The caller is
605
- * responsible for logging the drop and incrementing any drop metrics, since
606
- * it has the endpoint context.
607
- */
608
- private enqueueRequest(fn: () => Promise<void>, _endpointId: string): boolean {
609
- const execute = () => {
610
- this.activeRequests++;
611
- fn().finally(() => {
612
- this.activeRequests--;
613
- const next = this.requestQueue.shift();
614
- if (next) next();
615
- });
616
- };
617
-
618
- if (this.activeRequests < this.maxConcurrentRequests) {
619
- execute();
620
- return true;
621
- }
622
-
623
- if (this.maxQueueDepth > 0 && this.requestQueue.length >= this.maxQueueDepth) {
624
- return false;
625
- }
626
-
627
- this.requestQueue.push(execute);
628
- return true;
629
- }
630
- }
631
-
632
- /**
633
- * Resolve the effective `maxQueueDepth` from explicit config and the
634
- * `STEPFLOW_WEBHOOK_MAX_QUEUE_DEPTH` environment variable.
635
- *
636
- * Precedence: explicit config value > env var > default (1000).
637
- */
638
- function resolveMaxQueueDepth(configured: number | undefined): number {
639
- if (typeof configured === 'number') return configured;
640
- const env = typeof process !== 'undefined' ? process.env?.STEPFLOW_WEBHOOK_MAX_QUEUE_DEPTH : undefined;
641
- if (env !== undefined && env !== '') {
642
- const parsed = Number(env);
643
- if (Number.isFinite(parsed) && parsed >= 0) return parsed;
644
- }
645
- return 1000;
646
- }
647
-
648
- /**
649
- * Check if a hostname (as it appears in a URL) is blocked.
650
- * Handles bare hostnames, IPv4 literals, and bracketed IPv6 literals.
651
- */
652
- function isBlockedHost(hostname: string): boolean {
653
- if (hostname === 'localhost') return true;
654
-
655
- // IPv6 literals are wrapped in brackets by Node's URL parser, e.g. "[::1]".
656
- if (hostname.startsWith('[') && hostname.endsWith(']')) {
657
- const bare = hostname.slice(1, -1);
658
- return isIPv6(bare) ? isBlockedIPv6(bare) : true;
659
- }
660
-
661
- // Some callers (or legacy code paths) may pass an unwrapped IPv6 literal.
662
- if (isIPv6(hostname)) {
663
- return isBlockedIPv6(hostname);
664
- }
665
-
666
- if (isIPv4(hostname)) {
667
- return isBlockedIPv4(hostname);
668
- }
669
-
670
- return false;
671
- }
672
-
673
- /**
674
- * Check if a resolved IP address falls within a private/reserved range.
675
- * Used for runtime DNS resolution validation to prevent SSRF via DNS rebinding.
676
- */
677
- function isBlockedIp(ip: string): boolean {
678
- if (isIPv4(ip)) return isBlockedIPv4(ip);
679
- if (isIPv6(ip)) return isBlockedIPv6(ip);
680
- return false;
681
- }
682
-
683
- /**
684
- * Block IPv4 addresses in private, loopback, link-local, and cloud-metadata ranges.
685
- */
686
- function isBlockedIPv4(ip: string): boolean {
687
- const match = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
688
- if (!match) return false;
689
- const [, a, b] = match.map(Number);
690
- return isBlockedIPv4Octets(a, b);
691
- }
692
-
693
- function isBlockedIPv4Octets(a: number, b: number): boolean {
694
- // 0.0.0.0/8 — "this network"
695
- if (a === 0) return true;
696
- // 10.0.0.0/8 — RFC 1918 private
697
- if (a === 10) return true;
698
- // 100.64.0.0/10 — CGNAT
699
- if (a === 100 && b >= 64 && b <= 127) return true;
700
- // 127.0.0.0/8 — loopback
701
- if (a === 127) return true;
702
- // 169.254.0.0/16 — link-local (covers cloud metadata 169.254.169.254)
703
- if (a === 169 && b === 254) return true;
704
- // 172.16.0.0/12 — RFC 1918 private
705
- if (a === 172 && b >= 16 && b <= 31) return true;
706
- // 192.168.0.0/16 — RFC 1918 private
707
- if (a === 192 && b === 168) return true;
708
- // 224.0.0.0/4 multicast and 240.0.0.0/4 reserved (incl. 255.255.255.255 broadcast)
709
- if (a >= 224) return true;
710
- return false;
711
- }
712
-
713
- /**
714
- * Block IPv6 addresses in reserved, private, loopback, link-local, IPv4-mapped,
715
- * IPv4-translated, 6to4, and Teredo ranges. For mapped/embedded IPv4 forms,
716
- * the embedded IPv4 is decoded and rechecked against the IPv4 blocklist.
717
- */
718
- function isBlockedIPv6(ip: string): boolean {
719
- const groups = expandIPv6(ip);
720
- if (!groups) return false;
721
-
722
- // ::1 loopback and :: unspecified
723
- const allButLastZero = groups.slice(0, 7).every(g => g === 0);
724
- if (allButLastZero && groups[7] === 1) return true;
725
- if (groups.every(g => g === 0)) return true;
726
-
727
- // ::ffff:0:0/96 — IPv4-mapped IPv6 (decode embedded IPv4)
728
- if (groups.slice(0, 5).every(g => g === 0) && groups[5] === 0xffff) {
729
- return isBlockedIPv4Octets((groups[6] >> 8) & 0xff, groups[6] & 0xff);
730
- }
731
-
732
- // 64:ff9b::/96 — IPv4/IPv6 translation (RFC 6052) and 64:ff9b:1::/48 (RFC 8215)
733
- if (groups[0] === 0x64 && groups[1] === 0xff9b && groups.slice(2, 6).every(g => g === 0)) {
734
- return isBlockedIPv4Octets((groups[6] >> 8) & 0xff, groups[6] & 0xff);
735
- }
736
-
737
- // 2002::/16 — 6to4 (next 32 bits are the embedded IPv4)
738
- if (groups[0] === 0x2002) {
739
- return isBlockedIPv4Octets((groups[1] >> 8) & 0xff, groups[1] & 0xff);
740
- }
741
-
742
- // 2001::/32 — Teredo. Block conservatively; embedded server/client v4 fields
743
- // can mask private destinations and Teredo isn't a normal webhook target.
744
- if (groups[0] === 0x2001 && groups[1] === 0) return true;
745
-
746
- // 100::/64 — RFC 6666 Discard prefix
747
- if (groups[0] === 0x0100 && groups.slice(1, 4).every(g => g === 0)) return true;
748
-
749
- // fc00::/7 — Unique Local Addresses (fc00:: through fdff::)
750
- if ((groups[0] & 0xfe00) === 0xfc00) return true;
751
-
752
- // fe80::/10 — Link-local (fe80:: through febf::)
753
- if ((groups[0] & 0xffc0) === 0xfe80) return true;
754
-
755
- // ff00::/8 — Multicast
756
- if ((groups[0] & 0xff00) === 0xff00) return true;
757
-
758
- return false;
759
- }
760
-
761
- /**
762
- * Expand an IPv6 string into an array of 8 numeric hextets (0–0xffff).
763
- * Handles "::" abbreviation, embedded IPv4 dotted-quad, and zone IDs.
764
- * Returns null if the input is not a valid IPv6 address.
765
- */
766
- function expandIPv6(addr: string): number[] | null {
767
- // Strip zone identifier (e.g., "fe80::1%eth0")
768
- const noZone = addr.split('%')[0];
769
- if (!isIPv6(noZone)) return null;
770
-
771
- let normalized = noZone.toLowerCase();
772
-
773
- // Convert trailing dotted-quad (e.g., "::ffff:192.168.0.1") into two hextets.
774
- const v4Tail = normalized.match(/^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
775
- if (v4Tail) {
776
- const a = parseInt(v4Tail[2], 10);
777
- const b = parseInt(v4Tail[3], 10);
778
- const c = parseInt(v4Tail[4], 10);
779
- const d = parseInt(v4Tail[5], 10);
780
- if ([a, b, c, d].some(n => n > 255)) return null;
781
- const hi = ((a << 8) | b).toString(16);
782
- const lo = ((c << 8) | d).toString(16);
783
- normalized = `${v4Tail[1]}${hi}:${lo}`;
784
- }
785
-
786
- const halves = normalized.split('::');
787
- let parts: string[];
788
- if (halves.length === 1) {
789
- parts = halves[0].split(':');
790
- if (parts.length !== 8) return null;
791
- } else if (halves.length === 2) {
792
- const head = halves[0] ? halves[0].split(':') : [];
793
- const tail = halves[1] ? halves[1].split(':') : [];
794
- const missing = 8 - head.length - tail.length;
795
- if (missing < 0) return null;
796
- parts = [...head, ...Array(missing).fill('0'), ...tail];
797
- } else {
798
- return null;
799
- }
800
-
801
- const groups: number[] = [];
802
- for (const part of parts) {
803
- if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
804
- groups.push(parseInt(part, 16));
805
- }
806
- return groups;
807
- }