@peac/protocol 0.10.6 → 0.10.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.
@@ -0,0 +1,671 @@
1
+ "use strict";
2
+ /**
3
+ * SSRF-safe fetch utility for PEAC verifiers
4
+ *
5
+ * Implements SSRF protection per VERIFIER-SECURITY-MODEL.md:
6
+ * - HTTPS only
7
+ * - Block private IP ranges (RFC 1918)
8
+ * - Block link-local addresses
9
+ * - Block loopback addresses
10
+ * - Redirect limits and scheme downgrade protection
11
+ *
12
+ * ## Security Model: Best-Effort Protection
13
+ *
14
+ * **IMPORTANT**: SSRF protection is BEST-EFFORT, not a guarantee. The level of
15
+ * protection depends on the runtime environment's capabilities. In environments
16
+ * without DNS pre-resolution (browsers, edge workers), protection is limited to
17
+ * URL scheme validation and response limits. Defense-in-depth: combine with
18
+ * network-level controls (firewalls, egress filtering) in production.
19
+ *
20
+ * ## Hard Invariants (ALWAYS Enforced)
21
+ *
22
+ * These protections are enforced in ALL runtimes:
23
+ *
24
+ * | Invariant | Enforcement |
25
+ * |-----------|-------------|
26
+ * | HTTPS only | URL scheme validation before fetch |
27
+ * | No redirects (pointer fetch) | `redirect: 'manual'` + policy check |
28
+ * | Response size cap | Streaming with byte counter, abort on limit |
29
+ * | Timeout | AbortController with configurable timeout |
30
+ * | No scheme downgrade | Redirect target scheme validation |
31
+ *
32
+ * ## Runtime-Dependent Protections
33
+ *
34
+ * These protections require DNS pre-resolution capability:
35
+ *
36
+ * | Protection | Requires |
37
+ * |------------|----------|
38
+ * | Private IP blocking (RFC 1918) | DNS pre-resolution |
39
+ * | Loopback blocking (127.0.0.0/8) | DNS pre-resolution |
40
+ * | Link-local blocking (169.254.0.0/16) | DNS pre-resolution |
41
+ *
42
+ * ## Runtime Capability Model
43
+ *
44
+ * SSRF protection capabilities vary by runtime environment:
45
+ *
46
+ * | Runtime | DNS Pre-Resolution | IP Blocking | Notes |
47
+ * |-------------------|-------------------|-------------|-------|
48
+ * | Node.js | YES | YES | Full protection via dns module |
49
+ * | Browser | NO | NO | Relies on server-side validation |
50
+ * | Cloudflare Workers| NO | NO | No DNS access, relies on CF network |
51
+ * | Deno | NO | NO | dns module not available by default |
52
+ * | Bun | YES | YES | Compatible with Node.js dns module |
53
+ *
54
+ * Use `getSSRFCapabilities()` to detect runtime capabilities.
55
+ *
56
+ * @packageDocumentation
57
+ */
58
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
59
+ if (k2 === undefined) k2 = k;
60
+ var desc = Object.getOwnPropertyDescriptor(m, k);
61
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
62
+ desc = { enumerable: true, get: function() { return m[k]; } };
63
+ }
64
+ Object.defineProperty(o, k2, desc);
65
+ }) : (function(o, m, k, k2) {
66
+ if (k2 === undefined) k2 = k;
67
+ o[k2] = m[k];
68
+ }));
69
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
70
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
71
+ }) : function(o, v) {
72
+ o["default"] = v;
73
+ });
74
+ var __importStar = (this && this.__importStar) || (function () {
75
+ var ownKeys = function(o) {
76
+ ownKeys = Object.getOwnPropertyNames || function (o) {
77
+ var ar = [];
78
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
79
+ return ar;
80
+ };
81
+ return ownKeys(o);
82
+ };
83
+ return function (mod) {
84
+ if (mod && mod.__esModule) return mod;
85
+ var result = {};
86
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
87
+ __setModuleDefault(result, mod);
88
+ return result;
89
+ };
90
+ })();
91
+ Object.defineProperty(exports, "__esModule", { value: true });
92
+ exports.getSSRFCapabilities = getSSRFCapabilities;
93
+ exports.resetSSRFCapabilitiesCache = resetSSRFCapabilitiesCache;
94
+ exports.isBlockedIP = isBlockedIP;
95
+ exports.ssrfSafeFetch = ssrfSafeFetch;
96
+ exports.fetchJWKSSafe = fetchJWKSSafe;
97
+ exports.fetchPointerSafe = fetchPointerSafe;
98
+ const kernel_1 = require("@peac/kernel");
99
+ /**
100
+ * Cached capabilities (detected once per process)
101
+ */
102
+ let cachedCapabilities = null;
103
+ /**
104
+ * Detect SSRF protection capabilities for the current runtime
105
+ *
106
+ * This function performs runtime detection and returns a capability object
107
+ * that describes what SSRF protections are available.
108
+ *
109
+ * @returns SSRF capabilities for the current runtime
110
+ *
111
+ * @example
112
+ * ```typescript
113
+ * const caps = getSSRFCapabilities();
114
+ * if (!caps.dnsPreResolution) {
115
+ * console.warn('Running without DNS pre-resolution; SSRF protection is limited');
116
+ * }
117
+ * ```
118
+ */
119
+ function getSSRFCapabilities() {
120
+ if (cachedCapabilities) {
121
+ return cachedCapabilities;
122
+ }
123
+ cachedCapabilities = detectCapabilities();
124
+ return cachedCapabilities;
125
+ }
126
+ /**
127
+ * Internal: Detect runtime capabilities
128
+ */
129
+ function detectCapabilities() {
130
+ // Check for Node.js
131
+ if (typeof process !== 'undefined' && process.versions?.node) {
132
+ return {
133
+ runtime: 'node',
134
+ dnsPreResolution: true,
135
+ ipBlocking: true,
136
+ networkIsolation: false,
137
+ protectionLevel: 'full',
138
+ notes: [
139
+ 'Full SSRF protection available via Node.js dns module',
140
+ 'DNS resolution checked before HTTP connection',
141
+ 'All RFC 1918 private ranges blocked',
142
+ ],
143
+ };
144
+ }
145
+ // Check for Bun
146
+ if (typeof process !== 'undefined' && process.versions?.bun) {
147
+ return {
148
+ runtime: 'bun',
149
+ dnsPreResolution: true,
150
+ ipBlocking: true,
151
+ networkIsolation: false,
152
+ protectionLevel: 'full',
153
+ notes: [
154
+ 'Full SSRF protection available via Bun dns compatibility',
155
+ 'DNS resolution checked before HTTP connection',
156
+ ],
157
+ };
158
+ }
159
+ // Check for Deno
160
+ if (typeof globalThis !== 'undefined' && 'Deno' in globalThis) {
161
+ return {
162
+ runtime: 'deno',
163
+ dnsPreResolution: false,
164
+ ipBlocking: false,
165
+ networkIsolation: false,
166
+ protectionLevel: 'partial',
167
+ notes: [
168
+ 'DNS pre-resolution not available in Deno by default',
169
+ 'SSRF protection limited to URL validation and response limits',
170
+ 'Consider using Deno.connect with hostname resolution for enhanced protection',
171
+ ],
172
+ };
173
+ }
174
+ // Check for Cloudflare Workers
175
+ if (typeof globalThis !== 'undefined' &&
176
+ typeof globalThis.caches !== 'undefined' &&
177
+ typeof globalThis.HTMLRewriter !== 'undefined') {
178
+ return {
179
+ runtime: 'cloudflare-workers',
180
+ dnsPreResolution: false,
181
+ ipBlocking: false,
182
+ networkIsolation: true,
183
+ protectionLevel: 'partial',
184
+ notes: [
185
+ 'Cloudflare Workers provide network-level isolation',
186
+ 'DNS pre-resolution not available in Workers runtime',
187
+ 'CF network blocks many SSRF vectors at infrastructure level',
188
+ 'SSRF protection supplemented by URL validation and response limits',
189
+ ],
190
+ };
191
+ }
192
+ // Check for browser environment
193
+ // Use globalThis to avoid DOM type references
194
+ const g = globalThis;
195
+ if (typeof g.window !== 'undefined' || typeof g.document !== 'undefined') {
196
+ return {
197
+ runtime: 'browser',
198
+ dnsPreResolution: false,
199
+ ipBlocking: false,
200
+ networkIsolation: false,
201
+ protectionLevel: 'minimal',
202
+ notes: [
203
+ 'Browser environment detected; DNS pre-resolution not available',
204
+ 'SSRF protection limited to URL scheme validation',
205
+ 'Consider validating URLs server-side before browser fetch',
206
+ 'Same-origin policy provides some protection against SSRF',
207
+ ],
208
+ };
209
+ }
210
+ // Generic edge runtime
211
+ return {
212
+ runtime: 'edge-generic',
213
+ dnsPreResolution: false,
214
+ ipBlocking: false,
215
+ networkIsolation: false,
216
+ protectionLevel: 'partial',
217
+ notes: [
218
+ 'Edge runtime detected; DNS pre-resolution may not be available',
219
+ 'SSRF protection limited to URL validation and response limits',
220
+ 'Verify runtime provides additional network-level protections',
221
+ ],
222
+ };
223
+ }
224
+ /**
225
+ * Reset cached capabilities (for testing)
226
+ * @internal
227
+ */
228
+ function resetSSRFCapabilitiesCache() {
229
+ cachedCapabilities = null;
230
+ }
231
+ /**
232
+ * Parse an IPv4 address string into octets
233
+ */
234
+ function parseIPv4(ip) {
235
+ const parts = ip.split('.');
236
+ if (parts.length !== 4)
237
+ return null;
238
+ const octets = [];
239
+ for (const part of parts) {
240
+ const num = parseInt(part, 10);
241
+ if (isNaN(num) || num < 0 || num > 255)
242
+ return null;
243
+ octets.push(num);
244
+ }
245
+ return { octets: octets };
246
+ }
247
+ /**
248
+ * Check if an IPv4 address is in a CIDR range
249
+ */
250
+ function isInCIDR(ip, cidr) {
251
+ const [rangeStr, maskStr] = cidr.split('/');
252
+ const range = parseIPv4(rangeStr);
253
+ if (!range)
254
+ return false;
255
+ const maskBits = parseInt(maskStr, 10);
256
+ if (isNaN(maskBits) || maskBits < 0 || maskBits > 32)
257
+ return false;
258
+ // Convert to 32-bit integers
259
+ const ipNum = (ip.octets[0] << 24) | (ip.octets[1] << 16) | (ip.octets[2] << 8) | ip.octets[3];
260
+ const rangeNum = (range.octets[0] << 24) | (range.octets[1] << 16) | (range.octets[2] << 8) | range.octets[3];
261
+ // Create mask
262
+ const mask = maskBits === 0 ? 0 : ~((1 << (32 - maskBits)) - 1);
263
+ return (ipNum & mask) === (rangeNum & mask);
264
+ }
265
+ /**
266
+ * Check if an IPv6 address is loopback (::1)
267
+ */
268
+ function isIPv6Loopback(ip) {
269
+ const normalized = ip.toLowerCase().replace(/^::ffff:/, '');
270
+ return normalized === '::1' || normalized === '0:0:0:0:0:0:0:1';
271
+ }
272
+ /**
273
+ * Check if an IPv6 address is link-local (fe80::/10)
274
+ */
275
+ function isIPv6LinkLocal(ip) {
276
+ const normalized = ip.toLowerCase();
277
+ return (normalized.startsWith('fe8') ||
278
+ normalized.startsWith('fe9') ||
279
+ normalized.startsWith('fea') ||
280
+ normalized.startsWith('feb'));
281
+ }
282
+ /**
283
+ * Check if an IP address is private/blocked
284
+ */
285
+ function isBlockedIP(ip) {
286
+ // Handle IPv4-mapped IPv6 addresses
287
+ const ipv4Match = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
288
+ const effectiveIP = ipv4Match ? ipv4Match[1] : ip;
289
+ // Check IPv4
290
+ const ipv4 = parseIPv4(effectiveIP);
291
+ if (ipv4) {
292
+ // RFC 1918 private ranges
293
+ if (isInCIDR(ipv4, '10.0.0.0/8') ||
294
+ isInCIDR(ipv4, '172.16.0.0/12') ||
295
+ isInCIDR(ipv4, '192.168.0.0/16')) {
296
+ return { blocked: true, reason: 'private_ip' };
297
+ }
298
+ // Loopback
299
+ if (isInCIDR(ipv4, '127.0.0.0/8')) {
300
+ return { blocked: true, reason: 'loopback' };
301
+ }
302
+ // Link-local
303
+ if (isInCIDR(ipv4, '169.254.0.0/16')) {
304
+ return { blocked: true, reason: 'link_local' };
305
+ }
306
+ return { blocked: false };
307
+ }
308
+ // Check IPv6
309
+ if (isIPv6Loopback(ip)) {
310
+ return { blocked: true, reason: 'loopback' };
311
+ }
312
+ if (isIPv6LinkLocal(ip)) {
313
+ return { blocked: true, reason: 'link_local' };
314
+ }
315
+ return { blocked: false };
316
+ }
317
+ /**
318
+ * Resolve hostname to IP addresses (platform-specific)
319
+ *
320
+ * In Node.js environments, this uses dns.resolve.
321
+ * In browser environments, we cannot check IPs before fetch.
322
+ */
323
+ async function resolveHostname(hostname) {
324
+ // Node.js environment detection
325
+ if (typeof process !== 'undefined' && process.versions?.node) {
326
+ try {
327
+ const dns = await Promise.resolve().then(() => __importStar(require('dns')));
328
+ const { promisify } = await Promise.resolve().then(() => __importStar(require('util')));
329
+ const resolve4 = promisify(dns.resolve4);
330
+ const resolve6 = promisify(dns.resolve6);
331
+ const results = [];
332
+ let ipv4Error = null;
333
+ let ipv6Error = null;
334
+ try {
335
+ const ipv4 = await resolve4(hostname);
336
+ results.push(...ipv4);
337
+ }
338
+ catch (err) {
339
+ ipv4Error = err;
340
+ }
341
+ try {
342
+ const ipv6 = await resolve6(hostname);
343
+ results.push(...ipv6);
344
+ }
345
+ catch (err) {
346
+ ipv6Error = err;
347
+ }
348
+ // If we got at least one result, resolution succeeded
349
+ if (results.length > 0) {
350
+ return { ok: true, ips: results, browser: false };
351
+ }
352
+ // Both failed - this is a DNS failure
353
+ if (ipv4Error && ipv6Error) {
354
+ return {
355
+ ok: false,
356
+ message: `DNS resolution failed for ${hostname}: ${ipv4Error.message}`,
357
+ };
358
+ }
359
+ // No results but no errors either (unlikely)
360
+ return { ok: true, ips: [], browser: false };
361
+ }
362
+ catch (err) {
363
+ // DNS module import failed or other error
364
+ return {
365
+ ok: false,
366
+ message: `DNS resolution error: ${err instanceof Error ? err.message : String(err)}`,
367
+ };
368
+ }
369
+ }
370
+ // Browser environment: cannot pre-resolve, return empty
371
+ // SSRF check will rely on server-side validation
372
+ return { ok: true, ips: [], browser: true };
373
+ }
374
+ /**
375
+ * Perform an SSRF-safe fetch
376
+ *
377
+ * This function implements the SSRF protection algorithm from VERIFIER-SECURITY-MODEL.md:
378
+ * 1. Parse URL; reject if not https://
379
+ * 2. Resolve hostname to IP(s)
380
+ * 3. For each IP: reject if private, link-local, or loopback
381
+ * 4. Perform fetch with timeout
382
+ * 5. On redirect: increment counter, reject if > max, apply checks to redirect URL
383
+ * 6. Validate response size
384
+ *
385
+ * @param url - URL to fetch (must be https://)
386
+ * @param options - Fetch options
387
+ * @returns Fetch result or error
388
+ */
389
+ async function ssrfSafeFetch(url, options = {}) {
390
+ const { timeoutMs = kernel_1.VERIFIER_LIMITS.fetchTimeoutMs, maxBytes = kernel_1.VERIFIER_LIMITS.maxResponseBytes, maxRedirects = 0, allowRedirects = kernel_1.VERIFIER_NETWORK.allowRedirects, allowCrossOriginRedirects = true, // Default: allow for CDN compatibility
391
+ dnsFailureBehavior = 'block', // Default: fail-closed for security
392
+ headers = {}, } = options;
393
+ // Step 1: Parse and validate URL
394
+ let parsedUrl;
395
+ try {
396
+ parsedUrl = new URL(url);
397
+ }
398
+ catch {
399
+ return {
400
+ ok: false,
401
+ reason: 'invalid_url',
402
+ message: `Invalid URL: ${url}`,
403
+ blockedUrl: url,
404
+ };
405
+ }
406
+ // Step 1b: Require HTTPS
407
+ if (parsedUrl.protocol !== 'https:') {
408
+ return {
409
+ ok: false,
410
+ reason: 'not_https',
411
+ message: `URL must use HTTPS: ${url}`,
412
+ blockedUrl: url,
413
+ };
414
+ }
415
+ // Step 2: Resolve hostname to IPs
416
+ const dnsResult = await resolveHostname(parsedUrl.hostname);
417
+ // Step 2b: Handle DNS resolution failure (fail-closed by default)
418
+ if (!dnsResult.ok) {
419
+ if (dnsFailureBehavior === 'block') {
420
+ return {
421
+ ok: false,
422
+ reason: 'dns_failure',
423
+ message: `DNS resolution blocked: ${dnsResult.message}`,
424
+ blockedUrl: url,
425
+ };
426
+ }
427
+ // dnsFailureBehavior === 'fail': return network_error
428
+ return {
429
+ ok: false,
430
+ reason: 'network_error',
431
+ message: dnsResult.message,
432
+ blockedUrl: url,
433
+ };
434
+ }
435
+ // Step 3: Check each resolved IP (if not browser environment)
436
+ if (!dnsResult.browser) {
437
+ for (const ip of dnsResult.ips) {
438
+ const blockResult = isBlockedIP(ip);
439
+ if (blockResult.blocked) {
440
+ return {
441
+ ok: false,
442
+ reason: blockResult.reason,
443
+ message: `Blocked ${blockResult.reason} address: ${ip} for ${url}`,
444
+ blockedUrl: url,
445
+ };
446
+ }
447
+ }
448
+ }
449
+ // Step 4: Perform fetch with timeout
450
+ let redirectCount = 0;
451
+ let currentUrl = url;
452
+ const originalOrigin = parsedUrl.origin;
453
+ while (true) {
454
+ try {
455
+ const controller = new AbortController();
456
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
457
+ const response = await fetch(currentUrl, {
458
+ headers: {
459
+ Accept: 'application/json, text/plain',
460
+ ...headers,
461
+ },
462
+ signal: controller.signal,
463
+ redirect: 'manual', // Handle redirects manually for security
464
+ });
465
+ clearTimeout(timeoutId);
466
+ // Step 5: Handle redirects
467
+ if (response.status >= 300 && response.status < 400) {
468
+ const location = response.headers.get('location');
469
+ if (!location) {
470
+ return {
471
+ ok: false,
472
+ reason: 'network_error',
473
+ message: `Redirect without Location header from ${currentUrl}`,
474
+ };
475
+ }
476
+ // Check redirect policy
477
+ if (!allowRedirects) {
478
+ return {
479
+ ok: false,
480
+ reason: 'too_many_redirects',
481
+ message: `Redirects not allowed: ${currentUrl} -> ${location}`,
482
+ blockedUrl: location,
483
+ };
484
+ }
485
+ // Increment redirect counter
486
+ redirectCount++;
487
+ if (redirectCount > maxRedirects) {
488
+ return {
489
+ ok: false,
490
+ reason: 'too_many_redirects',
491
+ message: `Too many redirects (${redirectCount} > ${maxRedirects})`,
492
+ blockedUrl: location,
493
+ };
494
+ }
495
+ // Resolve redirect URL
496
+ let redirectUrl;
497
+ try {
498
+ redirectUrl = new URL(location, currentUrl);
499
+ }
500
+ catch {
501
+ return {
502
+ ok: false,
503
+ reason: 'invalid_url',
504
+ message: `Invalid redirect URL: ${location}`,
505
+ blockedUrl: location,
506
+ };
507
+ }
508
+ // Check for scheme downgrade (https -> http)
509
+ if (redirectUrl.protocol !== 'https:') {
510
+ return {
511
+ ok: false,
512
+ reason: 'scheme_downgrade',
513
+ message: `HTTPS to HTTP downgrade not allowed: ${currentUrl} -> ${redirectUrl.href}`,
514
+ blockedUrl: redirectUrl.href,
515
+ };
516
+ }
517
+ // Check for cross-origin redirects (configurable for CDN compatibility)
518
+ if (redirectUrl.origin !== originalOrigin && !allowCrossOriginRedirects) {
519
+ return {
520
+ ok: false,
521
+ reason: 'cross_origin_redirect',
522
+ message: `Cross-origin redirect not allowed: ${originalOrigin} -> ${redirectUrl.origin}`,
523
+ blockedUrl: redirectUrl.href,
524
+ };
525
+ }
526
+ // Check redirect target IPs (DNS resolution + SSRF checks)
527
+ const redirectDnsResult = await resolveHostname(redirectUrl.hostname);
528
+ // Handle DNS failure for redirect target
529
+ if (!redirectDnsResult.ok) {
530
+ if (dnsFailureBehavior === 'block') {
531
+ return {
532
+ ok: false,
533
+ reason: 'dns_failure',
534
+ message: `Redirect DNS resolution blocked: ${redirectDnsResult.message}`,
535
+ blockedUrl: redirectUrl.href,
536
+ };
537
+ }
538
+ return {
539
+ ok: false,
540
+ reason: 'network_error',
541
+ message: redirectDnsResult.message,
542
+ blockedUrl: redirectUrl.href,
543
+ };
544
+ }
545
+ // Check redirect target IPs for SSRF (if not browser environment)
546
+ if (!redirectDnsResult.browser) {
547
+ for (const ip of redirectDnsResult.ips) {
548
+ const blockResult = isBlockedIP(ip);
549
+ if (blockResult.blocked) {
550
+ return {
551
+ ok: false,
552
+ reason: blockResult.reason,
553
+ message: `Redirect to blocked ${blockResult.reason} address: ${ip}`,
554
+ blockedUrl: redirectUrl.href,
555
+ };
556
+ }
557
+ }
558
+ }
559
+ currentUrl = redirectUrl.href;
560
+ continue;
561
+ }
562
+ // Step 6: Validate response size
563
+ const contentLength = response.headers.get('content-length');
564
+ if (contentLength && parseInt(contentLength, 10) > maxBytes) {
565
+ return {
566
+ ok: false,
567
+ reason: 'response_too_large',
568
+ message: `Response too large: ${contentLength} bytes > ${maxBytes} max`,
569
+ };
570
+ }
571
+ // Read response body with size limit
572
+ const reader = response.body?.getReader();
573
+ if (!reader) {
574
+ const body = await response.text();
575
+ if (body.length > maxBytes) {
576
+ return {
577
+ ok: false,
578
+ reason: 'response_too_large',
579
+ message: `Response too large: ${body.length} bytes > ${maxBytes} max`,
580
+ };
581
+ }
582
+ // Convert body back to bytes for rawBytes (fallback path)
583
+ const rawBytes = new TextEncoder().encode(body);
584
+ return {
585
+ ok: true,
586
+ status: response.status,
587
+ body,
588
+ rawBytes,
589
+ contentType: response.headers.get('content-type') ?? undefined,
590
+ };
591
+ }
592
+ // Stream with size limit
593
+ const chunks = [];
594
+ let totalSize = 0;
595
+ while (true) {
596
+ const { done, value } = await reader.read();
597
+ if (done)
598
+ break;
599
+ totalSize += value.length;
600
+ if (totalSize > maxBytes) {
601
+ reader.cancel();
602
+ return {
603
+ ok: false,
604
+ reason: 'response_too_large',
605
+ message: `Response too large: ${totalSize} bytes > ${maxBytes} max`,
606
+ };
607
+ }
608
+ chunks.push(value);
609
+ }
610
+ // Concatenate chunks into raw bytes (preserve original bytes for digest)
611
+ const rawBytes = chunks.reduce((acc, chunk) => {
612
+ const result = new Uint8Array(acc.length + chunk.length);
613
+ result.set(acc);
614
+ result.set(chunk, acc.length);
615
+ return result;
616
+ }, new Uint8Array());
617
+ // Decode to string for body
618
+ const body = new TextDecoder().decode(rawBytes);
619
+ return {
620
+ ok: true,
621
+ status: response.status,
622
+ body,
623
+ rawBytes,
624
+ contentType: response.headers.get('content-type') ?? undefined,
625
+ };
626
+ }
627
+ catch (err) {
628
+ if (err instanceof Error) {
629
+ if (err.name === 'AbortError' || err.message.includes('timeout')) {
630
+ return {
631
+ ok: false,
632
+ reason: 'timeout',
633
+ message: `Fetch timeout after ${timeoutMs}ms: ${currentUrl}`,
634
+ };
635
+ }
636
+ }
637
+ return {
638
+ ok: false,
639
+ reason: 'network_error',
640
+ message: `Network error: ${err instanceof Error ? err.message : String(err)}`,
641
+ };
642
+ }
643
+ }
644
+ }
645
+ /**
646
+ * Convenience function to fetch JWKS with SSRF protection
647
+ */
648
+ async function fetchJWKSSafe(jwksUrl, options) {
649
+ return ssrfSafeFetch(jwksUrl, {
650
+ ...options,
651
+ maxBytes: kernel_1.VERIFIER_LIMITS.maxJwksBytes,
652
+ headers: {
653
+ Accept: 'application/json',
654
+ ...options?.headers,
655
+ },
656
+ });
657
+ }
658
+ /**
659
+ * Convenience function to fetch pointer target with SSRF protection
660
+ */
661
+ async function fetchPointerSafe(pointerUrl, options) {
662
+ return ssrfSafeFetch(pointerUrl, {
663
+ ...options,
664
+ maxBytes: kernel_1.VERIFIER_LIMITS.maxReceiptBytes,
665
+ headers: {
666
+ Accept: 'application/jose, application/json',
667
+ ...options?.headers,
668
+ },
669
+ });
670
+ }
671
+ //# sourceMappingURL=ssrf-safe-fetch.js.map