@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.
- package/LICENSE +190 -0
- package/README.md +736 -0
- package/dist/codes.d.ts +47 -0
- package/dist/codes.d.ts.map +1 -0
- package/dist/codes.js +53 -0
- package/dist/codes.js.map +1 -0
- package/dist/constants.d.ts +39 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +39 -0
- package/dist/constants.js.map +1 -0
- package/dist/evidence-utils.d.ts +49 -0
- package/dist/evidence-utils.d.ts.map +1 -0
- package/dist/evidence-utils.js +109 -0
- package/dist/evidence-utils.js.map +1 -0
- package/dist/impl.d.ts +331 -0
- package/dist/impl.d.ts.map +1 -0
- package/dist/impl.js +1188 -0
- package/dist/impl.js.map +1 -0
- package/dist/index.d.ts +772 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1121 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +28 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +59 -0
- package/dist/internal.js.map +1 -0
- package/dist/receipt-resolver.d.ts +65 -0
- package/dist/receipt-resolver.d.ts.map +1 -0
- package/dist/receipt-resolver.js +140 -0
- package/dist/receipt-resolver.js.map +1 -0
- package/dist/receipt-url-middleware.d.ts +87 -0
- package/dist/receipt-url-middleware.d.ts.map +1 -0
- package/dist/receipt-url-middleware.js +141 -0
- package/dist/receipt-url-middleware.js.map +1 -0
- package/dist/ssrf.d.ts +114 -0
- package/dist/ssrf.d.ts.map +1 -0
- package/dist/ssrf.js +214 -0
- package/dist/ssrf.js.map +1 -0
- package/dist/testing.d.ts +65 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +103 -0
- package/dist/testing.js.map +1 -0
- 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
|