@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/README.md ADDED
@@ -0,0 +1,736 @@
1
+ # @peac/net-node
2
+
3
+ SSRF-safe network utilities for PEAC Protocol with DNS resolution pinning.
4
+
5
+ ## Overview
6
+
7
+ This package provides a reference implementation of network-layer SSRF protection. String-level URL validation (as provided by `@peac/schema`) is necessary but **not sufficient** for complete SSRF protection. DNS rebinding attacks can bypass string-level checks by resolving to internal IPs after validation.
8
+
9
+ This package addresses the gap by:
10
+
11
+ 1. Performing string-level URL validation
12
+ 2. Resolving DNS and validating the resolved IP against private ranges
13
+ 3. Following redirects with validation
14
+
15
+ ## Requirements
16
+
17
+ - **Node.js 22+** (uses modern ES modules and native fetch)
18
+ - **ESM only** - This package is published as ES modules only
19
+
20
+ ### CJS Consumers
21
+
22
+ This package is **ESM-only** (no CommonJS build). If your project uses CommonJS, use dynamic import:
23
+
24
+ ```javascript
25
+ // In CommonJS - must be in async context
26
+ const { safeFetch } = await import('@peac/net-node');
27
+ ```
28
+
29
+ **Wrapper pattern for CJS modules:**
30
+
31
+ ```javascript
32
+ // lib/net.js - CJS wrapper
33
+ let _safeFetch;
34
+
35
+ async function getSafeFetch() {
36
+ if (!_safeFetch) {
37
+ const mod = await import('@peac/net-node');
38
+ _safeFetch = mod.safeFetch;
39
+ }
40
+ return _safeFetch;
41
+ }
42
+
43
+ module.exports = { getSafeFetch };
44
+ ```
45
+
46
+ ### Package Quality Gates
47
+
48
+ This package passes [publint](https://publint.dev/) and [@arethetypeswrong/cli](https://github.com/arethetypeswrong/arethetypeswrong.github.io) (attw) with two documented ignore rules:
49
+
50
+ **attw ignore rules:**
51
+
52
+ - `cjs-resolves-to-esm` - Expected for ESM-only packages. CJS `require()` would resolve to ESM, which Node rejects at runtime. This is intentional; use dynamic `import()` instead.
53
+ - `no-resolution` - Known false positive for the `./testing` subpath export. The types resolve correctly in real TypeScript projects.
54
+
55
+ **Support matrix:**
56
+
57
+ - Node.js >= 22 (uses native fetch, ES modules)
58
+ - Modern bundlers (Webpack 5+, esbuild, Rollup, Vite) - ESM works out of the box
59
+ - TypeScript >= 5.0 with `moduleResolution: "bundler"` or `"node16"`/`"nodenext"`
60
+
61
+ **If these rules start failing in your project:**
62
+
63
+ - `cjs-resolves-to-esm`: Your toolchain is trying to `require()` this package. Switch to dynamic `import()` or configure your bundler for ESM.
64
+ - `no-resolution`: Check your `tsconfig.json` has a modern `moduleResolution` setting. The `./testing` export requires Node 16+ resolution.
65
+
66
+ ### Common Tooling Configuration
67
+
68
+ **Jest (v29+):**
69
+
70
+ Jest supports ESM via `--experimental-vm-modules`. Add to `package.json`:
71
+
72
+ ```json
73
+ {
74
+ "scripts": {
75
+ "test": "NODE_OPTIONS='--experimental-vm-modules' jest"
76
+ },
77
+ "jest": {
78
+ "extensionsToTreatAsEsm": [".ts"],
79
+ "transform": {}
80
+ }
81
+ }
82
+ ```
83
+
84
+ Or use `jest.config.mjs` with `transformIgnorePatterns` to handle this package.
85
+
86
+ **ts-node / tsx:**
87
+
88
+ ```bash
89
+ # tsx (recommended - zero config ESM)
90
+ npx tsx your-script.ts
91
+
92
+ # ts-node with ESM loader
93
+ node --loader ts-node/esm your-script.ts
94
+ ```
95
+
96
+ **Bundlers (Webpack, esbuild, Rollup):**
97
+
98
+ Modern bundlers handle ESM natively. No special configuration needed.
99
+
100
+ ```javascript
101
+ // webpack.config.js - ESM works out of the box
102
+ export default {
103
+ target: 'node',
104
+ // ... your config
105
+ };
106
+ ```
107
+
108
+ ```javascript
109
+ // esbuild
110
+ import { build } from 'esbuild';
111
+ await build({
112
+ entryPoints: ['src/index.ts'],
113
+ platform: 'node',
114
+ format: 'esm',
115
+ // ...
116
+ });
117
+ ```
118
+
119
+ ## Installation
120
+
121
+ ```bash
122
+ pnpm add @peac/net-node
123
+ ```
124
+
125
+ ## Usage
126
+
127
+ ### Basic Usage
128
+
129
+ ```typescript
130
+ import { safeFetch } from '@peac/net';
131
+
132
+ const result = await safeFetch<{ keys: unknown[] }>(
133
+ 'https://issuer.example.com/.well-known/jwks.json'
134
+ );
135
+
136
+ if (result.ok) {
137
+ console.log('JWKS:', result.data);
138
+ } else {
139
+ console.error('Fetch failed:', result.error, result.code);
140
+ }
141
+ ```
142
+
143
+ ### JWKS-specific Helper
144
+
145
+ ```typescript
146
+ import { safeFetchJWKS } from '@peac/net';
147
+
148
+ const result = await safeFetchJWKS('https://issuer.example.com/.well-known/jwks.json');
149
+
150
+ if (result.ok) {
151
+ console.log('Keys:', result.data.keys);
152
+ }
153
+ ```
154
+
155
+ ### Custom SSRF Policy
156
+
157
+ ```typescript
158
+ import { safeFetch, type SafeFetchOptions } from '@peac/net';
159
+ import { ALLOW_CGNAT_ACK } from '@peac/schema';
160
+
161
+ const options: SafeFetchOptions = {
162
+ timeoutMs: 5000,
163
+ maxRedirects: 3,
164
+ ssrfPolicy: {
165
+ // Allow specific internal hosts
166
+ allowHosts: ['internal.example.com'],
167
+ // Allow CGNAT range (with required acknowledgment)
168
+ allowCgnat: true,
169
+ ack_allow_cgnat: ALLOW_CGNAT_ACK,
170
+ },
171
+ };
172
+
173
+ const result = await safeFetch(url, options);
174
+ ```
175
+
176
+ ## Security Model
177
+
178
+ ### What This Package Protects Against
179
+
180
+ - **DNS Rebinding**: Validates resolved IP addresses, not just hostnames
181
+ - **Private IP Access**: Blocks requests to private/reserved IP ranges
182
+ - **Redirect Attacks**: Validates redirect destinations by default
183
+ - **IPv6 Zone ID Injection**: Rejects zone identifiers (`%eth0`, `%25eth0`)
184
+ - **IPv4-Mapped IPv6 Bypass**: Normalizes `::ffff:127.0.0.1` to `127.0.0.1` before validation
185
+ - **Homograph Attacks**: Normalizes IDNs to ASCII (punycode) before validation
186
+ - **Hop-by-Hop Header Smuggling**: Strips RFC 7230 hop-by-hop headers before forwarding
187
+ - **PSL Subdomain Bypass**: Treats github.io, vercel.app, etc. as public suffixes
188
+ - **Non-HTTPS Downgrade**: Requires HTTPS by default (HTTP blocked)
189
+ - **Non-Standard Ports**: Only 80/443 allowed by default (blocks admin ports like 22, 25, 6379)
190
+
191
+ ### Scheme and Port Security (Defense in Depth)
192
+
193
+ By default, this package enforces strict scheme and port policies:
194
+
195
+ | Policy | Default | Description |
196
+ | ----------------- | ------- | ---------------------------------- |
197
+ | `requireHttps` | `true` | Blocks HTTP URLs (HTTPS only) |
198
+ | `allowedPorts` | 80, 443 | Only standard ports allowed |
199
+ | `blockIpLiterals` | `true` | Blocks `http://1.2.3.4` style URLs |
200
+
201
+ **Why non-standard ports are blocked:**
202
+
203
+ Non-standard ports (8080, 8443, 3000, etc.) are commonly used for:
204
+
205
+ - Internal admin interfaces (Jenkins on 8080, Kubernetes dashboard on 8443)
206
+ - Database services (Redis 6379, MongoDB 27017, MySQL 3306)
207
+ - Service meshes and sidecars (Envoy 15000, Istio 15001)
208
+
209
+ Even if the hostname resolves to a public IP, connecting to a non-standard port on an attacker-controlled server could be used for:
210
+
211
+ - Port scanning internal networks (via DNS rebinding)
212
+ - SSRF to internal admin services
213
+
214
+ **To allow specific non-standard ports:**
215
+
216
+ ```typescript
217
+ const result = await safeFetch(url, {
218
+ ssrfPolicy: {
219
+ allowPorts: [8443], // Add to the default 80/443
220
+ },
221
+ });
222
+ ```
223
+
224
+ **Dangerous Ports Blocklist (Defense in Depth):**
225
+
226
+ Even if you allow a port via `allowPorts`, certain ports are ALWAYS blocked by default as they commonly host sensitive services:
227
+
228
+ - **Remote access:** SSH (22), FTP (21), SMTP (25), Telnet (23)
229
+ - **Databases:** MySQL (3306), PostgreSQL (5432), Redis (6379), MongoDB (27017)
230
+ - **Container/orchestration:** Docker (2375/2376), etcd (2379/2380), Kubernetes API (6443)
231
+ - **Service mesh:** Envoy (15000), Istio (15001)
232
+
233
+ To connect to these dangerous ports, you MUST provide explicit acknowledgment:
234
+
235
+ ```typescript
236
+ import { safeFetch, ALLOW_DANGEROUS_PORTS_ACK } from '@peac/net';
237
+
238
+ const result = await safeFetch('https://internal.example.com:22/api', {
239
+ ssrfPolicy: {
240
+ allowPorts: [22],
241
+ },
242
+ allowDangerousPorts: true,
243
+ ack_allow_dangerous_ports: ALLOW_DANGEROUS_PORTS_ACK,
244
+ });
245
+ ```
246
+
247
+ ### Public Suffix List (PSL) Security
248
+
249
+ This package uses `tldts` with `allowPrivateDomains: true` for PSL lookups. This is a **deliberate security choice**:
250
+
251
+ - **Why:** Prevents attackers from bypassing allowlists via PSL subdomains
252
+ - Without this, `malicious.github.io` would be treated as a subdomain of `github.io`
253
+ - With this, `github.io` is correctly treated as a public suffix (like `.com`)
254
+ - **Affected domains:** github.io, vercel.app, netlify.app, pages.dev, and ~7,000 others
255
+ - **Trade-off:** Some legitimate private suffixes may be over-blocked
256
+
257
+ This setting is non-configurable by design. If you need to allow specific PSL domains, use the `allowHosts` option.
258
+
259
+ ### What You Still Need
260
+
261
+ - **TLS Certificate Validation**: Handled by the runtime's fetch implementation
262
+ - **Response Size Limits**: Implement `maxResponseBytes` option or application-layer limits
263
+ - **Rate Limiting**: Implement in your application layer
264
+
265
+ ## Evidence Contract
266
+
267
+ Every `safeFetch` call returns an `evidence` object suitable for audit trails, dispute resolution, and observability integration. Evidence is cryptographically bound via embedded digest.
268
+
269
+ ### Evidence Fields
270
+
271
+ | Field | Type | Description |
272
+ | -------------------- | ------ | -------------------------------------------------------- |
273
+ | `schema_version` | string | Evidence format version (`peac-safe-fetch-evidence/0.1`) |
274
+ | `evidence_digest` | string | SHA-256 of canonical evidence (0x-prefixed) |
275
+ | `evidence_alg` | string | Hash algorithm (`sha-256`) |
276
+ | `canonicalization` | string | Canonicalization scheme (`RFC8785-JCS`) |
277
+ | `evidence_level` | string | Redaction level (`public`, `tenant`, `private`) |
278
+ | `request_timestamp` | number | Request start time (epoch ms) |
279
+ | `response_timestamp` | number | Response headers received (epoch ms) |
280
+ | `policy_decision` | string | `allow` or `block` |
281
+ | `decision_code` | string | Detailed reason code |
282
+ | `audit_stats` | object | Queue health stats (only present if drops occurred) |
283
+ | `audit_truncated` | true | Present only if audit events were dropped (see below) |
284
+
285
+ ### Evidence Levels
286
+
287
+ ```typescript
288
+ // Public: SHA-256 hash of IPs (default, rainbow-table vulnerable for IPv4)
289
+ const result = await safeFetch(url, { evidenceLevel: 'public' });
290
+
291
+ // Tenant: HMAC-SHA256 with tenant key (prevents cross-org correlation)
292
+ const result = await safeFetch(url, {
293
+ evidenceLevel: 'tenant',
294
+ redactionKey: tenantKey, // Uint8Array, >= 32 bytes
295
+ redactionKeyId: 'key-2024', // Key identifier for rotation
296
+ });
297
+
298
+ // Private: Raw IPs included (internal audit only)
299
+ const result = await safeFetch(url, { evidenceLevel: 'private' });
300
+ ```
301
+
302
+ ### Digest Verification
303
+
304
+ Evidence includes a self-contained digest for verification:
305
+
306
+ ```typescript
307
+ import { computeEvidenceDigest } from '@peac/net';
308
+
309
+ const result = await safeFetch(url);
310
+ if (result.ok) {
311
+ const { evidence } = result;
312
+
313
+ // Verify digest integrity (strip digest fields, recompute)
314
+ const recomputed = computeEvidenceDigest(evidence);
315
+ console.assert(recomputed === evidence.evidence_digest);
316
+ }
317
+ ```
318
+
319
+ ## OpenTelemetry Integration
320
+
321
+ Evidence can be exported to OpenTelemetry for observability.
322
+
323
+ ### Span Attributes
324
+
325
+ ```typescript
326
+ import { trace } from '@opentelemetry/api';
327
+ import { safeFetch } from '@peac/net';
328
+
329
+ const tracer = trace.getTracer('my-service');
330
+
331
+ async function fetchWithTracing(url: string) {
332
+ return tracer.startActiveSpan('safe-fetch', async (span) => {
333
+ const result = await safeFetch(url);
334
+
335
+ if (result.ok) {
336
+ const { evidence } = result;
337
+
338
+ // Add evidence as span attributes
339
+ span.setAttributes({
340
+ 'peac.evidence_digest': evidence.evidence_digest,
341
+ 'peac.policy_decision': evidence.policy_decision,
342
+ 'peac.decision_code': evidence.decision_code,
343
+ 'peac.canonical_host': evidence.canonical_host,
344
+ 'peac.elapsed_ms': evidence.elapsed_ms,
345
+ 'peac.evidence_level': evidence.evidence_level,
346
+ });
347
+
348
+ // Add audit health if drops occurred
349
+ if (evidence.audit_truncated) {
350
+ span.setAttributes({
351
+ 'peac.audit.truncated': true,
352
+ 'peac.audit.dropped': evidence.audit_stats?.dropped,
353
+ 'peac.audit.pending': evidence.audit_stats?.pending,
354
+ });
355
+ }
356
+ } else {
357
+ span.setAttributes({
358
+ 'peac.error_code': result.code,
359
+ 'peac.policy_decision': 'block',
360
+ });
361
+ }
362
+
363
+ span.end();
364
+ return result;
365
+ });
366
+ }
367
+ ```
368
+
369
+ ### JSONL Export for SIEM
370
+
371
+ ```typescript
372
+ import { appendFileSync } from 'fs';
373
+ import { safeFetch } from '@peac/net';
374
+
375
+ async function fetchWithAuditLog(url: string) {
376
+ const result = await safeFetch(url);
377
+
378
+ // Export to JSONL for SIEM ingestion (Splunk, Elastic, etc.)
379
+ const logEntry = {
380
+ timestamp: new Date().toISOString(),
381
+ type: 'safe_fetch_evidence',
382
+ url,
383
+ ok: result.ok,
384
+ evidence: result.ok ? result.evidence : result.evidence,
385
+ };
386
+
387
+ appendFileSync('/var/log/peac-evidence.jsonl', JSON.stringify(logEntry) + '\n');
388
+
389
+ return result;
390
+ }
391
+ ```
392
+
393
+ ### Audit Event Hook
394
+
395
+ For real-time observability, use the audit hook:
396
+
397
+ ```typescript
398
+ const result = await safeFetch(url, {
399
+ onEvent: (event) => {
400
+ // Export to OTel, StatsD, Prometheus, etc.
401
+ if (event.type === 'audit_overflow') {
402
+ // Evidence quality degraded - alert
403
+ console.warn('Audit queue overflow:', event.meta);
404
+ }
405
+ },
406
+ });
407
+ ```
408
+
409
+ ### Hook Contract
410
+
411
+ The `onEvent` hook provides real-time observability into fetch operations. To build reliable integrations, understand these guarantees:
412
+
413
+ | Property | Guarantee | Notes |
414
+ | ---------------------- | ------------------- | ------------------------------------------------------------------- |
415
+ | **Delivery** | Asynchronous | Events are queued via `queueMicrotask` (non-blocking to fetch path) |
416
+ | **Ordering** | FIFO within request | Events arrive in fetch order for a single request |
417
+ | **Delivery guarantee** | At-most-once | Events may be dropped under load (bounded queue) |
418
+ | **Exceptions** | Swallowed | Hook MUST NOT throw; exceptions are silently caught |
419
+ | **Side effects** | None allowed | Hook MUST NOT modify request/response or call `safeFetch` |
420
+
421
+ **Special events:**
422
+
423
+ - `audit_overflow`: Emitted **synchronously** (not queued) when audit queue is full. Rate-limited to 1 per second to prevent log spam.
424
+ - `audit_hook_error`: Emitted when the hook throws an exception. Rate-limited to 1 per second. Contains:
425
+ - `error_message`: Sanitized error message (truncated to 200 chars, secrets redacted by default)
426
+ - `error_count`: Cumulative count of hook errors (**per-process lifetime**)
427
+ - `hook_name`: Name of the hook that threw (`'onEvent'`)
428
+ - `error_code`: Error code if available (e.g., `ECONNRESET`)
429
+ - `original_event_type`: The event type that triggered the hook error
430
+ - `suppressed_count`: Number of hook errors suppressed by rate limiting (**per-process lifetime**)
431
+
432
+ **Counter semantics:**
433
+
434
+ All counters in evidence are **request-scoped** (per-safeFetch invocation):
435
+
436
+ | Field in `audit_stats` | Scope | Description |
437
+ | ---------------------- | ----------- | --------------------------------------------- |
438
+ | `pending` | Per-request | Events pending at evidence finalization |
439
+ | `dropped` | Per-request | Events dropped due to queue overflow |
440
+ | `hook_errors` | Per-request | Hook errors that occurred during this request |
441
+ | `hook_suppressed` | Per-request | Hook errors suppressed by rate limiting |
442
+
443
+ Note: All counters in `audit_stats` are specific to **this request** only. They reset for each `safeFetch` call, ensuring evidence accurately describes that specific request without noise from other concurrent requests.
444
+
445
+ **Customizing error sanitization:**
446
+
447
+ ```typescript
448
+ // Use custom sanitizer
449
+ const result = await safeFetch(url, {
450
+ onEvent: myHook,
451
+ sanitizeHookError: (msg) => msg.replace(/sensitive/gi, '[REDACTED]'),
452
+ });
453
+
454
+ // Disable sanitization (NOT RECOMMENDED in production)
455
+ const result = await safeFetch(url, {
456
+ onEvent: myHook,
457
+ sanitizeHookError: 'off',
458
+ });
459
+ ```
460
+
461
+ NOTE: `error_message` is NOT stable for parsing; use the structured fields instead.
462
+
463
+ **Integration pattern:**
464
+
465
+ ```typescript
466
+ const result = await safeFetch(url, {
467
+ onEvent: (event) => {
468
+ // Safe: fire-and-forget to external system
469
+ telemetry.emit(event).catch(() => {});
470
+
471
+ // Safe: increment counter
472
+ metrics.increment(`peac.${event.type}`);
473
+
474
+ // UNSAFE: DO NOT call safeFetch from hook
475
+ // UNSAFE: DO NOT throw exceptions
476
+ // UNSAFE: DO NOT modify event object
477
+ },
478
+ });
479
+ ```
480
+
481
+ **Observability recommendations:**
482
+
483
+ 1. **Alert on `audit_overflow`** - indicates evidence quality degradation
484
+ 2. **Alert on `audit_hook_error`** - indicates hook implementation issues (check `meta.error_message`)
485
+ 3. **Check `audit_truncated`** - if `true`, evidence is incomplete for compliance/audit purposes
486
+ 4. **Track `audit_stats.dropped`** - monitor evidence loss over time
487
+ 5. **Export `evidence_digest`** - enables cross-system correlation
488
+
489
+ **Exception handling:**
490
+
491
+ Hook exceptions are caught and an `audit_hook_error` event is emitted (rate-limited to 1 per second). This provides observability without causing recursion. Key behaviors:
492
+
493
+ - **Sanitization**: Error messages are truncated to 200 chars and have secrets redacted (Bearer tokens, passwords, keys)
494
+ - **Structured fields**: Includes `error_code`, `original_event_type` for debugging
495
+ - **Recursion guard**: If the `audit_hook_error` event handler itself throws, that exception is silently discarded to prevent infinite loops
496
+
497
+ For additional debugging, you can wrap your hook in a try/catch:
498
+
499
+ ```typescript
500
+ onEvent: (event) => {
501
+ try {
502
+ myHandler(event);
503
+ } catch (err) {
504
+ // Log to dedicated error channel, NOT via safeFetch
505
+ errorLogger.error('Hook failed', { error: err, eventType: event.type });
506
+ }
507
+ };
508
+ ```
509
+
510
+ **Execution model:**
511
+
512
+ - Hooks are **fire-and-forget** - the hook function is invoked but NOT awaited
513
+ - The fetch completes regardless of hook execution status
514
+ - Slow hooks do NOT block the fetch response
515
+ - Hook microtasks execute after the current task completes
516
+
517
+ **Non-reentrancy rule:**
518
+
519
+ Hooks MUST NOT call `safeFetch` or any function that transitively calls `safeFetch`. This would cause:
520
+
521
+ 1. Infinite recursion (each fetch triggers hooks, which trigger fetches)
522
+ 2. Queue exhaustion and dropped events
523
+ 3. Unpredictable memory growth
524
+
525
+ The implementation does NOT detect reentrancy - it is the caller's responsibility.
526
+
527
+ **Backpressure and overflow:**
528
+
529
+ When the audit queue is full (default: 1000 pending events), the following occurs:
530
+
531
+ 1. New events for that request are **dropped** (not queued)
532
+ 2. An `audit_overflow` event is emitted **synchronously** to the hook
533
+ 3. `audit_overflow` is rate-limited to **1 per second** (prevents log spam)
534
+ 4. The `audit_stats` field in evidence will show `dropped > 0`
535
+ 5. The `audit_truncated` field in evidence will be `true`
536
+
537
+ **Partial loss semantics:** When overflow occurs during a request, that specific request's events may be partially lost. The request itself completes normally - only observability is degraded.
538
+
539
+ **`audit_truncated` presence semantics (NORMATIVE):**
540
+
541
+ This field uses **presence semantics** to ensure cross-implementation digest compatibility:
542
+
543
+ - **Present and `true`**: Audit event stream is incomplete, some events were dropped
544
+ - **Absent (undefined)**: Evidence is complete, no truncation occurred
545
+
546
+ **Normative rules:**
547
+
548
+ - Producers MUST omit `audit_truncated` when no truncation occurred
549
+ - Producers MUST NOT emit `audit_truncated: false`
550
+ - Consumers SHOULD treat absent `audit_truncated` as "not truncated"
551
+
552
+ The reason for presence semantics: `{ ...core }` and `{ ...core, audit_truncated: false }` produce different canonical JSON and thus different digests. By requiring omission when false, we guarantee that two implementations producing logically identical evidence will compute the same digest.
553
+
554
+ **Interpretation:**
555
+
556
+ - `audit_truncated: true` means "audit event stream is incomplete" - some events were dropped
557
+ - This does NOT mean the evidence is invalid or the request failed
558
+ - **Recommended policy**: Treat records with `audit_truncated: true` as having **degraded observability**, not failed verification
559
+ - The fetch result, evidence digest, and policy decision are still valid and cryptographically bound
560
+ - For compliance-critical use cases, consider alerting on `audit_truncated: true` and investigating capacity issues
561
+
562
+ **Recommended integration with correlation:**
563
+
564
+ ```typescript
565
+ import { randomUUID } from 'crypto';
566
+ import { safeFetch } from '@peac/net';
567
+
568
+ async function fetchWithCorrelation(url: string, correlationId?: string) {
569
+ const requestId = correlationId ?? randomUUID();
570
+
571
+ return safeFetch(url, {
572
+ onEvent: (event) => {
573
+ // Attach correlation ID for distributed tracing
574
+ const enriched = {
575
+ ...event,
576
+ correlation_id: requestId,
577
+ service: 'my-service',
578
+ timestamp: Date.now(),
579
+ };
580
+
581
+ // Fire-and-forget to telemetry
582
+ telemetry.emit(enriched).catch(() => {});
583
+
584
+ // Alert on quality degradation
585
+ if (event.type === 'audit_overflow') {
586
+ alerting.warn('Evidence quality degraded', {
587
+ correlation_id: requestId,
588
+ dropped: event.meta?.dropped,
589
+ });
590
+ }
591
+ },
592
+ });
593
+ }
594
+ ```
595
+
596
+ ## Policy Layering (Defense in Depth)
597
+
598
+ This package implements defense-in-depth through layered validation. Understanding which layer blocks a request helps with debugging and policy design.
599
+
600
+ ### Validation Layers
601
+
602
+ ```text
603
+ Request URL
604
+ |
605
+ v
606
+ +-------------------+
607
+ | Layer 1: Schema | <-- @peac/schema (string-level)
608
+ | - URL structure | Blocks: invalid URLs, non-HTTPS, non-standard ports
609
+ | - allowPorts | Error: E_NET_SSRF_URL_REJECTED
610
+ | - allowHosts |
611
+ +-------------------+
612
+ |
613
+ v
614
+ +-------------------+
615
+ | Layer 2: Net | <-- @peac/net (runtime)
616
+ | - Dangerous ports | Blocks: ports in DANGEROUS_PORTS set
617
+ | - DNS resolution | Error: E_NET_SSRF_DANGEROUS_PORT
618
+ | - IP validation |
619
+ | - Redirect policy |
620
+ +-------------------+
621
+ |
622
+ v
623
+ Fetch
624
+ ```
625
+
626
+ ### Layer Responsibilities
627
+
628
+ | Layer | Package | Validates | Error Prefix |
629
+ | ------ | -------------- | ----------------------------------------------------- | ----------------------------- |
630
+ | Schema | `@peac/schema` | URL structure, scheme, port allowlist, host allowlist | `E_NET_SSRF_URL_*` |
631
+ | Net | `@peac/net` | Dangerous ports, DNS resolution, IP ranges, redirects | `E_NET_SSRF_*`, `E_NET_DNS_*` |
632
+
633
+ ### Why Two Layers?
634
+
635
+ **Schema layer** provides fast, string-level rejection of obviously invalid requests. This is cheap and can be done before any network I/O.
636
+
637
+ **Net layer** provides runtime security that requires network operations:
638
+
639
+ - DNS resolution to detect rebinding attacks
640
+ - IP range validation against resolved addresses
641
+ - Redirect chain validation
642
+
643
+ ### Example: Port 22 (SSH)
644
+
645
+ ```typescript
646
+ // Scenario 1: Port 22 not in allowPorts
647
+ // Result: BLOCKED at Schema layer (E_NET_SSRF_URL_REJECTED)
648
+ await safeFetch('https://example.com:22/api');
649
+
650
+ // Scenario 2: Port 22 in allowPorts, no dangerous port ack
651
+ // Result: BLOCKED at Net layer (E_NET_SSRF_DANGEROUS_PORT)
652
+ await safeFetch('https://example.com:22/api', {
653
+ ssrfPolicy: { allowPorts: [22] },
654
+ });
655
+
656
+ // Scenario 3: Full acknowledgment chain
657
+ // Result: ALLOWED (with warning in evidence)
658
+ await safeFetch('https://example.com:22/api', {
659
+ ssrfPolicy: { allowPorts: [22] },
660
+ allowDangerousPorts: true,
661
+ ack_allow_dangerous_ports: ALLOW_DANGEROUS_PORTS_ACK,
662
+ });
663
+ ```
664
+
665
+ ### Redirect Hop Validation
666
+
667
+ Both layers are applied to **every hop** in a redirect chain:
668
+
669
+ ```typescript
670
+ // Initial URL: https://example.com/api (port 443, passes both layers)
671
+ // Redirect to: https://internal.example.com:22/data
672
+ //
673
+ // Even though the initial request passes, the redirect target
674
+ // is validated through both layers again:
675
+ // - Schema: port 22 must be in allowPorts
676
+ // - Net: port 22 requires DANGEROUS_PORTS acknowledgment
677
+ ```
678
+
679
+ ## API Reference
680
+
681
+ ### `safeFetch<T>(url, options?)`
682
+
683
+ SSRF-safe fetch with DNS resolution pinning.
684
+
685
+ **Parameters:**
686
+
687
+ - `url`: The URL to fetch
688
+ - `options`: Optional configuration (see `SafeFetchOptions`)
689
+
690
+ **Returns:** `Promise<SafeFetchResult<T>>`
691
+
692
+ ### `safeFetchJWKS(url, options?)`
693
+
694
+ Convenience wrapper for JWKS endpoints with:
695
+
696
+ - 10 second timeout (default)
697
+ - No redirects allowed
698
+ - Type-safe `{ keys: unknown[] }` response
699
+
700
+ ### `SafeFetchOptions`
701
+
702
+ | Option | Type | Default | Description |
703
+ | ------------------------- | ------------ | --------------------- | ---------------------------------- |
704
+ | `ssrfPolicy` | `SSRFPolicy` | `DEFAULT_SSRF_POLICY` | SSRF protection configuration |
705
+ | `timeoutMs` | `number` | `30000` | Request timeout in milliseconds |
706
+ | `maxRedirects` | `number` | `5` | Maximum redirects to follow |
707
+ | `allowCrossHostRedirects` | `boolean` | `false` | Allow redirects to different hosts |
708
+
709
+ ### Error Codes
710
+
711
+ | Code | Description |
712
+ | --------------------------------------- | --------------------------------------- |
713
+ | `E_NET_SSRF_URL_REJECTED` | URL failed string-level validation |
714
+ | `E_NET_SSRF_DNS_RESOLVED_PRIVATE` | DNS resolved to private IP |
715
+ | `E_NET_SSRF_ALL_IPS_BLOCKED` | All resolved IPs failed validation |
716
+ | `E_NET_SSRF_REDIRECT_BLOCKED` | Redirect blocked by policy |
717
+ | `E_NET_SSRF_TOO_MANY_REDIRECTS` | Exceeded max redirects |
718
+ | `E_NET_SSRF_ALLOWCIDRS_ACK_REQUIRED` | Dangerous CIDR escape hatch missing ack |
719
+ | `E_NET_SSRF_DANGEROUS_PORT` | Port in dangerous ports blocklist |
720
+ | `E_NET_SSRF_DANGEROUS_PORT_ACK_MISSING` | Dangerous port requires explicit ack |
721
+ | `E_NET_SSRF_IPV6_ZONE_ID` | IPv6 zone identifier detected |
722
+ | `E_NET_SSRF_INVALID_HOST` | Hostname failed canonicalization |
723
+ | `E_NET_TENANT_KEY_MISSING` | Tenant mode missing redactionKey/keyId |
724
+ | `E_NET_DNS_RESOLUTION_FAILED` | DNS lookup failed |
725
+ | `E_NET_REQUEST_TIMEOUT` | Request timed out |
726
+ | `E_NET_NETWORK_ERROR` | General network error |
727
+
728
+ ## License
729
+
730
+ Apache-2.0
731
+
732
+ ---
733
+
734
+ PEAC Protocol is an open source project stewarded by Originary and community contributors.
735
+
736
+ [Docs](https://www.peacprotocol.org) | [GitHub](https://github.com/peacprotocol/peac) | [Originary](https://www.originary.xyz)