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