@multiplier-labs/stepflow 0.3.3 → 0.3.4

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 (69) hide show
  1. package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
  2. package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
  3. package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
  4. package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
  5. package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
  6. package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
  7. package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
  8. package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
  9. package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
  10. package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
  11. package/.github/workflows/publish.yml +6 -0
  12. package/dist/{chunk-4VOKMGP6.js → chunk-5XJBAPXF.js} +517 -522
  13. package/dist/chunk-5XJBAPXF.js.map +1 -0
  14. package/dist/{chunk-KHTUWLVZ.js → chunk-FMPJBK2N.js} +55 -10
  15. package/dist/chunk-FMPJBK2N.js.map +1 -0
  16. package/dist/{chunk-ML35PIHX.js → chunk-JPXPB7U5.js} +52 -2
  17. package/dist/chunk-JPXPB7U5.js.map +1 -0
  18. package/dist/{chunk-GYPNJBDP.js → chunk-U3PCVK6O.js} +266 -47
  19. package/dist/chunk-U3PCVK6O.js.map +1 -0
  20. package/dist/events/index.d.ts +56 -3
  21. package/dist/events/index.js +1 -1
  22. package/dist/{index-Dzr437iW.d.ts → index-Cd0IZ40L.d.ts} +50 -11
  23. package/dist/index.d.ts +2 -12
  24. package/dist/index.js +764 -485
  25. package/dist/index.js.map +1 -1
  26. package/dist/scheduler/index.d.ts +1 -1
  27. package/dist/scheduler/index.js +2 -2
  28. package/dist/storage/index.d.ts +313 -47
  29. package/dist/storage/index.js +4 -2
  30. package/docs/stepflow-api-reference.md +3 -1
  31. package/package.json +7 -4
  32. package/src/core/engine/concurrency.ts +229 -0
  33. package/src/core/engine/lifecycle.ts +176 -0
  34. package/src/core/engine.test.ts +70 -0
  35. package/src/core/engine.ts +91 -185
  36. package/src/core/orchestrator/persistence.ts +165 -0
  37. package/src/core/orchestrator/plan-resolution.ts +140 -0
  38. package/src/core/orchestrator/state-transitions.ts +109 -0
  39. package/src/core/orchestrator/step-runner.ts +427 -0
  40. package/src/core/orchestrator.ts +40 -359
  41. package/src/events/webhook.test.ts +292 -0
  42. package/src/events/webhook.ts +320 -77
  43. package/src/planning/evaluate-condition.test.ts +153 -0
  44. package/src/planning/evaluate-condition.ts +105 -0
  45. package/src/planning/planner.ts +1 -83
  46. package/src/planning/registry.ts +3 -85
  47. package/src/scheduler/cron.test.ts +9 -1
  48. package/src/scheduler/cron.ts +26 -2
  49. package/src/scheduler/postgres-persistence.ts +27 -15
  50. package/src/scheduler/sqlite-persistence.ts +28 -3
  51. package/src/storage/postgres-core.ts +550 -0
  52. package/src/storage/postgres-migration.test.ts +69 -0
  53. package/src/storage/postgres-transaction-pagination.test.ts +107 -0
  54. package/src/storage/postgres.ts +171 -690
  55. package/src/storage/sqlite-core.ts +151 -0
  56. package/src/storage/sqlite.test.ts +177 -1
  57. package/src/storage/sqlite.ts +183 -93
  58. package/src/utils/index.ts +2 -0
  59. package/src/utils/pinned-fetch.test.ts +84 -0
  60. package/src/utils/pinned-fetch.ts +118 -0
  61. package/src/utils/postgres-deps.ts +16 -0
  62. package/src/utils/safe-json.test.ts +166 -0
  63. package/src/utils/safe-json.ts +107 -0
  64. package/src/utils/webhook-signature.test.ts +111 -0
  65. package/src/utils/webhook-signature.ts +74 -0
  66. package/dist/chunk-4VOKMGP6.js.map +0 -1
  67. package/dist/chunk-GYPNJBDP.js.map +0 -1
  68. package/dist/chunk-KHTUWLVZ.js.map +0 -1
  69. package/dist/chunk-ML35PIHX.js.map +0 -1
@@ -6,6 +6,8 @@
6
6
  */
7
7
 
8
8
  import { promises as dns } from 'node:dns';
