@peac/net-node 0.12.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +736 -0
  3. package/dist/codes.d.ts +47 -0
  4. package/dist/codes.d.ts.map +1 -0
  5. package/dist/codes.js +53 -0
  6. package/dist/codes.js.map +1 -0
  7. package/dist/constants.d.ts +39 -0
  8. package/dist/constants.d.ts.map +1 -0
  9. package/dist/constants.js +39 -0
  10. package/dist/constants.js.map +1 -0
  11. package/dist/evidence-utils.d.ts +49 -0
  12. package/dist/evidence-utils.d.ts.map +1 -0
  13. package/dist/evidence-utils.js +109 -0
  14. package/dist/evidence-utils.js.map +1 -0
  15. package/dist/impl.d.ts +331 -0
  16. package/dist/impl.d.ts.map +1 -0
  17. package/dist/impl.js +1188 -0
  18. package/dist/impl.js.map +1 -0
  19. package/dist/index.d.ts +772 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +1121 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/internal.d.ts +28 -0
  24. package/dist/internal.d.ts.map +1 -0
  25. package/dist/internal.js +59 -0
  26. package/dist/internal.js.map +1 -0
  27. package/dist/receipt-resolver.d.ts +65 -0
  28. package/dist/receipt-resolver.d.ts.map +1 -0
  29. package/dist/receipt-resolver.js +140 -0
  30. package/dist/receipt-resolver.js.map +1 -0
  31. package/dist/receipt-url-middleware.d.ts +87 -0
  32. package/dist/receipt-url-middleware.d.ts.map +1 -0
  33. package/dist/receipt-url-middleware.js +141 -0
  34. package/dist/receipt-url-middleware.js.map +1 -0
  35. package/dist/ssrf.d.ts +114 -0
  36. package/dist/ssrf.d.ts.map +1 -0
  37. package/dist/ssrf.js +214 -0
  38. package/dist/ssrf.js.map +1 -0
  39. package/dist/testing.d.ts +65 -0
  40. package/dist/testing.d.ts.map +1 -0
  41. package/dist/testing.js +103 -0
  42. package/dist/testing.js.map +1 -0
  43. package/package.json +79 -0
