@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/impl.js ADDED
@@ -0,0 +1,1188 @@
1
+ /**
2
+ * Internal Implementation for @peac/net-node
3
+ *
4
+ * This module contains internal helper functions that are NOT part of the public API.
5
+ * These functions are used internally by index.ts and exposed ONLY via the testing subpath.
6
+ *
7
+ * IMPORTANT: This module is NOT listed in package.json exports, so it cannot be
8
+ * directly imported by consumers. Only the testing subpath re-exports from here.
9
+ *
10
+ * @internal
11
+ * @module @peac/net-node/impl
12
+ */
13
+ import { createHash, createHmac } from 'crypto';
14
+ import ipaddr from 'ipaddr.js';
15
+ import { getDomain, parse as parseDomain } from 'tldts';
16
+ import { Agent } from 'undici';
17
+ // Local SSRF types (not from @peac/schema to keep package self-contained)
18
+ import { ALLOW_DANGEROUS_CIDRS_ACK } from './ssrf.js';
19
+ // Import from single source of truth modules (no circular deps)
20
+ import { SAFE_FETCH_ERROR_CODES } from './codes.js';
21
+ import { MAX_PENDING_AUDIT_EVENTS, SAFE_FETCH_EVENT_SCHEMA_VERSION } from './constants.js';
22
+ // Re-export for consumers
23
+ export { MAX_PENDING_AUDIT_EVENTS };
24
+ // -----------------------------------------------------------------------------
25
+ // Constants
26
+ // -----------------------------------------------------------------------------
27
+ /**
28
+ * Happy Eyeballs connection timeout for first attempt (250ms)
29
+ */
30
+ export const HAPPY_EYEBALLS_FIRST_TIMEOUT_MS = 250;
31
+ /**
32
+ * Hop-by-hop headers that MUST NOT be forwarded (RFC 7230 Section 6.1)
33
+ * These are stripped from requests to prevent proxy confusion and smuggling
34
+ */
35
+ export const HOP_BY_HOP_HEADERS = new Set([
36
+ 'connection',
37
+ 'keep-alive',
38
+ 'proxy-authenticate',
39
+ 'proxy-authorization',
40
+ 'te',
41
+ 'trailer',
42
+ 'transfer-encoding',
43
+ 'upgrade',
44
+ // Common non-standard hop-by-hop headers
45
+ 'proxy-connection',
46
+ ]);
47
+ // -----------------------------------------------------------------------------
48
+ // RFC 6890-grade IP Range Classification
49
+ // Uses ipaddr.js range() for comprehensive special-use detection
50
+ // -----------------------------------------------------------------------------
51
+ /**
52
+ * Blocked IPv4 range names from ipaddr.js
53
+ * These are always blocked (fail-closed security)
54
+ */
55
+ export const BLOCKED_IPV4_RANGES = new Set([
56
+ 'unspecified', // 0.0.0.0/8
57
+ 'broadcast', // 255.255.255.255/32
58
+ 'loopback', // 127.0.0.0/8
59
+ 'private', // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
60
+ 'linkLocal', // 169.254.0.0/16
61
+ 'carrierGradeNat', // 100.64.0.0/10 (CGNAT - special handling)
62
+ 'reserved', // 240.0.0.0/4
63
+ 'multicast', // 224.0.0.0/4
64
+ ]);
65
+ /**
66
+ * Additional IPv4 CIDR ranges to block (RFC 6890 compliance)
67
+ * Not covered by ipaddr.js range() but should be blocked
68
+ */
69
+ export const ADDITIONAL_BLOCKED_CIDRS_V4 = [
70
+ [ipaddr.IPv4.parse('192.0.0.0'), 24], // IETF Protocol Assignments
71
+ [ipaddr.IPv4.parse('192.0.2.0'), 24], // Documentation (TEST-NET-1)
72
+ [ipaddr.IPv4.parse('198.18.0.0'), 15], // Benchmarking
73
+ [ipaddr.IPv4.parse('198.51.100.0'), 24], // Documentation (TEST-NET-2)
74
+ [ipaddr.IPv4.parse('203.0.113.0'), 24], // Documentation (TEST-NET-3)
75
+ ];
76
+ /**
77
+ * Blocked IPv6 range names from ipaddr.js
78
+ */
79
+ export const BLOCKED_IPV6_RANGES = new Set([
80
+ 'unspecified', // ::/128
81
+ 'loopback', // ::1/128
82
+ 'linkLocal', // fe80::/10
83
+ 'uniqueLocal', // fc00::/7
84
+ 'multicast', // ff00::/8
85
+ 'reserved', // various reserved ranges
86
+ ]);
87
+ /**
88
+ * Additional IPv6 CIDR ranges to block
89
+ */
90
+ export const ADDITIONAL_BLOCKED_CIDRS_V6 = [
91
+ [ipaddr.IPv6.parse('2001:db8::'), 32], // Documentation
92
+ [ipaddr.IPv6.parse('2001::'), 23], // TEREDO (deprecated)
93
+ [ipaddr.IPv6.parse('2002::'), 16], // 6to4 (deprecated)
94
+ [ipaddr.IPv6.parse('64:ff9b::'), 96], // NAT64
95
+ [ipaddr.IPv6.parse('100::'), 64], // Discard prefix
96
+ ];
97
+ // Error codes imported from codes.ts (single source of truth)
98
+ // Re-export for testing subpath consumers
99
+ export { SAFE_FETCH_ERROR_CODES };
100
+ // -----------------------------------------------------------------------------
101
+ // P0.3: Evidence Redaction Helpers
102
+ // -----------------------------------------------------------------------------
103
+ /**
104
+ * Hash an IP address for public evidence using SHA-256
105
+ *
106
+ * Returns a 0x-prefixed lowercase hex string for consistency with
107
+ * other PEAC cryptographic identifiers (e.g., EAS anchors).
108
+ *
109
+ * @param ip - Raw IP address string
110
+ * @returns SHA-256 hash as 0x-prefixed lowercase hex
111
+ * @internal
112
+ */
113
+ export function hashIpAddress(ip) {
114
+ const hash = createHash('sha256').update(ip, 'utf8').digest('hex');
115
+ return `0x${hash}`;
116
+ }
117
+ /**
118
+ * HMAC-SHA256 hash an IP address for tenant-level evidence
119
+ *
120
+ * Uses keyed hashing to prevent cross-tenant correlation via rainbow tables.
121
+ * Unlike plain SHA-256, this requires knowledge of the key to correlate.
122
+ *
123
+ * @param ip - Raw IP address string
124
+ * @param key - HMAC key (must be at least 32 bytes)
125
+ * @returns HMAC-SHA256 hash as 0x-prefixed lowercase hex
126
+ * @internal
127
+ */
128
+ function hmacIpAddress(ip, key) {
129
+ if (key.length < 32) {
130
+ throw new Error('Redaction key must be at least 32 bytes for security');
131
+ }
132
+ const hash = createHmac('sha256', key).update(ip, 'utf8').digest('hex');
133
+ return `0x${hash}`;
134
+ }
135
+ /**
136
+ * Create redacted IP info for public evidence
137
+ *
138
+ * @param ip - Raw IP address string
139
+ * @param options - Optional HMAC key for tenant mode
140
+ * @returns Redacted info with family and hash
141
+ * @internal
142
+ */
143
+ export function redactIp(ip, options) {
144
+ const isIPv6 = ip.includes(':');
145
+ const result = {
146
+ family: isIPv6 ? 6 : 4,
147
+ hash: options?.key ? hmacIpAddress(ip, options.key) : hashIpAddress(ip),
148
+ };
149
+ if (options?.keyId) {
150
+ result.key_id = options.keyId;
151
+ }
152
+ return result;
153
+ }
154
+ /**
155
+ * Case-insensitive header presence check
156
+ *
157
+ * Works with any header input type (Headers, array, object).
158
+ * The Fetch API's Headers object is case-insensitive by spec, but this helper
159
+ * provides explicit case-insensitive checking for any input format.
160
+ *
161
+ * @param headers - Headers, array of [name, value] pairs, or plain object
162
+ * @param name - Header name to check (case-insensitive)
163
+ * @returns true if header exists (any casing)
164
+ * @internal
165
+ */
166
+ export function hasHeaderCaseInsensitive(headers, name) {
167
+ if (!headers)
168
+ return false;
169
+ const lowerName = name.toLowerCase();
170
+ if (headers instanceof Headers) {
171
+ // Headers.has() is case-insensitive by spec
172
+ return headers.has(lowerName);
173
+ }
174
+ if (Array.isArray(headers)) {
175
+ return headers.some(([k]) => k.toLowerCase() === lowerName);
176
+ }
177
+ // Plain object
178
+ return Object.keys(headers).some((k) => k.toLowerCase() === lowerName);
179
+ }
180
+ /**
181
+ * Parse a CIDR string into an ipaddr.js address and prefix length
182
+ * @internal
183
+ */
184
+ export function parseCidr(cidrStr) {
185
+ try {
186
+ const [network, prefixLen] = ipaddr.parseCIDR(cidrStr);
187
+ return [network, prefixLen];
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ }
193
+ /**
194
+ * Check if an IP address matches any CIDR in a list
195
+ * @internal
196
+ */
197
+ export function matchesAnyCidr(addr, cidrStrings) {
198
+ for (const cidrStr of cidrStrings) {
199
+ const parsed = parseCidr(cidrStr);
200
+ if (!parsed)
201
+ continue;
202
+ const [network, prefixLen] = parsed;
203
+ if (addr.kind() !== network.kind())
204
+ continue;
205
+ if (addr.match(network, prefixLen)) {
206
+ return true;
207
+ }
208
+ }
209
+ return false;
210
+ }
211
+ /**
212
+ * Convert IPv4 to uint32 for range comparison
213
+ * @internal
214
+ */
215
+ function ipv4ToUint32(ip) {
216
+ const octets = ip.octets;
217
+ return (octets[0] << 24) + (octets[1] << 16) + (octets[2] << 8) + octets[3];
218
+ }
219
+ /**
220
+ * Convert IPv6 to BigInt for range comparison
221
+ * @internal
222
+ */
223
+ function ipv6ToBigInt(ip) {
224
+ const parts = ip.parts;
225
+ let result = BigInt(0);
226
+ for (let i = 0; i < 8; i++) {
227
+ result = (result << BigInt(16)) + BigInt(parts[i]);
228
+ }
229
+ return result;
230
+ }
231
+ /**
232
+ * Calculate CIDR end address for IPv4
233
+ * @internal
234
+ */
235
+ function ipv4CidrEnd(network, prefixLen) {
236
+ const start = ipv4ToUint32(network);
237
+ const hostBits = 32 - prefixLen;
238
+ return start + ((1 << hostBits) - 1);
239
+ }
240
+ /**
241
+ * Calculate CIDR end address for IPv6
242
+ * @internal
243
+ */
244
+ function ipv6CidrEnd(network, prefixLen) {
245
+ const start = ipv6ToBigInt(network);
246
+ const hostBits = BigInt(128 - prefixLen);
247
+ return start + ((BigInt(1) << hostBits) - BigInt(1));
248
+ }
249
+ /**
250
+ * Check if two IPv4 CIDR ranges overlap using robust range comparison
251
+ * @internal
252
+ */
253
+ function ipv4RangesOverlap(net1, prefix1, net2, prefix2) {
254
+ const start1 = ipv4ToUint32(net1);
255
+ const end1 = ipv4CidrEnd(net1, prefix1);
256
+ const start2 = ipv4ToUint32(net2);
257
+ const end2 = ipv4CidrEnd(net2, prefix2);
258
+ // Ranges overlap if one starts before the other ends
259
+ return start1 <= end2 && start2 <= end1;
260
+ }
261
+ /**
262
+ * Check if two IPv6 CIDR ranges overlap using robust range comparison
263
+ * @internal
264
+ */
265
+ function ipv6RangesOverlap(net1, prefix1, net2, prefix2) {
266
+ const start1 = ipv6ToBigInt(net1);
267
+ const end1 = ipv6CidrEnd(net1, prefix1);
268
+ const start2 = ipv6ToBigInt(net2);
269
+ const end2 = ipv6CidrEnd(net2, prefix2);
270
+ // Ranges overlap if one starts before the other ends
271
+ return start1 <= end2 && start2 <= end1;
272
+ }
273
+ /**
274
+ * Check if a CIDR string overlaps with any dangerous/private ranges
275
+ * Uses robust range-based overlap detection (conservative - false positives OK)
276
+ * @internal
277
+ */
278
+ export function cidrOverlapsDangerousRanges(cidrStr) {
279
+ const parsed = parseCidr(cidrStr);
280
+ if (!parsed) {
281
+ // If we can't parse, treat as dangerous (fail-closed)
282
+ return true;
283
+ }
284
+ const [network, prefixLen] = parsed;
285
+ try {
286
+ if (network.kind() === 'ipv4') {
287
+ // Type assertion: we've confirmed it's IPv4 via kind() check
288
+ const networkV4 = network;
289
+ // Check if the network address itself falls into a blocked range
290
+ const range = networkV4.range();
291
+ if (BLOCKED_IPV4_RANGES.has(range)) {
292
+ return true;
293
+ }
294
+ // Check overlap with additional blocked CIDRs using robust range comparison
295
+ for (const [blockedNet, blockedPrefix] of ADDITIONAL_BLOCKED_CIDRS_V4) {
296
+ if (ipv4RangesOverlap(networkV4, prefixLen, blockedNet, blockedPrefix)) {
297
+ return true;
298
+ }
299
+ }
300
+ // Also check if any IP in the CIDR could fall into ipaddr.js special ranges
301
+ // Sample endpoints and midpoint of the CIDR
302
+ const samplePoints = [
303
+ ipv4ToUint32(networkV4),
304
+ ipv4CidrEnd(networkV4, prefixLen),
305
+ Math.floor((ipv4ToUint32(networkV4) + ipv4CidrEnd(networkV4, prefixLen)) / 2),
306
+ ];
307
+ for (const point of samplePoints) {
308
+ try {
309
+ const testIp = ipaddr.IPv4.parse(`${(point >>> 24) & 0xff}.${(point >>> 16) & 0xff}.${(point >>> 8) & 0xff}.${point & 0xff}`);
310
+ if (BLOCKED_IPV4_RANGES.has(testIp.range())) {
311
+ return true;
312
+ }
313
+ }
314
+ catch {
315
+ // Parsing error, skip this sample
316
+ }
317
+ }
318
+ }
319
+ else {
320
+ // Type assertion: we've confirmed it's IPv6 via kind() check
321
+ const networkV6 = network;
322
+ // IPv6
323
+ const range = networkV6.range();
324
+ if (BLOCKED_IPV6_RANGES.has(range)) {
325
+ return true;
326
+ }
327
+ for (const [blockedNet, blockedPrefix] of ADDITIONAL_BLOCKED_CIDRS_V6) {
328
+ if (ipv6RangesOverlap(networkV6, prefixLen, blockedNet, blockedPrefix)) {
329
+ return true;
330
+ }
331
+ }
332
+ }
333
+ }
334
+ catch {
335
+ // Any error during calculation, treat as dangerous (fail-closed)
336
+ return true;
337
+ }
338
+ return false;
339
+ }
340
+ /**
341
+ * Check if an IP address is private/reserved using RFC 6890-grade classification
342
+ * @internal
343
+ */
344
+ export function isPrivateIP(ip, policy) {
345
+ let addr;
346
+ try {
347
+ addr = ipaddr.process(ip);
348
+ }
349
+ catch {
350
+ // Invalid IP - treat as private (fail-closed)
351
+ return true;
352
+ }
353
+ // Check allowCidrs first (higher precedence)
354
+ // But only if dangerous ack is provided when allowCidrs contains dangerous ranges
355
+ if (policy?.allowCidrs && policy.allowCidrs.length > 0) {
356
+ // Validate ack is present if any allowCidr overlaps dangerous ranges
357
+ const hasDangerousAllowCidr = policy.allowCidrs.some(cidrOverlapsDangerousRanges);
358
+ const hasAck = policy.ack_allow_dangerous_cidrs === ALLOW_DANGEROUS_CIDRS_ACK;
359
+ if (hasDangerousAllowCidr && !hasAck) {
360
+ // Fail-closed: ignore allowCidrs without proper ack
361
+ // The caller should check this before calling
362
+ }
363
+ else if (matchesAnyCidr(addr, policy.allowCidrs)) {
364
+ return false; // Explicitly allowed
365
+ }
366
+ }
367
+ // Check custom blockedCidrs
368
+ if (policy?.blockedCidrs && policy.blockedCidrs.length > 0) {
369
+ if (matchesAnyCidr(addr, policy.blockedCidrs)) {
370
+ return true; // Explicitly blocked
371
+ }
372
+ }
373
+ // Use ipaddr.js range() for RFC 6890-grade classification
374
+ const range = addr.range();
375
+ if (addr.kind() === 'ipv4') {
376
+ // Check if range is blocked
377
+ if (BLOCKED_IPV4_RANGES.has(range)) {
378
+ // Special case: CGNAT requires explicit ack
379
+ if (range === 'carrierGradeNat') {
380
+ const cgnatAllowed = policy?.allowCgnat && policy?.ack_allow_cgnat === 'I_UNDERSTAND_CGNAT_SECURITY_RISKS';
381
+ return !cgnatAllowed;
382
+ }
383
+ return true;
384
+ }
385
+ // Check additional blocked CIDRs
386
+ for (const [network, prefix] of ADDITIONAL_BLOCKED_CIDRS_V4) {
387
+ if (addr.match(network, prefix)) {
388
+ return true;
389
+ }
390
+ }
391
+ }
392
+ else {
393
+ // IPv6
394
+ if (BLOCKED_IPV6_RANGES.has(range)) {
395
+ return true;
396
+ }
397
+ // Check additional blocked CIDRs
398
+ for (const [network, prefix] of ADDITIONAL_BLOCKED_CIDRS_V6) {
399
+ if (addr.match(network, prefix)) {
400
+ return true;
401
+ }
402
+ }
403
+ }
404
+ // Only unicast global addresses are allowed
405
+ return range !== 'unicast';
406
+ }
407
+ /**
408
+ * Validate that allowCidrs has proper acknowledgment if it contains dangerous ranges
409
+ * @internal
410
+ */
411
+ export function validateAllowCidrsAck(policy) {
412
+ if (!policy?.allowCidrs || policy.allowCidrs.length === 0) {
413
+ return { valid: true };
414
+ }
415
+ const hasDangerousAllowCidr = policy.allowCidrs.some(cidrOverlapsDangerousRanges);
416
+ if (hasDangerousAllowCidr) {
417
+ if (policy.ack_allow_dangerous_cidrs !== ALLOW_DANGEROUS_CIDRS_ACK) {
418
+ return {
419
+ valid: false,
420
+ error: `allowCidrs contains private/dangerous ranges but ack_allow_dangerous_cidrs is not set to "${ALLOW_DANGEROUS_CIDRS_ACK}"`,
421
+ };
422
+ }
423
+ }
424
+ return { valid: true };
425
+ }
426
+ // -----------------------------------------------------------------------------
427
+ // Canonical Host Pipeline
428
+ // Single source of truth for hostname canonicalization
429
+ // -----------------------------------------------------------------------------
430
+ /**
431
+ * Check if a hostname contains an IPv6 zone identifier
432
+ *
433
+ * Zone identifiers (e.g., %eth0, %25eth0) are used for link-local addresses
434
+ * and MUST be rejected to prevent SSRF bypasses via interface binding.
435
+ *
436
+ * @param hostname - Raw hostname from URL
437
+ * @returns true if zone ID is present
438
+ * @internal
439
+ */
440
+ export function hasIPv6ZoneId(hostname) {
441
+ // Zone IDs appear as %<zone> in raw form or %25<zone> when URL-encoded
442
+ // They only appear in IPv6 addresses (bracketed in URLs)
443
+ return hostname.includes('%') || hostname.includes('%25');
444
+ }
445
+ /**
446
+ * Normalize IPv4-mapped IPv6 address to IPv4
447
+ *
448
+ * IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) can bypass IPv4 blocklists
449
+ * if not normalized. We convert them to plain IPv4 for consistent blocking.
450
+ *
451
+ * @param ip - IP address string
452
+ * @returns Normalized IP (IPv4 if it was IPv4-mapped, otherwise unchanged)
453
+ * @internal
454
+ */
455
+ export function normalizeIPv4MappedIPv6(ip) {
456
+ try {
457
+ const parsed = ipaddr.parse(ip);
458
+ // ipaddr.process() automatically converts IPv4-mapped IPv6 to IPv4
459
+ // We use it explicitly here for clarity
460
+ if (parsed.kind() === 'ipv6') {
461
+ const v6 = parsed;
462
+ // Check if this is an IPv4-mapped IPv6 address
463
+ if (v6.isIPv4MappedAddress()) {
464
+ return v6.toIPv4Address().toString();
465
+ }
466
+ }
467
+ return parsed.toString();
468
+ }
469
+ catch {
470
+ return ip;
471
+ }
472
+ }
473
+ /**
474
+ * Canonicalize a hostname with full security validation
475
+ *
476
+ * This is the single source of truth for hostname processing:
477
+ * 1. Reject IPv6 zone identifiers (SSRF bypass prevention)
478
+ * 2. Lowercase and strip trailing dot
479
+ * 3. Normalize IDNs to ASCII (punycode)
480
+ * 4. Normalize IPv4-mapped IPv6 to IPv4
481
+ *
482
+ * @param hostname - Raw hostname from URL
483
+ * @returns Canonicalized hostname or error
484
+ * @internal
485
+ */
486
+ export function canonicalizeHost(hostname) {
487
+ const original = hostname;
488
+ let normalized = hostname.trim().toLowerCase();
489
+ // P0.1a: Reject IPv6 zone identifiers
490
+ // These can be used to bind to specific network interfaces, bypassing SSRF controls
491
+ if (hasIPv6ZoneId(normalized)) {
492
+ return {
493
+ ok: false,
494
+ error: `IPv6 zone identifiers are not allowed: ${hostname}`,
495
+ code: SAFE_FETCH_ERROR_CODES.E_SSRF_IPV6_ZONE_ID,
496
+ };
497
+ }
498
+ // Strip trailing dot (FQDN form)
499
+ if (normalized.endsWith('.')) {
500
+ normalized = normalized.slice(0, -1);
501
+ }
502
+ // Check if it's an IP address BEFORE any domain processing
503
+ if (ipaddr.isValid(normalized)) {
504
+ // P0.1b: Normalize IPv4-mapped IPv6 to IPv4
505
+ // This ensures ::ffff:127.0.0.1 is blocked just like 127.0.0.1
506
+ normalized = normalizeIPv4MappedIPv6(normalized);
507
+ return {
508
+ ok: true,
509
+ hostname: normalized,
510
+ isIP: true,
511
+ originalHostname: original,
512
+ };
513
+ }
514
+ // P0.1c: Normalize IDNs to ASCII (punycode)
515
+ // This prevents homograph attacks and ensures consistent comparison
516
+ try {
517
+ // Node's url module provides WHATWG URL-compliant IDN handling
518
+ const asciiHost = new URL(`http://${normalized}`).hostname;
519
+ normalized = asciiHost;
520
+ }
521
+ catch {
522
+ // If URL parsing fails, try tldts for punycode
523
+ const parsed = parseDomain(normalized);
524
+ normalized = parsed.hostname ?? normalized;
525
+ }
526
+ // Validate the result is non-empty
527
+ if (!normalized) {
528
+ return {
529
+ ok: false,
530
+ error: `Invalid hostname: ${hostname}`,
531
+ code: SAFE_FETCH_ERROR_CODES.E_SSRF_INVALID_HOST,
532
+ };
533
+ }
534
+ return {
535
+ ok: true,
536
+ hostname: normalized,
537
+ isIP: false,
538
+ originalHostname: original,
539
+ };
540
+ }
541
+ /**
542
+ * Strip hop-by-hop headers from request headers
543
+ *
544
+ * RFC 7230 Section 6.1 defines hop-by-hop headers that MUST NOT be forwarded.
545
+ * Stripping these prevents proxy confusion and request smuggling attacks.
546
+ *
547
+ * @param headers - Headers object to sanitize (mutated in place)
548
+ * @internal
549
+ */
550
+ export function stripHopByHopHeaders(headers) {
551
+ // First, check the Connection header for additional hop-by-hop headers
552
+ const connectionHeader = headers.get('connection');
553
+ if (connectionHeader) {
554
+ // Connection header can list additional headers to treat as hop-by-hop
555
+ const additionalHeaders = connectionHeader
556
+ .split(',')
557
+ .map((h) => h.trim().toLowerCase())
558
+ .filter((h) => h.length > 0);
559
+ for (const header of additionalHeaders) {
560
+ headers.delete(header);
561
+ }
562
+ }
563
+ // Remove standard hop-by-hop headers
564
+ for (const header of HOP_BY_HOP_HEADERS) {
565
+ headers.delete(header);
566
+ }
567
+ }
568
+ /**
569
+ * Normalize hostname for comparison:
570
+ * - Lowercase
571
+ * - Strip trailing dot
572
+ * - Convert to ASCII (punycode)
573
+ * - Normalize IPv4-mapped IPv6
574
+ * @internal
575
+ */
576
+ export function normalizeHostname(hostname) {
577
+ const result = canonicalizeHost(hostname);
578
+ if (!result.ok) {
579
+ // For backwards compatibility, return the trimmed lowercase version
580
+ // The error will be caught elsewhere in the pipeline
581
+ return hostname.trim().toLowerCase();
582
+ }
583
+ return result.hostname;
584
+ }
585
+ /**
586
+ * Extract registrable domain (eTLD+1) from hostname using proper PSL
587
+ * Uses tldts library for accurate Public Suffix List matching
588
+ *
589
+ * IMPORTANT: This function accepts a hostname (from URL.hostname), NOT a host:port string.
590
+ * IPv6 addresses are returned as-is (they don't have registrable domains).
591
+ *
592
+ * @param hostname - A hostname (e.g., "example.com", "2001:db8::1"), NOT "host:port"
593
+ * @internal
594
+ */
595
+ export function getRegistrableDomain(hostname) {
596
+ const normalized = normalizeHostname(hostname);
597
+ // Handle IP addresses (both IPv4 and IPv6)
598
+ // IPv6 addresses contain ':' and must not be split
599
+ if (ipaddr.isValid(normalized)) {
600
+ return normalized;
601
+ }
602
+ // Use tldts for proper eTLD+1 extraction
603
+ // SECURITY: allowPrivateDomains=true ensures that public suffix list entries
604
+ // like github.io, blogspot.com are treated as public suffixes.
605
+ // This prevents user1.github.io from redirecting to user2.github.io
606
+ // with same-registrable-domain policy.
607
+ const domain = getDomain(normalized, { allowPrivateDomains: true });
608
+ // If getDomain returns null (invalid or TLD-only), return normalized hostname
609
+ // This handles edge cases like localhost, single-label names, or TLDs
610
+ return domain ?? normalized;
611
+ }
612
+ /**
613
+ * Check if redirect target is allowed by policy
614
+ * @internal
615
+ */
616
+ export function isRedirectAllowed(originalUrl, redirectUrl, policy, allowHosts) {
617
+ switch (policy) {
618
+ case 'none':
619
+ return false;
620
+ case 'same-origin':
621
+ return originalUrl.protocol === redirectUrl.protocol && originalUrl.host === redirectUrl.host;
622
+ case 'same-registrable-domain': {
623
+ if (originalUrl.protocol !== redirectUrl.protocol) {
624
+ // Allow http -> https upgrade
625
+ if (!(originalUrl.protocol === 'http:' && redirectUrl.protocol === 'https:')) {
626
+ return false;
627
+ }
628
+ }
629
+ const origDomain = getRegistrableDomain(originalUrl.hostname);
630
+ const redirDomain = getRegistrableDomain(redirectUrl.hostname);
631
+ if (origDomain === redirDomain) {
632
+ return true;
633
+ }
634
+ // Check additional allow hosts
635
+ if (allowHosts?.includes(redirectUrl.hostname)) {
636
+ return true;
637
+ }
638
+ if (allowHosts?.includes(redirDomain)) {
639
+ return true;
640
+ }
641
+ return false;
642
+ }
643
+ case 'allowlist':
644
+ if (!allowHosts || allowHosts.length === 0) {
645
+ return false;
646
+ }
647
+ return (allowHosts.includes(redirectUrl.hostname) ||
648
+ allowHosts.includes(getRegistrableDomain(redirectUrl.hostname)));
649
+ default:
650
+ return false;
651
+ }
652
+ }
653
+ /**
654
+ * Create an undici Agent with DNS pinning and optional connect timeout
655
+ * @internal
656
+ */
657
+ export function createPinnedAgent(pinnedIp, _hostname, options) {
658
+ return new Agent({
659
+ connect: {
660
+ // P1.3: Pass connect timeout to undici
661
+ timeout: options?.connectTimeoutMs,
662
+ // Custom lookup function that returns only the pinned IP
663
+ lookup: (_lookupHostname, _options, callback) => {
664
+ // Determine address family
665
+ const isIPv6 = pinnedIp.includes(':');
666
+ callback(null, pinnedIp, isIPv6 ? 6 : 4);
667
+ },
668
+ },
669
+ });
670
+ }
671
+ /**
672
+ * P1.3: Wrap a promise with a timeout
673
+ * Returns a discriminated union to distinguish timeout from actual result
674
+ * @internal
675
+ */
676
+ export async function withTimeout(promise, timeoutMs, timeoutCode) {
677
+ let timeoutId;
678
+ const timeoutPromise = new Promise((resolve) => {
679
+ timeoutId = setTimeout(() => {
680
+ resolve({ ok: false, code: timeoutCode });
681
+ }, timeoutMs);
682
+ });
683
+ try {
684
+ const result = await Promise.race([
685
+ promise.then((value) => ({ ok: true, value })),
686
+ timeoutPromise,
687
+ ]);
688
+ return result;
689
+ }
690
+ finally {
691
+ if (timeoutId)
692
+ clearTimeout(timeoutId);
693
+ }
694
+ }
695
+ /**
696
+ * Resolve DNS and validate ALL resolved IPs, returning only safe ones
697
+ * Implements RFC 8305-style address family ordering
698
+ *
699
+ * SECURITY NOTES:
700
+ * - Error messages are intentionally generic to avoid leaking internal IPs
701
+ * - Structured dns_answers are returned for audit trail (debug mode only reveals in events)
702
+ * - Mixed DNS (public + private) requires explicit opt-in
703
+ *
704
+ * @internal
705
+ */
706
+ export async function resolveDnsSecure(hostname, policy, resolver, dnsOptions) {
707
+ const allowMixed = dnsOptions?.allowMixedPublicAndPrivate ?? false;
708
+ try {
709
+ const { ipv4, ipv6 } = await resolver.resolveAll(hostname);
710
+ const allAddresses = [...ipv6, ...ipv4]; // Prefer IPv6 per RFC 8305
711
+ if (allAddresses.length === 0) {
712
+ return {
713
+ ok: false,
714
+ // Generic error message - no hostname in production logs
715
+ error: 'DNS resolution returned no addresses',
716
+ code: SAFE_FETCH_ERROR_CODES.E_DNS_RESOLUTION_FAILED,
717
+ };
718
+ }
719
+ // Build structured DNS answers for audit trail
720
+ const dnsAnswers = [];
721
+ // Validate ALL resolved IPs - reject if ANY is private (unless allowMixed)
722
+ const safeIpv4 = [];
723
+ const safeIpv6 = [];
724
+ const blockedAddresses = [];
725
+ for (const address of ipv4) {
726
+ const isBlocked = isPrivateIP(address, policy);
727
+ dnsAnswers.push({
728
+ ip: address,
729
+ family: 4,
730
+ blocked_reason: isBlocked ? 'private_ip' : undefined,
731
+ });
732
+ if (isBlocked) {
733
+ blockedAddresses.push(address);
734
+ }
735
+ else {
736
+ safeIpv4.push(address);
737
+ }
738
+ }
739
+ for (const address of ipv6) {
740
+ const isBlocked = isPrivateIP(address, policy);
741
+ dnsAnswers.push({
742
+ ip: address,
743
+ family: 6,
744
+ blocked_reason: isBlocked ? 'private_ip' : undefined,
745
+ });
746
+ if (isBlocked) {
747
+ blockedAddresses.push(address);
748
+ }
749
+ else {
750
+ safeIpv6.push(address);
751
+ }
752
+ }
753
+ // SECURITY: If ANY address is private, we normally block the request
754
+ // This prevents attackers from adding a public IP alongside a private one
755
+ if (blockedAddresses.length > 0) {
756
+ const hasPublicIps = safeIpv4.length > 0 || safeIpv6.length > 0;
757
+ // P0.2: Use distinct error codes for audit-clean logging
758
+ if (hasPublicIps) {
759
+ // Mixed DNS scenario: both public and private IPs returned
760
+ if (allowMixed) {
761
+ // Mixed mode: allow request but only use public IPs
762
+ // The caller should have validated the ack before enabling this
763
+ const safeAddresses = [...safeIpv6, ...safeIpv4];
764
+ return {
765
+ ok: true,
766
+ addresses: safeAddresses,
767
+ selectedAddress: safeAddresses[0],
768
+ ipv4Addresses: safeIpv4,
769
+ ipv6Addresses: safeIpv6,
770
+ };
771
+ }
772
+ // Mixed DNS blocked - distinct from pure private IP
773
+ // Generic error message - details in dns_answers for audit
774
+ return {
775
+ ok: false,
776
+ error: 'DNS returned mixed public/private addresses (blocked by policy)',
777
+ code: SAFE_FETCH_ERROR_CODES.E_SSRF_MIXED_DNS_BLOCKED,
778
+ dns_answers: dnsAnswers,
779
+ };
780
+ }
781
+ // All IPs are private (no public IPs to use)
782
+ // Generic error message - details in dns_answers for audit
783
+ return {
784
+ ok: false,
785
+ error: 'DNS resolved to blocked addresses',
786
+ code: SAFE_FETCH_ERROR_CODES.E_SSRF_DNS_RESOLVED_PRIVATE,
787
+ dns_answers: dnsAnswers,
788
+ };
789
+ }
790
+ const safeAddresses = [...safeIpv6, ...safeIpv4]; // Prefer IPv6
791
+ if (safeAddresses.length === 0) {
792
+ return {
793
+ ok: false,
794
+ error: 'All resolved addresses were blocked by policy',
795
+ code: SAFE_FETCH_ERROR_CODES.E_SSRF_ALL_IPS_BLOCKED,
796
+ dns_answers: dnsAnswers,
797
+ };
798
+ }
799
+ // Select first safe address (IPv6 preferred per RFC 8305)
800
+ return {
801
+ ok: true,
802
+ addresses: safeAddresses,
803
+ selectedAddress: safeAddresses[0],
804
+ ipv4Addresses: safeIpv4,
805
+ ipv6Addresses: safeIpv6,
806
+ };
807
+ }
808
+ catch (err) {
809
+ // Generic error message - no internal details
810
+ return {
811
+ ok: false,
812
+ error: 'DNS resolution failed',
813
+ code: SAFE_FETCH_ERROR_CODES.E_DNS_RESOLUTION_FAILED,
814
+ };
815
+ }
816
+ }
817
+ /**
818
+ * Attempt connection with Happy Eyeballs-style fallback (RFC 8305)
819
+ * Properly cancels and cleans up the losing attempt
820
+ * @internal
821
+ */
822
+ export async function attemptWithFallback(url, dnsResult, httpClient, options) {
823
+ const timeoutMs = options.timeoutMs ?? 30000; // DEFAULT_TIMEOUT_MS
824
+ const { ipv6Addresses, ipv4Addresses } = dnsResult;
825
+ // If we only have one family, just use it
826
+ if (ipv6Addresses.length === 0 || ipv4Addresses.length === 0) {
827
+ return httpClient.fetch(url, dnsResult.selectedAddress, {
828
+ ...options,
829
+ timeoutMs,
830
+ });
831
+ }
832
+ const attempts = [];
833
+ // Helper to create an attempt that can be cleaned up
834
+ const createAttempt = (ip, attemptTimeoutMs) => {
835
+ let result = null;
836
+ let settled = false;
837
+ const promise = httpClient
838
+ .fetch(url, ip, {
839
+ ...options,
840
+ timeoutMs: attemptTimeoutMs,
841
+ })
842
+ .then((r) => {
843
+ result = r;
844
+ settled = true;
845
+ return r;
846
+ })
847
+ .catch((err) => {
848
+ settled = true;
849
+ throw err;
850
+ });
851
+ const cleanupOnLose = async () => {
852
+ // If this attempt completed, close its agent
853
+ if (result) {
854
+ await result.close();
855
+ }
856
+ // If still pending, the agent will be cleaned up when it eventually settles
857
+ // (the httpClient handles cleanup on error)
858
+ };
859
+ return {
860
+ promise,
861
+ cleanupOnLose,
862
+ get settled() {
863
+ return settled;
864
+ },
865
+ };
866
+ };
867
+ // Start IPv6 attempt first (RFC 8305)
868
+ const ipv6Attempt = createAttempt(ipv6Addresses[0], timeoutMs);
869
+ attempts.push(ipv6Attempt);
870
+ // Wait for either IPv6 success or first-attempt timeout
871
+ const firstAttemptResult = await Promise.race([
872
+ ipv6Attempt.promise.then((r) => ({ winner: 'ipv6', result: r })),
873
+ new Promise((resolve) => setTimeout(() => resolve({ winner: 'timeout' }), HAPPY_EYEBALLS_FIRST_TIMEOUT_MS)),
874
+ ipv6Attempt.promise.then(() => {
875
+ throw new Error('should not reach');
876
+ }, () => ({ winner: 'ipv6_failed' })),
877
+ ]);
878
+ if (firstAttemptResult.winner === 'ipv6' && 'result' in firstAttemptResult) {
879
+ // IPv6 won quickly, no cleanup needed
880
+ return firstAttemptResult.result;
881
+ }
882
+ // IPv6 either timed out or failed, start IPv4 attempt
883
+ const ipv4Attempt = createAttempt(ipv4Addresses[0], timeoutMs - HAPPY_EYEBALLS_FIRST_TIMEOUT_MS);
884
+ attempts.push(ipv4Attempt);
885
+ // Race both attempts (IPv6 may still complete)
886
+ try {
887
+ const raceResult = await Promise.race([
888
+ ipv6Attempt.promise.then((r) => ({ winner: 'ipv6', result: r })),
889
+ ipv4Attempt.promise.then((r) => ({ winner: 'ipv4', result: r })),
890
+ ]);
891
+ // Clean up the losing attempt
892
+ if (raceResult.winner === 'ipv6') {
893
+ // IPv6 won, clean up IPv4
894
+ ipv4Attempt.cleanupOnLose().catch(() => { });
895
+ }
896
+ else {
897
+ // IPv4 won, clean up IPv6
898
+ ipv6Attempt.cleanupOnLose().catch(() => { });
899
+ }
900
+ return raceResult.result;
901
+ }
902
+ catch (firstError) {
903
+ // One failed, wait for the other
904
+ try {
905
+ const otherAttempt = ipv6Attempt.settled ? ipv4Attempt : ipv6Attempt;
906
+ return await otherAttempt.promise;
907
+ }
908
+ catch {
909
+ // Both failed, throw the original error
910
+ throw firstError;
911
+ }
912
+ }
913
+ }
914
+ // -----------------------------------------------------------------------------
915
+ // Response Body Processing with Size Limits
916
+ // -----------------------------------------------------------------------------
917
+ /**
918
+ * Read response body with size limit enforcement
919
+ * @internal
920
+ */
921
+ export async function readBodyWithLimit(response, maxBytes) {
922
+ // Check Content-Length header first (optimization)
923
+ const contentLength = response.headers.get('content-length');
924
+ if (contentLength) {
925
+ const length = parseInt(contentLength, 10);
926
+ if (!isNaN(length) && length > maxBytes) {
927
+ return {
928
+ ok: false,
929
+ error: `Response too large: ${length} bytes (max: ${maxBytes})`,
930
+ code: SAFE_FETCH_ERROR_CODES.E_RESPONSE_TOO_LARGE,
931
+ };
932
+ }
933
+ }
934
+ // Stream the body with size limit
935
+ const reader = response.body?.getReader();
936
+ if (!reader) {
937
+ // No body, return empty
938
+ return { ok: true, body: new ArrayBuffer(0) };
939
+ }
940
+ const chunks = [];
941
+ let totalBytes = 0;
942
+ try {
943
+ while (true) {
944
+ const { done, value } = await reader.read();
945
+ if (done)
946
+ break;
947
+ totalBytes += value.length;
948
+ if (totalBytes > maxBytes) {
949
+ await reader.cancel();
950
+ return {
951
+ ok: false,
952
+ error: `Response too large: exceeded ${maxBytes} bytes`,
953
+ code: SAFE_FETCH_ERROR_CODES.E_RESPONSE_TOO_LARGE,
954
+ };
955
+ }
956
+ chunks.push(value);
957
+ }
958
+ // Concatenate chunks
959
+ const result = new Uint8Array(totalBytes);
960
+ let offset = 0;
961
+ for (const chunk of chunks) {
962
+ result.set(chunk, offset);
963
+ offset += chunk.length;
964
+ }
965
+ return { ok: true, body: result.buffer };
966
+ }
967
+ catch (err) {
968
+ return {
969
+ ok: false,
970
+ error: `Failed to read response body: ${err instanceof Error ? err.message : String(err)}`,
971
+ code: SAFE_FETCH_ERROR_CODES.E_NETWORK_ERROR,
972
+ };
973
+ }
974
+ }
975
+ /**
976
+ * Create a new request-scoped audit context
977
+ * @internal
978
+ */
979
+ export function createRequestAuditContext() {
980
+ return {
981
+ pending: 0,
982
+ dropped: 0,
983
+ hookErrors: 0,
984
+ hookSuppressed: 0,
985
+ };
986
+ }
987
+ /**
988
+ * Module-global pending count for bounded queue enforcement
989
+ * (Rate limiting needs global state, but evidence uses request-scoped counters)
990
+ * @internal
991
+ */
992
+ let globalPendingCount = 0;
993
+ /**
994
+ * Rate limit for overflow and hook error events (1 per second max, global)
995
+ * Rate limiting must be global to prevent log spam across concurrent requests.
996
+ */
997
+ const OVERFLOW_EVENT_INTERVAL_MS = 1000;
998
+ let lastOverflowEventTime = 0;
999
+ let lastHookErrorEventTime = 0;
1000
+ /**
1001
+ * Reset audit queue statistics (for testing)
1002
+ * @internal
1003
+ */
1004
+ export function resetAuditQueueStats() {
1005
+ globalPendingCount = 0;
1006
+ lastOverflowEventTime = 0;
1007
+ lastHookErrorEventTime = 0;
1008
+ }
1009
+ /**
1010
+ * Get global audit queue statistics for monitoring (process-level)
1011
+ *
1012
+ * NOTE: For per-request stats, use the RequestAuditContext.
1013
+ * This function returns process-level totals for operational monitoring.
1014
+ *
1015
+ * @returns Object with global pending count
1016
+ */
1017
+ export function getAuditQueueStats() {
1018
+ return {
1019
+ pending: globalPendingCount,
1020
+ dropped: 0, // Deprecated: use RequestAuditContext.dropped for per-request
1021
+ };
1022
+ }
1023
+ /**
1024
+ * Sanitize error message to prevent leaking sensitive data
1025
+ * @internal
1026
+ */
1027
+ function sanitizeErrorMessage(err) {
1028
+ const raw = err instanceof Error ? err.message : String(err);
1029
+ // Truncate to prevent DoS via huge error messages
1030
+ const truncated = raw.length > 200 ? raw.slice(0, 200) + '...' : raw;
1031
+ // Redact potential secrets (tokens, keys, passwords)
1032
+ return truncated
1033
+ .replace(/Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi, 'Bearer [REDACTED]')
1034
+ .replace(/token[=:]\s*["']?[A-Za-z0-9\-._~+/]+["']?/gi, 'token=[REDACTED]')
1035
+ .replace(/key[=:]\s*["']?[A-Za-z0-9\-._~+/]+["']?/gi, 'key=[REDACTED]')
1036
+ .replace(/password[=:]\s*["']?[^\s"']+["']?/gi, 'password=[REDACTED]')
1037
+ .replace(/secret[=:]\s*["']?[A-Za-z0-9\-._~+/]+["']?/gi, 'secret=[REDACTED]');
1038
+ }
1039
+ // SAFE_FETCH_EVENT_SCHEMA_VERSION is imported from constants.ts
1040
+ /**
1041
+ * Safely emit an audit event via microtask for performance isolation
1042
+ *
1043
+ * Events are queued via queueMicrotask to prevent slow hooks from blocking
1044
+ * the request path. Ordering is guaranteed within a request but may
1045
+ * interleave with other async operations.
1046
+ *
1047
+ * P1.4: Implements bounded queue to prevent microtask blowup.
1048
+ * Events are dropped (not queued) if MAX_PENDING_AUDIT_EVENTS is exceeded.
1049
+ * When drops occur, a rate-limited `audit_overflow` event is emitted.
1050
+ *
1051
+ * @param ctx - Request-scoped audit context for tracking per-request counters
1052
+ * @internal
1053
+ */
1054
+ export function emitAuditEvent(ctx, hook, sanitizer, hookName, event) {
1055
+ if (!hook)
1056
+ return;
1057
+ // P1.4: Bounded queue - drop events if too many pending (global limit)
1058
+ if (globalPendingCount >= MAX_PENDING_AUDIT_EVENTS) {
1059
+ const wasFirstDrop = ctx.dropped === 0;
1060
+ ctx.dropped++;
1061
+ // Emit rate-limited overflow event (max 1 per second, global rate limit)
1062
+ const now = Date.now();
1063
+ if (wasFirstDrop || now - lastOverflowEventTime >= OVERFLOW_EVENT_INTERVAL_MS) {
1064
+ lastOverflowEventTime = now;
1065
+ // Direct call to hook (not queued) to ensure delivery
1066
+ try {
1067
+ hook({
1068
+ schema_version: SAFE_FETCH_EVENT_SCHEMA_VERSION,
1069
+ type: 'audit_overflow',
1070
+ timestamp: now,
1071
+ url: event.url ?? '[unknown]',
1072
+ meta: {
1073
+ dropped_total: ctx.dropped,
1074
+ pending: globalPendingCount,
1075
+ max_pending: MAX_PENDING_AUDIT_EVENTS,
1076
+ },
1077
+ });
1078
+ }
1079
+ catch {
1080
+ // Audit hooks MUST NOT throw - silently ignore errors
1081
+ }
1082
+ }
1083
+ return;
1084
+ }
1085
+ // Add schema version to event
1086
+ const fullEvent = {
1087
+ schema_version: SAFE_FETCH_EVENT_SCHEMA_VERSION,
1088
+ ...event,
1089
+ };
1090
+ // Track pending count (both global for rate limiting and per-request for evidence)
1091
+ globalPendingCount++;
1092
+ ctx.pending++;
1093
+ // Use queueMicrotask for performance isolation
1094
+ // This prevents slow hooks from blocking the request path
1095
+ queueMicrotask(() => {
1096
+ try {
1097
+ hook(fullEvent);
1098
+ }
1099
+ catch (err) {
1100
+ // Audit hooks MUST NOT throw - but we emit a rate-limited error event
1101
+ // to provide observability without recursion risk
1102
+ ctx.hookErrors++;
1103
+ // EXPLICIT NON-RECURSION GUARD: Never emit audit_hook_error for
1104
+ // an audit_hook_error event. This prevents infinite loops when the
1105
+ // error handler itself throws.
1106
+ if (fullEvent.type === 'audit_hook_error') {
1107
+ // Silently swallow - we already tried to report an error and it failed
1108
+ ctx.hookSuppressed++;
1109
+ return;
1110
+ }
1111
+ const now = Date.now();
1112
+ // Rate limiting: emit at most 1 error event per second (global rate limit)
1113
+ // This is concurrency-safe because we check-then-set atomically in JS
1114
+ // (single-threaded event loop, no true parallelism within microtask)
1115
+ if (now - lastHookErrorEventTime >= OVERFLOW_EVENT_INTERVAL_MS) {
1116
+ lastHookErrorEventTime = now;
1117
+ // Direct synchronous call - inner try/catch prevents recursion if
1118
+ // the error handler itself throws
1119
+ try {
1120
+ hook({
1121
+ schema_version: SAFE_FETCH_EVENT_SCHEMA_VERSION,
1122
+ type: 'audit_hook_error',
1123
+ timestamp: now,
1124
+ url: fullEvent.url ?? '[unknown]',
1125
+ meta: {
1126
+ // Sanitized error message (configurable via sanitizer option)
1127
+ // Custom sanitizers are wrapped in try/catch for safety
1128
+ error_message: (() => {
1129
+ const rawMsg = err instanceof Error ? err.message : String(err);
1130
+ if (sanitizer === 'off')
1131
+ return rawMsg;
1132
+ if (typeof sanitizer === 'function') {
1133
+ try {
1134
+ return sanitizer(rawMsg);
1135
+ }
1136
+ catch {
1137
+ // Custom sanitizer threw - fall back to built-in
1138
+ return sanitizeErrorMessage(err);
1139
+ }
1140
+ }
1141
+ return sanitizeErrorMessage(err);
1142
+ })(),
1143
+ error_count: ctx.hookErrors,
1144
+ // Structured fields for observability
1145
+ hook_name: hookName,
1146
+ error_code: err instanceof Error && 'code' in err ? String(err.code) : undefined,
1147
+ original_event_type: fullEvent.type,
1148
+ suppressed_count: ctx.hookSuppressed,
1149
+ },
1150
+ });
1151
+ }
1152
+ catch {
1153
+ // If error event hook also throws, silently ignore.
1154
+ // This is a second line of defense; the primary guard above
1155
+ // (checking fullEvent.type === 'audit_hook_error') prevents
1156
+ // re-entering this path entirely.
1157
+ }
1158
+ }
1159
+ else {
1160
+ // Rate limited - track suppressed errors for observability (per-request)
1161
+ ctx.hookSuppressed++;
1162
+ }
1163
+ }
1164
+ finally {
1165
+ // Decrement pending counts after processing
1166
+ globalPendingCount--;
1167
+ ctx.pending--;
1168
+ }
1169
+ });
1170
+ }
1171
+ /**
1172
+ * Sanitize URL for logging (remove credentials)
1173
+ * @internal
1174
+ */
1175
+ export function sanitizeUrl(url) {
1176
+ try {
1177
+ const parsed = new URL(url);
1178
+ // Remove username and password
1179
+ parsed.username = '';
1180
+ parsed.password = '';
1181
+ return parsed.toString();
1182
+ }
1183
+ catch {
1184
+ // If URL is malformed, return a generic placeholder
1185
+ return '[invalid-url]';
1186
+ }
1187
+ }
1188
+ //# sourceMappingURL=impl.js.map