9
+ import { isIPv4, isIPv6 } from 'node:net';
10
+ import { pinnedFetch } from '../utils/pinned-fetch';
9
11
  import type { EventTransport, EventCallback, Unsubscribe, WorkflowEvent, WorkflowEventType } from './types';
10
12
  import type { Logger } from '../core/types';
11
13
 
@@ -78,6 +80,25 @@ export interface WebhookEventTransportConfig {
78
80
  */
79
81
  maxConcurrentRequests?: number;
80
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
+
81
102
  /** Logger for webhook transport errors (default: console-based) */
82
103
  logger?: import('../core/types').Logger;
83
104
  }
@@ -130,13 +151,29 @@ export class WebhookEventTransport implements EventTransport {
130
151
  private defaultRetries: number;
131
152
  private retryDelay: number;
132
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;
133
161
  private allowInsecureUrls: boolean;
134
162
  private maxPayloadBytes: number;
135
163
  private maxConcurrentRequests: number;
164
+ private maxQueueDepth: number;
165
+ private queueDropRetryAfterMs: number;
136
166
  private activeRequests = 0;
137
167
  private requestQueue: Array<() => void> = [];
168
+ private droppedRequests = 0;
138
169
  private logger: Logger;
139
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
+
140
177
  // Local subscribers for server-side subscriptions
141
178
  private runSubscribers = new Map<string, Set<EventCallback>>();
142
179
  private globalSubscribers = new Set<EventCallback>();
@@ -145,6 +182,7 @@ export class WebhookEventTransport implements EventTransport {
145
182
  this.defaultTimeout = config.defaultTimeout ?? 5000;
146
183
  this.defaultRetries = config.defaultRetries ?? 3;
147
184
  this.retryDelay = config.retryDelay ?? 1000;
185
+ this.hasCustomFetch = config.fetchFn !== undefined;
148
186
  this.fetchFn = config.fetchFn ?? globalThis.fetch;
149
187
  this.allowInsecureUrls = config.allowInsecureUrls ?? false;
150
188
  this.maxPayloadBytes = config.maxPayloadBytes ?? 1_048_576; // 1 MB
@@ -152,6 +190,11 @@ export class WebhookEventTransport implements EventTransport {
152
190
  if (this.maxConcurrentRequests <= 0) {
153
191
  throw new Error('maxConcurrentRequests must be greater than 0');
154
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;
155
198
  this.logger = config.logger ?? {
156
199
  debug() {},
157
200
  info() {},
@@ -234,14 +277,43 @@ export class WebhookEventTransport implements EventTransport {
234
277
  if (!endpoint.enabled) continue;
235
278
  if (!this.matchesFilter(event, endpoint)) continue;
236
279
 
237
- this.enqueueRequest(() =>
238
- this.sendWebhook(endpoint, event).catch((error) => {
239
- this.logger.error(`Webhook ${endpoint.id} failed:`, error);
240
- })
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
241
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
+ }
242
299
  }
243
300
  }
244
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
+
245
317
  /**
246
318
  * Subscribe to events for a specific run (server-side).
247
319
  */
@@ -337,24 +409,35 @@ export class WebhookEventTransport implements EventTransport {
337
409
  headers['X-Webhook-Signature'] = signature;
338
410
  }
339
411
 
340
- // Resolve hostname and validate the resolved IP is not private/reserved (SSRF protection).
341
- // This prevents DNS rebinding attacks where a hostname initially resolves to a public IP
342
- // but later resolves to a private IP.
343
- await this.validateResolvedHost(endpoint.url);
344
-
345
412
  let lastError: Error | undefined;
346
413
 
347
414
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
348
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
+
349
425
  const controller = new AbortController();
350
426
  const timeoutId = setTimeout(() => controller.abort(), timeout);
351
427
 
352
- const response = await this.fetchFn(endpoint.url, {
353
- method: 'POST',
354
- headers,
355
- body,
356
- signal: controller.signal,
357
- });
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
+ );
358
441
 
359
442
  clearTimeout(timeoutId);
360
443
 
@@ -408,29 +491,78 @@ export class WebhookEventTransport implements EventTransport {
408
491
  }
409
492
 
410
493
  /**
411
- * Resolve the hostname from a URL and verify the resolved IP is not private/reserved.
412
- * Prevents DNS rebinding attacks where a public hostname resolves to a private IP at send time.
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.
413
506
  */
414
- private async validateResolvedHost(url: string): Promise<void> {
507
+ private async validateResolvedHost(
508
+ url: string,
509
+ ): Promise<{ address: string; family: 4 | 6 } | null> {
415
510
  const parsed = new URL(url);
416
511
  const hostname = parsed.hostname.toLowerCase();
417
512
 
418
- // Skip DNS resolution for raw IP addresses — already checked by isBlockedHost in validateWebhookUrl
419
- if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
420
- return;
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 };
421
529
  }
422
530
 
423
531
  try {
424
- const { address } = await dns.lookup(hostname);
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
+
425
552
  if (isBlockedIp(address)) {
426
553
  throw new Error(
427
554
  `Webhook URL hostname "${hostname}" resolves to blocked IP ${address}`
428
555
  );
429
556
  }
557
+
558
+ return { address, family };
430
559
  } catch (error) {
431
560
  if (error instanceof Error && error.message.includes('resolves to blocked IP')) {
432
561
  throw error;
433
562
  }
563
+ if (error instanceof Error && error.name === 'AbortError') {
564
+ throw new Error(`DNS lookup for webhook URL hostname "${hostname}" timed out`);
565
+ }
434
566
  throw new Error(`Failed to resolve webhook URL hostname "${hostname}": ${error}`);
435
567
  }
436
568
  }
@@ -467,9 +599,13 @@ export class WebhookEventTransport implements EventTransport {
467
599
  }
468
600
 
469
601
  /**
470
- * Enqueue a webhook request with concurrency limiting.
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.
471
607
  */
472
- private enqueueRequest(fn: () => Promise<void>): void {
608
+ private enqueueRequest(fn: () => Promise<void>, _endpointId: string): boolean {
473
609
  const execute = () => {
474
610
  this.activeRequests++;
475
611
  fn().finally(() => {
@@ -481,53 +617,54 @@ export class WebhookEventTransport implements EventTransport {
481
617
 
482
618
  if (this.activeRequests < this.maxConcurrentRequests) {
483
619
  execute();
484
- } else {
485
- this.requestQueue.push(execute);
620
+ return true;
621
+ }
622
+
623
+ if (this.maxQueueDepth > 0 && this.requestQueue.length >= this.maxQueueDepth) {
624
+ return false;
486
625
  }
626
+
627
+ this.requestQueue.push(execute);
628
+ return true;
487
629
  }
488
630
  }
489
631
 
490
632
  /**
491
- * Check if a hostname resolves to a blocked (private/reserved) address.
492
- * Blocks loopback, RFC 1918 private ranges, link-local, and cloud metadata IPs.
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).
493
637
  */
494
- function isBlockedHost(hostname: string): boolean {
495
- // Loopback (Node's URL parser wraps IPv6 in brackets, e.g. "[::1]")
496
- if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]') {
497
- return true;
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;
498
644
  }
645
+ return 1000;
646
+ }
499
647
 
500
- // Block all IPv6 address literals (wrapped in brackets by URL parser)
501
- if (hostname.startsWith('[')) {
502
- return true;
503
- }
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;
504
654
 
505
- // Cloud metadata endpoint
506
- if (hostname === '169.254.169.254') {
507
- return true;
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;
508
659
  }
509
660
 
510
- // Check for IP addresses in private/reserved ranges
511
- const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
512
- if (ipv4Match) {
513
- const [, a, b] = ipv4Match.map(Number);
514
- // 10.0.0.0/8
515
- if (a === 10) return true;
516
- // 172.16.0.0/12
517
- if (a === 172 && b >= 16 && b <= 31) return true;
518
- // 192.168.0.0/16
519
- if (a === 192 && b === 168) return true;
520
- // 127.0.0.0/8
521
- if (a === 127) return true;
522
- // 169.254.0.0/16 (link-local)
523
- if (a === 169 && b === 254) return true;
524
- // 0.0.0.0
525
- if (a === 0 && b === 0) return true;
661
+ // Some callers (or legacy code paths) may pass an unwrapped IPv6 literal.
662
+ if (isIPv6(hostname)) {
663
+ return isBlockedIPv6(hostname);
526
664
  }
527
665
 
528
- // IPv6 link-local (fe80::)
529
- if (hostname.startsWith('fe80:') || hostname.startsWith('[fe80:')) {
530
- return true;
666
+ if (isIPv4(hostname)) {
667
+ return isBlockedIPv4(hostname);
531
668
  }
532
669
 
533
670
  return false;
@@ -538,27 +675,133 @@ function isBlockedHost(hostname: string): boolean {
538
675
  * Used for runtime DNS resolution validation to prevent SSRF via DNS rebinding.
539
676
  */
540
677
  function isBlockedIp(ip: string): boolean {
541
- const ipv4Match = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
542
- if (ipv4Match) {
543
- const [, a, b] = ipv4Match.map(Number);
544
- // 127.0.0.0/8 (loopback)
545
- if (a === 127) return true;
546
- // 10.0.0.0/8
547
- if (a === 10) return true;
548
- // 172.16.0.0/12
549
- if (a === 172 && b >= 16 && b <= 31) return true;
550
- // 192.168.0.0/16
551
- if (a === 192 && b === 168) return true;
552
- // 169.254.0.0/16 (link-local, including cloud metadata 169.254.169.254)
553
- if (a === 169 && b === 254) return true;
554
- // 0.0.0.0
555
- if (a === 0 && b === 0) return true;
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);
556
735
  }
557
736
 
558
- // IPv6 loopback
559
- if (ip === '::1') return true;
560
- // IPv6 link-local
561
- if (ip.startsWith('fe80:')) return true;
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;
562
757
 
563
758
  return false;
564
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
+ }
@@ -0,0 +1,153 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ evaluateCondition,
4
+ getNestedValue,
5
+ MAX_REGEX_PATTERN_LENGTH,
6
+ } from './evaluate-condition';
7
+
8
+ describe('getNestedValue', () => {
9
+ it('returns top-level values', () => {
10
+ expect(getNestedValue({ a: 1 }, 'a')).toBe(1);
11
+ });
12
+
13
+ it('returns nested values via dot notation', () => {
14
+ expect(getNestedValue({ a: { b: { c: 'deep' } } }, 'a.b.c')).toBe('deep');
15
+ });
16
+
17
+ it('returns undefined for missing paths', () => {
18
+ expect(getNestedValue({ a: 1 }, 'b')).toBeUndefined();
19
+ expect(getNestedValue({ a: { b: 1 } }, 'a.c')).toBeUndefined();
20
+ expect(getNestedValue({ a: { b: 1 } }, 'a.b.c')).toBeUndefined();
21
+ });
22
+
23
+ it('returns undefined when traversing through a non-object', () => {
24
+ expect(getNestedValue({ a: 5 }, 'a.b')).toBeUndefined();
25
+ expect(getNestedValue({ a: null }, 'a.b')).toBeUndefined();
26
+ });
27
+
28
+ it('preserves falsy values (0, false, empty string)', () => {
29
+ expect(getNestedValue({ a: 0 }, 'a')).toBe(0);
30
+ expect(getNestedValue({ a: false }, 'a')).toBe(false);
31
+ expect(getNestedValue({ a: '' }, 'a')).toBe('');
32
+ });
33
+ });
34
+
35
+ describe('evaluateCondition', () => {
36
+ describe('eq / neq', () => {
37
+ it('uses strict equality', () => {
38
+ expect(evaluateCondition('eq', 1, 1)).toBe(true);
39
+ expect(evaluateCondition('eq', 1, '1')).toBe(false);
40
+ expect(evaluateCondition('eq', 'a', 'a')).toBe(true);
41
+ expect(evaluateCondition('neq', 1, 2)).toBe(true);
42
+ expect(evaluateCondition('neq', 'a', 'a')).toBe(false);
43
+ });
44
+ });
45
+
46
+ describe('numeric comparisons', () => {
47
+ it('gt / gte / lt / lte work for numbers', () => {
48
+ expect(evaluateCondition('gt', 5, 3)).toBe(true);
49
+ expect(evaluateCondition('gt', 3, 5)).toBe(false);
50
+ expect(evaluateCondition('gt', 5, 5)).toBe(false);
51
+ expect(evaluateCondition('gte', 5, 5)).toBe(true);
52
+ expect(evaluateCondition('lt', 3, 5)).toBe(true);
53
+ expect(evaluateCondition('lt', 5, 5)).toBe(false);
54
+ expect(evaluateCondition('lte', 5, 5)).toBe(true);
55
+ });
56
+
57
+ it('returns false when either side is not a number', () => {
58
+ expect(evaluateCondition('gt', '5', 3)).toBe(false);
59
+ expect(evaluateCondition('gt', 5, '3')).toBe(false);
60
+ expect(evaluateCondition('gte', null, 0)).toBe(false);
61
+ expect(evaluateCondition('lt', undefined, 1)).toBe(false);
62
+ expect(evaluateCondition('lte', true, 0)).toBe(false);
63
+ });
64
+ });
65
+
66
+ describe('contains', () => {
67
+ it('matches substrings in strings', () => {
68
+ expect(evaluateCondition('contains', 'hello world', 'world')).toBe(true);
69
+ expect(evaluateCondition('contains', 'hello world', 'xyz')).toBe(false);
70
+ });
71
+
72
+ it('matches elements in arrays', () => {
73
+ expect(evaluateCondition('contains', [1, 2, 3], 2)).toBe(true);
74
+ expect(evaluateCondition('contains', ['a', 'b'], 'c')).toBe(false);
75
+ });
76
+
77
+ it('returns false for unsupported types', () => {
78
+ expect(evaluateCondition('contains', 42, 4)).toBe(false);
79
+ expect(evaluateCondition('contains', { a: 1 }, 'a')).toBe(false);
80
+ });
81
+ });
82
+
83
+ describe('matches', () => {
84
+ it('returns true for matching regex', () => {
85
+ expect(evaluateCondition('matches', 'hello world', '^hello')).toBe(true);
86
+ expect(evaluateCondition('matches', 'foo123', '\\d+')).toBe(true);
87
+ });
88
+
89
+ it('returns false for non-matching regex', () => {
90
+ expect(evaluateCondition('matches', 'hello', '^world')).toBe(false);
91
+ });
92
+
93
+ it('returns false on invalid regex (does not throw)', () => {
94
+ expect(evaluateCondition('matches', 'hello', '[invalid')).toBe(false);
95
+ });
96
+
97
+ it('returns false when either side is not a string', () => {
98
+ expect(evaluateCondition('matches', 123, '\\d+')).toBe(false);
99
+ expect(evaluateCondition('matches', 'abc', /abc/)).toBe(false);
100
+ });
101
+
102
+ it('rejects unsafe RE2-incompatible patterns rather than throwing', () => {
103
+ // Backreferences are unsupported in RE2 — verify we degrade gracefully.
104
+ expect(evaluateCondition('matches', 'aa', '(a)\\1')).toBe(false);
105
+ });
106
+
107
+ it('rejects patterns longer than MAX_REGEX_PATTERN_LENGTH without compiling', () => {
108
+ // A pattern equal to the limit should still compile and match.
109
+ const atLimit = 'a'.repeat(MAX_REGEX_PATTERN_LENGTH);
110
+ expect(evaluateCondition('matches', atLimit, atLimit)).toBe(true);
111
+
112
+ // One character over the limit should be rejected (returns false, no throw).
113
+ const overLimit = 'a'.repeat(MAX_REGEX_PATTERN_LENGTH + 1);
114
+ expect(evaluateCondition('matches', overLimit, overLimit)).toBe(false);
115
+ });
116
+ });
117
+
118
+ describe('exists / notExists', () => {
119
+ it('exists is true for any non-empty value', () => {
120
+ expect(evaluateCondition('exists', 'value', undefined)).toBe(true);
121
+ expect(evaluateCondition('exists', 0, undefined)).toBe(true);
122
+ expect(evaluateCondition('exists', false, undefined)).toBe(true);
123
+ expect(evaluateCondition('exists', [], undefined)).toBe(true);
124
+ });
125
+
126
+ it('exists is false for undefined, null, or empty string', () => {
127
+ expect(evaluateCondition('exists', undefined, undefined)).toBe(false);
128
+ expect(evaluateCondition('exists', null, undefined)).toBe(false);
129
+ expect(evaluateCondition('exists', '', undefined)).toBe(false);
130
+ });
131
+
132
+ it('notExists mirrors exists', () => {
133
+ expect(evaluateCondition('notExists', undefined, undefined)).toBe(true);
134
+ expect(evaluateCondition('notExists', null, undefined)).toBe(true);
135
+ expect(evaluateCondition('notExists', '', undefined)).toBe(true);
136
+ expect(evaluateCondition('notExists', 'value', undefined)).toBe(false);
137
+ expect(evaluateCondition('notExists', 0, undefined)).toBe(false);
138
+ });
139
+ });
140
+
141
+ describe('unknown operator', () => {
142
+ it('returns false for an unrecognized operator', () => {
143
+ // Cast through unknown to test the default branch.
144
+ expect(
145
+ evaluateCondition(
146
+ 'bogus' as unknown as Parameters<typeof evaluateCondition>[0],
147
+ 1,
148
+ 1
149
+ )
150
+ ).toBe(false);
151
+ });
152
+ });
153
+ });