package/dist/index.js ADDED
@@ -0,0 +1,1121 @@
1
+ /**
2
+ * PEAC Net (Node.js) - SSRF-Safe Network Utilities with DNS Pinning
3
+ *
4
+ * This module provides a reference implementation of network-layer SSRF protection
5
+ * with proper DNS resolution pinning using undici's custom dispatcher.
6
+ *
7
+ * SECURITY NOTES:
8
+ * - String-level URL checks alone are NOT sufficient for SSRF protection
9
+ * - DNS rebinding attacks require connection-time IP pinning
10
+ * - This implementation resolves DNS, validates ALL candidate IPs, and PINS
11
+ * the chosen IP at the connection layer (not just pre-check)
12
+ *
13
+ * RUNTIME: Node.js only (requires dns module and undici)
14
+ *
15
+ * @since v0.10.x
16
+ * @module @peac/net-node
17
+ */
18
+ import { createHash } from 'crypto';
19
+ import * as dns from 'dns/promises';
20
+ import { fetch as undiciFetch } from 'undici';
21
+ // Local SSRF types (not from @peac/schema to keep package self-contained)
22
+ import { validateUrlForSSRF, DEFAULT_SSRF_POLICY } from './ssrf.js';
23
+ // Import finalizeEvidence from internal module (not exported from main entry)
24
+ import { finalizeEvidence } from './internal.js';
25
+ // Import internal helpers from impl.ts (for internal use only - NOT re-exported)
26
+ import { isPrivateIP, resolveDnsSecure, createPinnedAgent, validateAllowCidrsAck, isRedirectAllowed, sanitizeUrl, readBodyWithLimit, attemptWithFallback, stripHopByHopHeaders, canonicalizeHost, emitAuditEvent, createRequestAuditContext, redactIp, getAuditQueueStats, withTimeout, } from './impl.js';
27
+ // Import from single source of truth modules
28
+ import { SAFE_FETCH_ERROR_CODES } from './codes.js';
29
+ import { SAFE_FETCH_EVENT_SCHEMA_VERSION, SAFE_FETCH_EVIDENCE_SCHEMA_VERSION, MAX_PENDING_AUDIT_EVENTS, } from './constants.js';
30
+ // Re-export public API items
31
+ export { getAuditQueueStats };
32
+ // Re-export error codes and constants
33
+ export { SAFE_FETCH_ERROR_CODES };
34
+ export { SAFE_FETCH_EVENT_SCHEMA_VERSION, SAFE_FETCH_EVIDENCE_SCHEMA_VERSION, MAX_PENDING_AUDIT_EVENTS, };
35
+ /**
36
+ * Acknowledgment string for allowing mixed public/private DNS resolution
37
+ */
38
+ export const ALLOW_MIXED_DNS_ACK = 'I_UNDERSTAND_MIXED_DNS_SECURITY_RISKS';
39
+ // -----------------------------------------------------------------------------
40
+ // P1.1: Vendor-neutral Audit Hook (Protocol-Grade)
41
+ // -----------------------------------------------------------------------------
42
+ // -----------------------------------------------------------------------------
43
+ // P1.2: JCS Canonicalization + SHA-256 Digest
44
+ // -----------------------------------------------------------------------------
45
+ /**
46
+ * Check if a value is a plain object (prototype is Object.prototype or null)
47
+ *
48
+ * This excludes Date, RegExp, Map, Set, and other non-JSON-serializable objects.
49
+ * @internal
50
+ */
51
+ function isPlainObject(value) {
52
+ if (value === null || typeof value !== 'object')
53
+ return false;
54
+ const proto = Object.getPrototypeOf(value);
55
+ return proto === Object.prototype || proto === null;
56
+ }
57
+ /**
58
+ * Canonicalize a JSON value per RFC 8785 (JSON Canonicalization Scheme)
59
+ *
60
+ * This ensures deterministic serialization for cross-implementation parity:
61
+ * - Object keys sorted lexicographically (Unicode code points)
62
+ * - No whitespace between tokens
63
+ * - Numbers serialized per ES2015
64
+ * - Strings with minimal escaping
65
+ *
66
+ * @param value - Any JSON-serializable value
67
+ * @returns Canonical JSON string
68
+ * @internal
69
+ */
70
+ function jcsCanonicalizeValue(value) {
71
+ if (value === null)
72
+ return 'null';
73
+ if (typeof value === 'boolean')
74
+ return value ? 'true' : 'false';
75
+ if (typeof value === 'number') {
76
+ // Handle special cases per ES2015 spec
77
+ if (Object.is(value, -0))
78
+ return '0';
79
+ if (!Number.isFinite(value)) {
80
+ throw new Error('JCS does not support Infinity or NaN');
81
+ }
82
+ return String(value);
83
+ }
84
+ if (typeof value === 'string') {
85
+ // Use JSON.stringify for proper escaping
86
+ return JSON.stringify(value);
87
+ }
88
+ if (Array.isArray(value)) {
89
+ const elements = value.map(jcsCanonicalizeValue);
90
+ return '[' + elements.join(',') + ']';
91
+ }
92
+ if (isPlainObject(value)) {
93
+ // Sort keys lexicographically (Unicode code point order)
94
+ const keys = Object.keys(value).sort();
95
+ const pairs = keys
96
+ .filter((k) => value[k] !== undefined)
97
+ .map((k) => JSON.stringify(k) + ':' + jcsCanonicalizeValue(value[k]));
98
+ return '{' + pairs.join(',') + '}';
99
+ }
100
+ throw new Error(`Cannot canonicalize value of type ${typeof value}`);
101
+ }
102
+ /**
103
+ * Canonicalize evidence per RFC 8785 (JCS)
104
+ *
105
+ * Returns the canonical JSON string representation of the evidence object.
106
+ * This is deterministic across implementations and can be used for:
107
+ * - Computing digests for signing
108
+ * - Cross-implementation verification
109
+ * - Deduplication
110
+ *
111
+ * IMPORTANT: For digest computation, pass evidence WITHOUT digest fields
112
+ * to avoid self-referential circularity.
113
+ *
114
+ * @param evidence - SafeFetchEvidence or SafeFetchEvidenceCore object
115
+ * @returns Canonical JSON string
116
+ */
117
+ export function canonicalizeEvidence(evidence) {
118
+ return jcsCanonicalizeValue(evidence);
119
+ }
120
+ /**
121
+ * Compute SHA-256 digest of evidence using JCS canonicalization
122
+ *
123
+ * Returns a 0x-prefixed lowercase hex string, consistent with other
124
+ * PEAC cryptographic identifiers (EAS anchors, IP hashes).
125
+ *
126
+ * Self-omission rule: If the evidence includes digest fields, they are
127
+ * stripped before computing the digest to avoid circularity.
128
+ *
129
+ * @param evidence - SafeFetchEvidence or SafeFetchEvidenceCore object
130
+ * @returns 0x-prefixed SHA-256 hex digest
131
+ */
132
+ export function computeEvidenceDigest(evidence) {
133
+ // Strip digest fields if present (self-omission rule)
134
+ const { evidence_digest, evidence_alg, canonicalization, ...core } = evidence;
135
+ const canonical = canonicalizeEvidence(core);
136
+ const hash = createHash('sha256').update(canonical, 'utf8').digest('hex');
137
+ return `0x${hash}`;
138
+ }
139
+ /**
140
+ * Default timeout values
141
+ */
142
+ export const DEFAULT_TIMEOUTS = {
143
+ totalMs: 30000,
144
+ dnsMs: 5000,
145
+ connectMs: 10000,
146
+ headersMs: 15000,
147
+ bodyMs: 30000,
148
+ };
149
+ // -----------------------------------------------------------------------------
150
+ // Constants
151
+ // -----------------------------------------------------------------------------
152
+ /**
153
+ * Default timeout for fetch requests (30 seconds)
154
+ */
155
+ export const DEFAULT_TIMEOUT_MS = 30000;
156
+ /**
157
+ * Default maximum redirects
158
+ */
159
+ export const DEFAULT_MAX_REDIRECTS = 5;
160
+ /**
161
+ * Default allowed HTTP methods (GET/HEAD only for SSRF safety)
162
+ */
163
+ export const DEFAULT_ALLOWED_METHODS = ['GET', 'HEAD'];
164
+ /**
165
+ * Default maximum response size (2 MB)
166
+ *
167
+ * COMPRESSION SEMANTICS (P0.4 - Protocol-Grade Evidence):
168
+ *
169
+ * This limit measures DECODED bytes (after decompression), NOT wire bytes.
170
+ * The limit applies to what `response.body.getReader().read()` returns.
171
+ *
172
+ * By default, we request `Accept-Encoding: identity` to prevent compression.
173
+ * This means:
174
+ * - Wire bytes === Decoded bytes (no compression)
175
+ * - Content-Length matches actual body size
176
+ * - No decompression bomb risk
177
+ *
178
+ * If `allowCompression: true`, compression is allowed, and:
179
+ * - Wire bytes may be smaller than decoded bytes
180
+ * - Content-Length reflects compressed size (if set)
181
+ * - We measure DECODED bytes (after browser/runtime decompresses)
182
+ * - Decompression bombs are possible (1KB compressed -> 1GB decoded)
183
+ *
184
+ * WARNING: Only use `allowCompression: true` if you trust the server AND have
185
+ * additional decoded size limits in place.
186
+ *
187
+ * For evidence purposes, `response_bytes` in SafeFetchEvidence reflects
188
+ * decoded bytes (what we actually read), not wire bytes.
189
+ */
190
+ export const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
191
+ /**
192
+ * Whether maxResponseBytes measures wire or decoded bytes.
193
+ * Always 'decoded' - we measure after potential decompression.
194
+ */
195
+ export const RESPONSE_BYTES_MEASUREMENT = 'decoded';
196
+ /**
197
+ * Maximum response size for JWKS/OIDC endpoints (512 KB)
198
+ */
199
+ export const MAX_JWKS_RESPONSE_BYTES = 512 * 1024;
200
+ /**
201
+ * Dangerous ports that are ALWAYS blocked regardless of configuration.
202
+ *
203
+ * These ports commonly host:
204
+ * - SSH (22), SMTP (25), DNS (53), FTP (21)
205
+ * - Database services (3306 MySQL, 5432 PostgreSQL, 6379 Redis, 27017 MongoDB)
206
+ * - Kubernetes (6443, 10250), Envoy (15000), Prometheus (9090)
207
+ * - Internal admin panels, debug ports
208
+ *
209
+ * Blocking these prevents SSRF to internal services even on attacker-controlled hosts.
210
+ * To allow these ports, you MUST provide explicit acknowledgment.
211
+ */
212
+ export const DANGEROUS_PORTS = new Set([
213
+ // Remote access
214
+ 21, // FTP control
215
+ 22, // SSH
216
+ 23, // Telnet
217
+ 25, // SMTP
218
+ 53, // DNS
219
+ 110, // POP3
220
+ 143, // IMAP
221
+ 445, // SMB
222
+ 513, // rlogin
223
+ 514, // rsh/syslog
224
+ 587, // SMTP submission
225
+ // Databases
226
+ 1433, // MSSQL
227
+ 1521, // Oracle
228
+ 3306, // MySQL
229
+ 5432, // PostgreSQL
230
+ 5984, // CouchDB
231
+ 6379, // Redis
232
+ 9042, // Cassandra
233
+ 27017, // MongoDB
234
+ // Container/orchestration
235
+ 2375, // Docker daemon (unencrypted)
236
+ 2376, // Docker daemon (TLS)
237
+ 2379, // etcd client
238
+ 2380, // etcd peer
239
+ 6443, // Kubernetes API
240
+ 10250, // Kubelet
241
+ 10255, // Kubelet read-only
242
+ // Service mesh/proxy
243
+ 9090, // Prometheus
244
+ 15000, // Envoy admin
245
+ 15001, // Istio proxy
246
+ 15004, // Istio debug
247
+ ]);
248
+ /**
249
+ * Acknowledgment string for allowing dangerous ports
250
+ */
251
+ export const ALLOW_DANGEROUS_PORTS_ACK = 'I_UNDERSTAND_DANGEROUS_PORTS_RISK';
252
+ // Internal constants are now imported from impl.ts
253
+ // Internal helper functions are now imported from impl.ts
254
+ // -----------------------------------------------------------------------------
255
+ // Default Implementations
256
+ // -----------------------------------------------------------------------------
257
+ /**
258
+ * Default DNS resolver using Node.js dns.promises module
259
+ * Resolves both A (IPv4) and AAAA (IPv6) records separately for Happy Eyeballs
260
+ */
261
+ export const defaultDnsResolver = {
262
+ async resolveAll(hostname) {
263
+ const ipv4 = [];
264
+ const ipv6 = [];
265
+ // Try to get all A records (IPv4)
266
+ try {
267
+ const ipv4Addresses = await dns.resolve4(hostname);
268
+ ipv4.push(...ipv4Addresses);
269
+ }
270
+ catch {
271
+ // IPv4 resolution failed, continue with IPv6
272
+ }
273
+ // Try to get all AAAA records (IPv6)
274
+ try {
275
+ const ipv6Addresses = await dns.resolve6(hostname);
276
+ ipv6.push(...ipv6Addresses);
277
+ }
278
+ catch {
279
+ // IPv6 resolution failed
280
+ }
281
+ // If both failed, fall back to dns.lookup for /etc/hosts entries
282
+ if (ipv4.length === 0 && ipv6.length === 0) {
283
+ const result = await dns.lookup(hostname, { all: true });
284
+ if (Array.isArray(result)) {
285
+ for (const r of result) {
286
+ if (r.family === 4) {
287
+ ipv4.push(r.address);
288
+ }
289
+ else if (r.family === 6) {
290
+ ipv6.push(r.address);
291
+ }
292
+ }
293
+ }
294
+ }
295
+ return { ipv4, ipv6 };
296
+ },
297
+ };
298
+ /**
299
+ * Default HTTP client using undici with DNS pinning
300
+ * Returns close function for proper agent lifecycle management
301
+ * P1.3: Supports phase-specific timeouts via undici options
302
+ */
303
+ export const defaultHttpClient = {
304
+ async fetch(url, pinnedIp, options) {
305
+ const parsedUrl = new URL(url);
306
+ // P1.3: Extract phase-specific timeouts
307
+ const timeouts = options?.timeouts;
308
+ const totalMs = timeouts?.totalMs ?? options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
309
+ const connectMs = timeouts?.connectMs ?? DEFAULT_TIMEOUTS.connectMs;
310
+ const headersMs = timeouts?.headersMs ?? DEFAULT_TIMEOUTS.headersMs;
311
+ const bodyMs = timeouts?.bodyMs ?? DEFAULT_TIMEOUTS.bodyMs;
312
+ // Create abort controller for total timeout
313
+ const controller = new AbortController();
314
+ const timeoutId = setTimeout(() => controller.abort(), totalMs);
315
+ let agent = null;
316
+ try {
317
+ if (pinnedIp) {
318
+ // Use pinned agent for DNS rebinding protection
319
+ // P1.3: Pass connect timeout to agent
320
+ agent = createPinnedAgent(pinnedIp, parsedUrl.hostname, {
321
+ connectTimeoutMs: connectMs,
322
+ });
323
+ // P1.3: Pass phase-specific timeouts to undici
324
+ const response = await undiciFetch(url, {
325
+ ...options,
326
+ signal: controller.signal,
327
+ dispatcher: agent,
328
+ redirect: 'manual', // Handle redirects manually
329
+ // undici-specific timeout options
330
+ headersTimeout: headersMs,
331
+ bodyTimeout: bodyMs,
332
+ });
333
+ clearTimeout(timeoutId);
334
+ // Return response and close function
335
+ // IMPORTANT: Agent must be closed AFTER body is consumed
336
+ return {
337
+ response: response,
338
+ close: async () => {
339
+ if (agent) {
340
+ await agent.close();
341
+ }
342
+ },
343
+ };
344
+ }
345
+ else {
346
+ // No pinning needed (IP literal already validated)
347
+ // P1.3: Pass phase-specific timeouts to undici
348
+ const response = await undiciFetch(url, {
349
+ ...options,
350
+ signal: controller.signal,
351
+ redirect: 'manual',
352
+ headersTimeout: headersMs,
353
+ bodyTimeout: bodyMs,
354
+ });
355
+ clearTimeout(timeoutId);
356
+ return {
357
+ response: response,
358
+ close: async () => {
359
+ // No agent to close
360
+ },
361
+ };
362
+ }
363
+ }
364
+ catch (err) {
365
+ clearTimeout(timeoutId);
366
+ // Clean up agent on error
367
+ if (agent) {
368
+ await agent.close();
369
+ }
370
+ throw err;
371
+ }
372
+ },
373
+ };
374
+ // -----------------------------------------------------------------------------
375
+ // Main API
376
+ // -----------------------------------------------------------------------------
377
+ /**
378
+ * SSRF-safe raw fetch with proper DNS resolution pinning
379
+ * Returns response stream with close function for proper lifecycle management
380
+ *
381
+ * Use this when you need to process the response stream directly.
382
+ * IMPORTANT: You MUST call the close() function after consuming the response.
383
+ *
384
+ * Security features:
385
+ * - DNS resolution pinning (prevents rebinding attacks)
386
+ * - Private IP blocking (RFC 6890 compliant)
387
+ * - Host header enforcement (prevents SSRF via Host manipulation)
388
+ * - Hop-by-hop header stripping (RFC 7230)
389
+ * - Response size enforcement
390
+ * - Redirect chain validation
391
+ *
392
+ * Returns an evidence object for PEAC-ready receipts and audit trails.
393
+ */
394
+ export async function safeFetchRaw(url, options) {
395
+ // Create request-scoped audit context for per-request counters
396
+ const auditCtx = createRequestAuditContext();
397
+ const requestTimestamp = Date.now();
398
+ const internalOptions = {
399
+ ...options,
400
+ _redirectCount: options?._redirectCount ?? 0,
401
+ _originalHost: options?._originalHost,
402
+ _originalOrigin: options?._originalOrigin,
403
+ };
404
+ // P1.3: Extract phase-specific timeouts from TimeoutConfig
405
+ // If timeouts option is provided, it overrides the legacy timeoutMs option
406
+ const phaseTimeouts = options?.timeouts ?? {
407
+ totalMs: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
408
+ dnsMs: DEFAULT_TIMEOUTS.dnsMs,
409
+ connectMs: DEFAULT_TIMEOUTS.connectMs,
410
+ headersMs: DEFAULT_TIMEOUTS.headersMs,
411
+ bodyMs: DEFAULT_TIMEOUTS.bodyMs,
412
+ };
413
+ const totalTimeoutMs = phaseTimeouts.totalMs ?? DEFAULT_TIMEOUT_MS;
414
+ const dnsTimeoutMs = phaseTimeouts.dnsMs ?? DEFAULT_TIMEOUTS.dnsMs;
415
+ // connectMs, headersMs, bodyMs are passed to httpClient
416
+ const maxRedirects = options?.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
417
+ const maxResponseBytes = options?.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
418
+ const ssrfPolicy = options?.ssrfPolicy ?? DEFAULT_SSRF_POLICY;
419
+ const allowedMethods = options?.allowedMethods ?? DEFAULT_ALLOWED_METHODS;
420
+ const debug = options?.debug ?? false;
421
+ // P0.3: Evidence level (default 'public' for defense-in-depth)
422
+ const evidenceLevel = options?.evidenceLevel ?? 'public';
423
+ const isPrivateEvidence = evidenceLevel === 'private';
424
+ const warnings = [];
425
+ const droppedHeaders = [];
426
+ // Validate tenant mode configuration (fail-fast)
427
+ if (evidenceLevel === 'tenant') {
428
+ if (!options?.redactionKey || options.redactionKey.length < 32) {
429
+ // Build minimal error evidence before we have hostname info
430
+ const errorCore = {
431
+ schema_version: SAFE_FETCH_EVIDENCE_SCHEMA_VERSION,
432
+ evidence_level: 'public', // Fallback to public for error evidence
433
+ request_timestamp: requestTimestamp,
434
+ request_url: sanitizeUrl(url),
435
+ canonical_host: '[unknown]',
436
+ is_ip_literal: false,
437
+ policy_decision: 'block',
438
+ decision_code: SAFE_FETCH_ERROR_CODES.E_TENANT_KEY_MISSING,
439
+ max_response_bytes: options?.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
440
+ };
441
+ return {
442
+ ok: false,
443
+ error: 'Tenant mode requires redactionKey (>= 32 bytes)',
444
+ code: SAFE_FETCH_ERROR_CODES.E_TENANT_KEY_MISSING,
445
+ evidence: finalizeEvidence(errorCore, auditCtx),
446
+ };
447
+ }
448
+ if (!options?.redactionKeyId) {
449
+ const errorCore = {
450
+ schema_version: SAFE_FETCH_EVIDENCE_SCHEMA_VERSION,
451
+ evidence_level: 'public',
452
+ request_timestamp: requestTimestamp,
453
+ request_url: sanitizeUrl(url),
454
+ canonical_host: '[unknown]',
455
+ is_ip_literal: false,
456
+ policy_decision: 'block',
457
+ decision_code: SAFE_FETCH_ERROR_CODES.E_TENANT_KEY_MISSING,
458
+ max_response_bytes: options?.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
459
+ };
460
+ return {
461
+ ok: false,
462
+ error: 'Tenant mode requires redactionKeyId for key rotation audit',
463
+ code: SAFE_FETCH_ERROR_CODES.E_TENANT_KEY_MISSING,
464
+ evidence: finalizeEvidence(errorCore, auditCtx),
465
+ };
466
+ }
467
+ }
468
+ // Sanitize URL for logging (remove credentials)
469
+ const sanitizedUrl = sanitizeUrl(url);
470
+ // Handle deprecated allowCrossHostRedirects
471
+ let redirectPolicy = options?.redirectPolicy ?? 'same-origin';
472
+ if (options?.allowCrossHostRedirects && !options?.redirectPolicy) {
473
+ redirectPolicy = 'same-registrable-domain';
474
+ warnings.push('allowCrossHostRedirects is deprecated, use redirectPolicy instead');
475
+ }
476
+ // Use injected dependencies or defaults
477
+ const dnsResolver = options?._dnsResolver ?? defaultDnsResolver;
478
+ const httpClient = options?._httpClient ?? defaultHttpClient;
479
+ // Helper to build error evidence (partial, before hostname resolution)
480
+ // P0.3: Respects evidenceLevel for redaction
481
+ const buildErrorEvidence = (code, canonicalHost = '[unknown]', isIpLiteral = false, dnsAnswers, selectedIp) => {
482
+ // Build redaction options for tenant mode
483
+ const redactOpts = evidenceLevel === 'tenant' && options?.redactionKey
484
+ ? { key: options.redactionKey, keyId: options?.redactionKeyId }
485
+ : undefined;
486
+ // Build base evidence (without digest - will be added by finalizeEvidence)
487
+ const core = {
488
+ schema_version: SAFE_FETCH_EVIDENCE_SCHEMA_VERSION,
489
+ evidence_level: evidenceLevel,
490
+ request_timestamp: requestTimestamp,
491
+ request_url: sanitizedUrl,
492
+ canonical_host: canonicalHost,
493
+ is_ip_literal: isIpLiteral,
494
+ policy_decision: 'block',
495
+ decision_code: code,
496
+ max_response_bytes: maxResponseBytes,
497
+ dropped_headers: droppedHeaders.length > 0 ? droppedHeaders : undefined,
498
+ };
499
+ // Add IP-related fields based on evidence level
500
+ if (selectedIp) {
501
+ if (isPrivateEvidence) {
502
+ core.selected_ip = selectedIp;
503
+ }
504
+ else {
505
+ core.selected_ip_info = redactIp(selectedIp, redactOpts);
506
+ }
507
+ }
508
+ // Add DNS answers based on evidence level
509
+ if (dnsAnswers && dnsAnswers.length > 0) {
510
+ if (isPrivateEvidence) {
511
+ core.dns_answers = dnsAnswers;
512
+ }
513
+ else {
514
+ // Public/tenant: only include counts
515
+ const ipv4Count = dnsAnswers.filter((a) => a.family === 4).length;
516
+ const ipv6Count = dnsAnswers.filter((a) => a.family === 6).length;
517
+ core.dns_answer_count = { ipv4: ipv4Count, ipv6: ipv6Count };
518
+ }
519
+ }
520
+ // Finalize with digest (pass auditCtx for request-scoped counters)
521
+ return finalizeEvidence(core, auditCtx);
522
+ };
523
+ // Step 0: Validate HTTP method (SSRF side-effect prevention)
524
+ const method = (options?.method ?? 'GET').toUpperCase();
525
+ if (!allowedMethods.map((m) => m.toUpperCase()).includes(method)) {
526
+ return {
527
+ ok: false,
528
+ error: `HTTP method '${method}' not allowed`,
529
+ code: SAFE_FETCH_ERROR_CODES.E_METHOD_NOT_ALLOWED,
530
+ evidence: buildErrorEvidence(SAFE_FETCH_ERROR_CODES.E_METHOD_NOT_ALLOWED),
531
+ };
532
+ }
533
+ // Step 1: Validate allowCidrs acknowledgment at DNS-time
534
+ const ackValidation = validateAllowCidrsAck(ssrfPolicy);
535
+ if (!ackValidation.valid) {
536
+ return {
537
+ ok: false,
538
+ error: 'SSRF policy configuration error',
539
+ code: SAFE_FETCH_ERROR_CODES.E_SSRF_ALLOWCIDRS_ACK_REQUIRED,
540
+ evidence: buildErrorEvidence(SAFE_FETCH_ERROR_CODES.E_SSRF_ALLOWCIDRS_ACK_REQUIRED),
541
+ };
542
+ }
543
+ // Validate allowMixedPublicAndPrivateDns acknowledgment
544
+ const allowMixedDns = options?.allowMixedPublicAndPrivateDns ?? false;
545
+ if (allowMixedDns && options?.ack_allow_mixed_dns !== ALLOW_MIXED_DNS_ACK) {
546
+ return {
547
+ ok: false,
548
+ error: 'Mixed DNS requires explicit acknowledgment',
549
+ code: SAFE_FETCH_ERROR_CODES.E_SSRF_MIXED_DNS_ACK_MISSING,
550
+ evidence: buildErrorEvidence(SAFE_FETCH_ERROR_CODES.E_SSRF_MIXED_DNS_ACK_MISSING),
551
+ };
552
+ }
553
+ // Step 2: String-level URL validation
554
+ const urlCheck = validateUrlForSSRF(url, ssrfPolicy);
555
+ if (!urlCheck.ok) {
556
+ return {
557
+ ok: false,
558
+ error: 'URL rejected by SSRF policy',
559
+ code: SAFE_FETCH_ERROR_CODES.E_SSRF_URL_REJECTED,
560
+ cause_code: urlCheck.code,
561
+ evidence: buildErrorEvidence(SAFE_FETCH_ERROR_CODES.E_SSRF_URL_REJECTED),
562
+ };
563
+ }
564
+ const parsedUrl = urlCheck.value;
565
+ const rawHostname = parsedUrl.hostname;
566
+ // Step 2.1: Dangerous port validation
567
+ // Get port number (default 443 for https, 80 for http)
568
+ const portStr = parsedUrl.port;
569
+ const effectivePort = portStr
570
+ ? parseInt(portStr, 10)
571
+ : parsedUrl.protocol === 'https:'
572
+ ? 443
573
+ : 80;
574
+ // Check if port is in dangerous ports list
575
+ if (DANGEROUS_PORTS.has(effectivePort)) {
576
+ const allowDangerous = options?.allowDangerousPorts ?? false;
577
+ if (allowDangerous && options?.ack_allow_dangerous_ports !== ALLOW_DANGEROUS_PORTS_ACK) {
578
+ return {
579
+ ok: false,
580
+ error: 'Dangerous port requires explicit acknowledgment',
581
+ code: SAFE_FETCH_ERROR_CODES.E_SSRF_DANGEROUS_PORT_ACK_MISSING,
582
+ evidence: buildErrorEvidence(SAFE_FETCH_ERROR_CODES.E_SSRF_DANGEROUS_PORT_ACK_MISSING),
583
+ };
584
+ }
585
+ if (!allowDangerous) {
586
+ return {
587
+ ok: false,
588
+ error: 'Port blocked by security policy',
589
+ code: SAFE_FETCH_ERROR_CODES.E_SSRF_DANGEROUS_PORT,
590
+ evidence: buildErrorEvidence(SAFE_FETCH_ERROR_CODES.E_SSRF_DANGEROUS_PORT),
591
+ };
592
+ }
593
+ // User explicitly acknowledged - allow through
594
+ warnings.push(`Dangerous port ${effectivePort} allowed via explicit acknowledgment`);
595
+ }
596
+ // Step 2.5: Canonical host validation (zone IDs, IDN, IPv4-mapped IPv6)
597
+ const hostResult = canonicalizeHost(rawHostname);
598
+ if (!hostResult.ok) {
599
+ return {
600
+ ...hostResult,
601
+ evidence: buildErrorEvidence(hostResult.code),
602
+ };
603
+ }
604
+ const hostname = hostResult.hostname;
605
+ // Track original host/origin for redirect validation
606
+ if (!internalOptions._originalHost) {
607
+ internalOptions._originalHost = hostname;
608
+ internalOptions._originalOrigin = parsedUrl.origin;
609
+ }
610
+ // Get the audit hook for event emission and optional sanitizer
611
+ const onEvent = options?.onEvent;
612
+ const hookSanitizer = options?.sanitizeHookError;
613
+ // Track DNS answers for evidence
614
+ let dnsAnswers;
615
+ // Step 3: DNS resolution with validation and pinning
616
+ let pinnedIp = null;
617
+ let dnsResult = null;
618
+ if (hostResult.isIP) {
619
+ // URL contains IP literal (may have been normalized from IPv4-mapped IPv6)
620
+ // No DNS resolution needed, but we still validate the IP
621
+ const isBlocked = isPrivateIP(hostname, ssrfPolicy);
622
+ dnsAnswers = [
623
+ {
624
+ ip: hostname,
625
+ family: hostname.includes(':') ? 6 : 4,
626
+ blocked_reason: isBlocked ? 'private_ip' : undefined,
627
+ },
628
+ ];
629
+ if (isBlocked) {
630
+ emitAuditEvent(auditCtx, onEvent, hookSanitizer, 'onEvent', {
631
+ type: 'dns_blocked',
632
+ timestamp: Date.now(),
633
+ url: sanitizedUrl,
634
+ hostname,
635
+ code: SAFE_FETCH_ERROR_CODES.E_SSRF_DNS_RESOLVED_PRIVATE,
636
+ message: 'IP literal blocked by policy',
637
+ policy_decision: 'block',
638
+ meta: debug ? { dns_answers: dnsAnswers } : undefined,
639
+ });
640
+ return {
641
+ ok: false,
642
+ error: 'IP literal blocked by policy',
643
+ code: SAFE_FETCH_ERROR_CODES.E_SSRF_DNS_RESOLVED_PRIVATE,
644
+ evidence: buildErrorEvidence(SAFE_FETCH_ERROR_CODES.E_SSRF_DNS_RESOLVED_PRIVATE, hostname, true, dnsAnswers),
645
+ };
646
+ }
647
+ pinnedIp = null; // No pinning needed for IP literals
648
+ }
649
+ else {
650
+ // Emit DNS start event
651
+ emitAuditEvent(auditCtx, onEvent, hookSanitizer, 'onEvent', {
652
+ type: 'dns_start',
653
+ timestamp: Date.now(),
654
+ url: sanitizedUrl,
655
+ hostname,
656
+ });
657
+ // DNS resolution with validation and P1.3 phase timeout
658
+ const dnsPromise = resolveDnsSecure(hostname, ssrfPolicy, dnsResolver, {
659
+ policy: ssrfPolicy,
660
+ allowMixedPublicAndPrivate: allowMixedDns,
661
+ });
662
+ const dnsWithTimeout = await withTimeout(dnsPromise, dnsTimeoutMs, SAFE_FETCH_ERROR_CODES.E_DNS_TIMEOUT);
663
+ // Handle DNS timeout
664
+ if (!dnsWithTimeout.ok) {
665
+ emitAuditEvent(auditCtx, onEvent, hookSanitizer, 'onEvent', {
666
+ type: 'dns_error',
667
+ timestamp: Date.now(),
668
+ url: sanitizedUrl,
669
+ hostname,
670
+ code: dnsWithTimeout.code,
671
+ message: 'DNS resolution timed out',
672
+ policy_decision: 'block',
673
+ });
674
+ return {
675
+ ok: false,
676
+ error: 'DNS resolution timed out',
677
+ code: dnsWithTimeout.code,
678
+ evidence: buildErrorEvidence(dnsWithTimeout.code, hostname, false),
679
+ };
680
+ }
681
+ const result = dnsWithTimeout.value;
682
+ if (!result.ok) {
683
+ dnsAnswers = result.dns_answers;
684
+ const eventType = result.code === SAFE_FETCH_ERROR_CODES.E_DNS_RESOLUTION_FAILED
685
+ ? 'dns_error'
686
+ : 'dns_blocked';
687
+ emitAuditEvent(auditCtx, onEvent, hookSanitizer, 'onEvent', {
688
+ type: eventType,
689
+ timestamp: Date.now(),
690
+ url: sanitizedUrl,
691
+ hostname,
692
+ code: result.code,
693
+ message: result.error,
694
+ policy_decision: 'block',
695
+ meta: debug ? { dns_answers: dnsAnswers } : undefined,
696
+ });
697
+ return {
698
+ ...result,
699
+ evidence: buildErrorEvidence(result.code, hostname, false, dnsAnswers),
700
+ };
701
+ }
702
+ dnsResult = result;
703
+ pinnedIp = result.selectedAddress;
704
+ // Build dns_answers for successful resolution (all allowed)
705
+ dnsAnswers = [
706
+ ...result.ipv4Addresses.map((ip) => ({ ip, family: 4 })),
707
+ ...result.ipv6Addresses.map((ip) => ({ ip, family: 6 })),
708
+ ];
709
+ // Emit DNS success event
710
+ emitAuditEvent(auditCtx, onEvent, hookSanitizer, 'onEvent', {
711
+ type: 'dns_success',
712
+ timestamp: Date.now(),
713
+ url: sanitizedUrl,
714
+ hostname,
715
+ selected_ip: pinnedIp,
716
+ policy_decision: 'allow',
717
+ meta: debug
718
+ ? {
719
+ dns_answers: dnsAnswers,
720
+ ipv4_count: result.ipv4Addresses.length,
721
+ ipv6_count: result.ipv6Addresses.length,
722
+ }
723
+ : {
724
+ ipv4_count: result.ipv4Addresses.length,
725
+ ipv6_count: result.ipv6Addresses.length,
726
+ },
727
+ });
728
+ }
729
+ // Step 4: Build request options with security defaults
730
+ const allowCompression = options?.allowCompression ?? false;
731
+ const fetchHeaders = new Headers(options?.headers);
732
+ // P0.3: Strip hop-by-hop headers (RFC 7230 Section 6.1)
733
+ // This prevents proxy confusion and request smuggling attacks
734
+ stripHopByHopHeaders(fetchHeaders);
735
+ // SECURITY: Drop user-provided Host header to prevent SSRF via Host manipulation
736
+ // The Host header must match the URL, not be controlled by the caller.
737
+ // This prevents attacks where attacker controls Host to redirect internal requests.
738
+ if (fetchHeaders.has('host')) {
739
+ droppedHeaders.push('host');
740
+ fetchHeaders.delete('host');
741
+ }
742
+ // P0.5: Prevent decompression bombs by requesting identity encoding by default
743
+ // Only applies if Accept-Encoding is not already set and compression is not explicitly allowed
744
+ //
745
+ // SECURITY NOTE: Headers.has() is case-insensitive per Fetch API spec.
746
+ // The Headers object normalizes all header names to lowercase internally.
747
+ // We check for 'accept-encoding' (lowercase) to be explicit about this behavior.
748
+ // This prevents bypass via 'Accept-Encoding', 'ACCEPT-ENCODING', or mixed case.
749
+ //
750
+ // NOTE: maxResponseBytes measures WIRE bytes (after identity encoding).
751
+ // If compression is enabled, the actual decoded size could exceed the budget.
752
+ // Only enable compression if you trust the server AND have additional decoded size limits.
753
+ if (!allowCompression && !fetchHeaders.has('accept-encoding')) {
754
+ fetchHeaders.set('Accept-Encoding', 'identity');
755
+ }
756
+ const fetchOptions = {
757
+ ...options,
758
+ headers: fetchHeaders,
759
+ timeoutMs: totalTimeoutMs,
760
+ };
761
+ // Step 5: Make the request with DNS pinning
762
+ // Emit request start event with dropped headers info
763
+ emitAuditEvent(auditCtx, onEvent, hookSanitizer, 'onEvent', {
764
+ type: 'request_start',
765
+ timestamp: Date.now(),
766
+ url: sanitizedUrl,
767
+ hostname,
768
+ selected_ip: pinnedIp ?? hostname,
769
+ meta: {
770
+ method,
771
+ dropped_headers: droppedHeaders.length > 0 ? droppedHeaders : undefined,
772
+ },
773
+ });
774
+ let fetchResult;
775
+ try {
776
+ if (dnsResult) {
777
+ // Use Happy Eyeballs for dual-stack hosts
778
+ fetchResult = await attemptWithFallback(url, dnsResult, httpClient, fetchOptions);
779
+ }
780
+ else {
781
+ // IP literal - direct fetch
782
+ fetchResult = await httpClient.fetch(url, pinnedIp, fetchOptions);
783
+ }
784
+ }
785
+ catch (err) {
786
+ const isTimeout = err instanceof Error && err.name === 'AbortError';
787
+ const code = isTimeout
788
+ ? SAFE_FETCH_ERROR_CODES.E_REQUEST_TIMEOUT
789
+ : SAFE_FETCH_ERROR_CODES.E_NETWORK_ERROR;
790
+ emitAuditEvent(auditCtx, onEvent, hookSanitizer, 'onEvent', {
791
+ type: 'error',
792
+ timestamp: Date.now(),
793
+ url: sanitizedUrl,
794
+ hostname,
795
+ selected_ip: pinnedIp ?? undefined,
796
+ code,
797
+ // Generic error messages - no internal details
798
+ message: isTimeout ? 'Request timed out' : 'Network error',
799
+ });
800
+ const elapsedMs = Date.now() - requestTimestamp;
801
+ // Build redaction options for tenant mode
802
+ const redactOpts = evidenceLevel === 'tenant' && options?.redactionKey
803
+ ? { key: options.redactionKey, keyId: options?.redactionKeyId }
804
+ : undefined;
805
+ // P0.3: Build network error evidence with proper redaction
806
+ const errorCore = {
807
+ schema_version: SAFE_FETCH_EVIDENCE_SCHEMA_VERSION,
808
+ evidence_level: evidenceLevel,
809
+ request_timestamp: requestTimestamp,
810
+ request_url: sanitizedUrl,
811
+ canonical_host: hostname,
812
+ is_ip_literal: hostResult.isIP,
813
+ // Public/tenant evidence: include redacted IP info
814
+ ...(pinnedIp && !isPrivateEvidence
815
+ ? { selected_ip_info: redactIp(pinnedIp, redactOpts) }
816
+ : {}),
817
+ // Private evidence: include raw details
818
+ ...(isPrivateEvidence ? { dns_answers: dnsAnswers } : {}),
819
+ ...(isPrivateEvidence && pinnedIp ? { selected_ip: pinnedIp } : {}),
820
+ // Public/tenant evidence: include DNS answer counts
821
+ ...(!isPrivateEvidence && dnsAnswers.length > 0
822
+ ? {
823
+ dns_answer_count: {
824
+ ipv4: dnsAnswers.filter((a) => a.family === 4).length,
825
+ ipv6: dnsAnswers.filter((a) => a.family === 6).length,
826
+ },
827
+ }
828
+ : {}),
829
+ policy_decision: 'block',
830
+ decision_code: code,
831
+ max_response_bytes: maxResponseBytes,
832
+ elapsed_ms: elapsedMs,
833
+ dropped_headers: droppedHeaders.length > 0 ? droppedHeaders : undefined,
834
+ };
835
+ const errorEvidence = finalizeEvidence(errorCore, auditCtx);
836
+ if (isTimeout) {
837
+ return {
838
+ ok: false,
839
+ error: 'Request timed out',
840
+ code: SAFE_FETCH_ERROR_CODES.E_REQUEST_TIMEOUT,
841
+ evidence: errorEvidence,
842
+ };
843
+ }
844
+ return {
845
+ ok: false,
846
+ error: 'Network error',
847
+ code: SAFE_FETCH_ERROR_CODES.E_NETWORK_ERROR,
848
+ evidence: errorEvidence,
849
+ };
850
+ }
851
+ const { response, close } = fetchResult;
852
+ const responseTimestamp = Date.now();
853
+ // Emit response headers event
854
+ emitAuditEvent(auditCtx, onEvent, hookSanitizer, 'onEvent', {
855
+ type: 'response_headers',
856
+ timestamp: responseTimestamp,
857
+ url: sanitizedUrl,
858
+ hostname,
859
+ selected_ip: pinnedIp ?? undefined,
860
+ meta: {
861
+ status: response.status,
862
+ statusText: response.statusText,
863
+ },
864
+ });
865
+ // Helper to build redirect error evidence
866
+ // P0.3: Respects evidenceLevel for redaction
867
+ const buildRedirectErrorEvidence = (code) => {
868
+ // Build redaction options for tenant mode
869
+ const redactOpts = evidenceLevel === 'tenant' && options?.redactionKey
870
+ ? { key: options.redactionKey, keyId: options?.redactionKeyId }
871
+ : undefined;
872
+ const core = {
873
+ schema_version: SAFE_FETCH_EVIDENCE_SCHEMA_VERSION,
874
+ evidence_level: evidenceLevel,
875
+ request_timestamp: requestTimestamp,
876
+ response_timestamp: responseTimestamp,
877
+ request_url: sanitizedUrl,
878
+ canonical_host: hostname,
879
+ is_ip_literal: hostResult.isIP,
880
+ policy_decision: 'block',
881
+ decision_code: code,
882
+ response_status: response.status,
883
+ max_response_bytes: maxResponseBytes,
884
+ elapsed_ms: responseTimestamp - requestTimestamp,
885
+ dropped_headers: droppedHeaders.length > 0 ? droppedHeaders : undefined,
886
+ };
887
+ // Add IP-related fields based on evidence level
888
+ if (pinnedIp) {
889
+ if (isPrivateEvidence) {
890
+ core.selected_ip = pinnedIp;
891
+ }
892
+ else {
893
+ core.selected_ip_info = redactIp(pinnedIp, redactOpts);
894
+ }
895
+ }
896
+ // Add DNS answers based on evidence level
897
+ if (dnsAnswers && dnsAnswers.length > 0) {
898
+ if (isPrivateEvidence) {
899
+ core.dns_answers = dnsAnswers;
900
+ }
901
+ else {
902
+ core.dns_answer_count = {
903
+ ipv4: dnsAnswers.filter((a) => a.family === 4).length,
904
+ ipv6: dnsAnswers.filter((a) => a.family === 6).length,
905
+ };
906
+ }
907
+ }
908
+ return finalizeEvidence(core, auditCtx);
909
+ };
910
+ // Step 6: Handle redirects
911
+ if (response.status >= 300 && response.status < 400) {
912
+ // Close current response before following redirect
913
+ await close();
914
+ const location = response.headers.get('location');
915
+ if (!location) {
916
+ return {
917
+ ok: false,
918
+ error: 'Redirect response missing Location header',
919
+ code: SAFE_FETCH_ERROR_CODES.E_SSRF_REDIRECT_BLOCKED,
920
+ evidence: buildRedirectErrorEvidence(SAFE_FETCH_ERROR_CODES.E_SSRF_REDIRECT_BLOCKED),
921
+ };
922
+ }
923
+ // Check redirect count
924
+ if (internalOptions._redirectCount >= maxRedirects) {
925
+ return {
926
+ ok: false,
927
+ error: 'Too many redirects',
928
+ code: SAFE_FETCH_ERROR_CODES.E_SSRF_TOO_MANY_REDIRECTS,
929
+ evidence: buildRedirectErrorEvidence(SAFE_FETCH_ERROR_CODES.E_SSRF_TOO_MANY_REDIRECTS),
930
+ };
931
+ }
932
+ // Resolve redirect URL
933
+ const redirectUrl = new URL(location, url);
934
+ const originalUrl = new URL(internalOptions._originalOrigin);
935
+ // Emit redirect event
936
+ emitAuditEvent(auditCtx, onEvent, hookSanitizer, 'onEvent', {
937
+ type: 'redirect',
938
+ timestamp: Date.now(),
939
+ url: sanitizedUrl,
940
+ hostname,
941
+ selected_ip: pinnedIp ?? undefined,
942
+ meta: {
943
+ redirect_to: sanitizeUrl(redirectUrl.toString()),
944
+ redirect_count: internalOptions._redirectCount + 1,
945
+ },
946
+ });
947
+ // Check redirect policy
948
+ if (!isRedirectAllowed(originalUrl, redirectUrl, redirectPolicy, options?.redirectAllowHosts)) {
949
+ return {
950
+ ok: false,
951
+ error: 'Redirect blocked by policy',
952
+ code: SAFE_FETCH_ERROR_CODES.E_SSRF_REDIRECT_BLOCKED,
953
+ evidence: buildRedirectErrorEvidence(SAFE_FETCH_ERROR_CODES.E_SSRF_REDIRECT_BLOCKED),
954
+ };
955
+ }
956
+ // Recursive call with incremented redirect count
957
+ // IMPORTANT: Full re-validation occurs for redirects
958
+ return safeFetchRaw(redirectUrl.toString(), {
959
+ ...options,
960
+ _redirectCount: internalOptions._redirectCount + 1,
961
+ _originalHost: internalOptions._originalHost,
962
+ _originalOrigin: internalOptions._originalOrigin,
963
+ });
964
+ }
965
+ // Build success evidence with P0.3 redaction
966
+ const effectiveIp = pinnedIp ?? (hostResult.isIP ? hostname : undefined);
967
+ // Build redaction options for tenant mode
968
+ const redactOptsSuccess = evidenceLevel === 'tenant' && options?.redactionKey
969
+ ? { key: options.redactionKey, keyId: options?.redactionKeyId }
970
+ : undefined;
971
+ const successCore = {
972
+ schema_version: SAFE_FETCH_EVIDENCE_SCHEMA_VERSION,
973
+ evidence_level: evidenceLevel,
974
+ request_timestamp: requestTimestamp,
975
+ response_timestamp: responseTimestamp,
976
+ request_url: sanitizedUrl,
977
+ canonical_host: hostname,
978
+ is_ip_literal: hostResult.isIP,
979
+ // Public/tenant evidence: include redacted IP info
980
+ ...(effectiveIp && !isPrivateEvidence
981
+ ? { selected_ip_info: redactIp(effectiveIp, redactOptsSuccess) }
982
+ : {}),
983
+ // Private evidence: include raw details
984
+ ...(isPrivateEvidence ? { dns_answers: dnsAnswers } : {}),
985
+ ...(isPrivateEvidence && effectiveIp ? { selected_ip: effectiveIp } : {}),
986
+ // Public/tenant evidence: include DNS answer counts
987
+ ...(!isPrivateEvidence && dnsAnswers.length > 0
988
+ ? {
989
+ dns_answer_count: {
990
+ ipv4: dnsAnswers.filter((a) => a.family === 4).length,
991
+ ipv6: dnsAnswers.filter((a) => a.family === 6).length,
992
+ },
993
+ }
994
+ : {}),
995
+ policy_decision: 'allow',
996
+ decision_code: 'OK',
997
+ response_status: response.status,
998
+ max_response_bytes: maxResponseBytes,
999
+ elapsed_ms: responseTimestamp - requestTimestamp,
1000
+ dropped_headers: droppedHeaders.length > 0 ? droppedHeaders : undefined,
1001
+ };
1002
+ const successEvidence = finalizeEvidence(successCore, auditCtx);
1003
+ return {
1004
+ ok: true,
1005
+ response,
1006
+ close,
1007
+ warnings: warnings.length > 0 ? warnings : undefined,
1008
+ evidence: successEvidence,
1009
+ };
1010
+ }
1011
+ /**
1012
+ * SSRF-safe JSON fetch with proper DNS resolution pinning and size limits
1013
+ *
1014
+ * This is the recommended function for fetching JSON APIs (JWKS, OIDC, etc.)
1015
+ */
1016
+ export async function safeFetchJson(url, options) {
1017
+ const maxResponseBytes = options?.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
1018
+ const rawResult = await safeFetchRaw(url, options);
1019
+ if (!rawResult.ok) {
1020
+ return rawResult;
1021
+ }
1022
+ const { response, close, warnings, evidence } = rawResult;
1023
+ try {
1024
+ // Read body with size limit
1025
+ const bodyResult = await readBodyWithLimit(response, maxResponseBytes);
1026
+ if (!bodyResult.ok) {
1027
+ // Add evidence to body read errors (recompute digest)
1028
+ const { evidence_digest, evidence_alg, canonicalization, ...baseEvidence } = evidence;
1029
+ const errorCore = {
1030
+ ...baseEvidence,
1031
+ policy_decision: 'block',
1032
+ decision_code: bodyResult.code,
1033
+ };
1034
+ return {
1035
+ ...bodyResult,
1036
+ evidence: finalizeEvidence(errorCore),
1037
+ };
1038
+ }
1039
+ // Parse JSON
1040
+ const text = new TextDecoder().decode(bodyResult.body);
1041
+ const data = JSON.parse(text);
1042
+ // Update evidence with actual response bytes (recompute digest)
1043
+ const { evidence_digest, evidence_alg, canonicalization, ...baseEvidence } = evidence;
1044
+ const finalCore = {
1045
+ ...baseEvidence,
1046
+ response_bytes: bodyResult.body.byteLength,
1047
+ };
1048
+ const finalEvidence = finalizeEvidence(finalCore);
1049
+ return {
1050
+ ok: true,
1051
+ response,
1052
+ data,
1053
+ warnings,
1054
+ evidence: finalEvidence,
1055
+ };
1056
+ }
1057
+ catch (err) {
1058
+ // Strip digest fields before modifying
1059
+ const { evidence_digest, evidence_alg, canonicalization, ...baseEvidence } = evidence;
1060
+ if (err instanceof SyntaxError) {
1061
+ const errorCore = {
1062
+ ...baseEvidence,
1063
+ policy_decision: 'block',
1064
+ decision_code: SAFE_FETCH_ERROR_CODES.E_PARSE_ERROR,
1065
+ };
1066
+ return {
1067
+ ok: false,
1068
+ error: 'Failed to parse JSON response',
1069
+ code: SAFE_FETCH_ERROR_CODES.E_PARSE_ERROR,
1070
+ evidence: finalizeEvidence(errorCore),
1071
+ };
1072
+ }
1073
+ const errorCore = {
1074
+ ...baseEvidence,
1075
+ policy_decision: 'block',
1076
+ decision_code: SAFE_FETCH_ERROR_CODES.E_NETWORK_ERROR,
1077
+ };
1078
+ return {
1079
+ ok: false,
1080
+ error: 'Failed to read response body',
1081
+ code: SAFE_FETCH_ERROR_CODES.E_NETWORK_ERROR,
1082
+ evidence: finalizeEvidence(errorCore),
1083
+ };
1084
+ }
1085
+ finally {
1086
+ // IMPORTANT: Always close the agent after body consumption
1087
+ await close();
1088
+ }
1089
+ }
1090
+ /**
1091
+ * SSRF-safe fetch with JSON parsing (legacy API, use safeFetchJson for new code)
1092
+ *
1093
+ * @deprecated Use safeFetchJson for better type safety and structured errors
1094
+ */
1095
+ export async function safeFetch(url, options) {
1096
+ return safeFetchJson(url, options);
1097
+ }
1098
+ /**
1099
+ * SSRF-safe fetch for JWKS endpoints
1100
+ *
1101
+ * Convenience wrapper around safeFetchJson with JWKS-specific defaults:
1102
+ * - Shorter timeout (10 seconds)
1103
+ * - No redirects by default
1104
+ * - Smaller response size limit (512 KB)
1105
+ * - Type-safe JWKS response
1106
+ */
1107
+ export async function safeFetchJWKS(url, options) {
1108
+ return safeFetchJson(url, {
1109
+ ...options,
1110
+ timeoutMs: options?.timeoutMs ?? 10000,
1111
+ maxRedirects: 0, // JWKS endpoints should not redirect
1112
+ maxResponseBytes: options?.maxResponseBytes ?? MAX_JWKS_RESPONSE_BYTES,
1113
+ });
1114
+ }
1115
+ // Receipt URL resolver (v0.11.2+)
1116
+ export { resolveReceiptUrl, verifyReceiptRef } from './receipt-resolver.js';
1117
+ // Receipt URL resolution middleware (v0.12.6+, DD-207)
1118
+ export { createReceiptUrlResolver } from './receipt-url-middleware.js';
1119
+ // NOTE: Internal functions are now in impl.ts and NOT exported from main entry.
1120
+ // Access internal utilities via '@peac/net-node/testing' subpath only.
1121
+ //# sourceMappingURL=index.js.map