@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.
- package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
- package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
- package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
- package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
- package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
- package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
- package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
- package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
- package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
- package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
- package/.github/workflows/publish.yml +6 -0
- package/dist/{chunk-4VOKMGP6.js → chunk-5XJBAPXF.js} +517 -522
- package/dist/chunk-5XJBAPXF.js.map +1 -0
- package/dist/{chunk-KHTUWLVZ.js → chunk-FMPJBK2N.js} +55 -10
- package/dist/chunk-FMPJBK2N.js.map +1 -0
- package/dist/{chunk-ML35PIHX.js → chunk-JPXPB7U5.js} +52 -2
- package/dist/chunk-JPXPB7U5.js.map +1 -0
- package/dist/{chunk-GYPNJBDP.js → chunk-U3PCVK6O.js} +266 -47
- package/dist/chunk-U3PCVK6O.js.map +1 -0
- package/dist/events/index.d.ts +56 -3
- package/dist/events/index.js +1 -1
- package/dist/{index-Dzr437iW.d.ts → index-Cd0IZ40L.d.ts} +50 -11
- package/dist/index.d.ts +2 -12
- package/dist/index.js +764 -485
- package/dist/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +1 -1
- package/dist/scheduler/index.js +2 -2
- package/dist/storage/index.d.ts +313 -47
- package/dist/storage/index.js +4 -2
- package/docs/stepflow-api-reference.md +3 -1
- package/package.json +7 -4
- package/src/core/engine/concurrency.ts +229 -0
- package/src/core/engine/lifecycle.ts +176 -0
- package/src/core/engine.test.ts +70 -0
- package/src/core/engine.ts +91 -185
- package/src/core/orchestrator/persistence.ts +165 -0
- package/src/core/orchestrator/plan-resolution.ts +140 -0
- package/src/core/orchestrator/state-transitions.ts +109 -0
- package/src/core/orchestrator/step-runner.ts +427 -0
- package/src/core/orchestrator.ts +40 -359
- package/src/events/webhook.test.ts +292 -0
- package/src/events/webhook.ts +320 -77
- package/src/planning/evaluate-condition.test.ts +153 -0
- package/src/planning/evaluate-condition.ts +105 -0
- package/src/planning/planner.ts +1 -83
- package/src/planning/registry.ts +3 -85
- package/src/scheduler/cron.test.ts +9 -1
- package/src/scheduler/cron.ts +26 -2
- package/src/scheduler/postgres-persistence.ts +27 -15
- package/src/scheduler/sqlite-persistence.ts +28 -3
- package/src/storage/postgres-core.ts +550 -0
- package/src/storage/postgres-migration.test.ts +69 -0
- package/src/storage/postgres-transaction-pagination.test.ts +107 -0
- package/src/storage/postgres.ts +171 -690
- package/src/storage/sqlite-core.ts +151 -0
- package/src/storage/sqlite.test.ts +177 -1
- package/src/storage/sqlite.ts +183 -93
- package/src/utils/index.ts +2 -0
- package/src/utils/pinned-fetch.test.ts +84 -0
- package/src/utils/pinned-fetch.ts +118 -0
- package/src/utils/postgres-deps.ts +16 -0
- package/src/utils/safe-json.test.ts +166 -0
- package/src/utils/safe-json.ts +107 -0
- package/src/utils/webhook-signature.test.ts +111 -0
- package/src/utils/webhook-signature.ts +74 -0
- package/dist/chunk-4VOKMGP6.js.map +0 -1
- package/dist/chunk-GYPNJBDP.js.map +0 -1
- package/dist/chunk-KHTUWLVZ.js.map +0 -1
- package/dist/chunk-ML35PIHX.js.map +0 -1
package/src/events/webhook.ts
CHANGED
|
@@ -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
|
-
|
|
239
|
-
this.
|
|
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 =
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
412
|
-
*
|
|
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(
|
|
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
|
|
419
|
-
|
|
420
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
485
|
-
|
|
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
|
-
*
|
|
492
|
-
*
|
|
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
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
//
|
|
506
|
-
if (hostname
|
|
507
|
-
|
|
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
|
-
//
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
529
|
-
|
|
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
|
-
|
|
542
|
-
if (
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
//
|
|
559
|
-
if (
|
|
560
|
-
|
|
561
|
-
|
|
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
|
+
});
|