@yedoma-labs/tanur-ssrf 0.0.0-stage → 1.0.0
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 +21 -0
- package/README.md +130 -2
- package/dist/index.cjs +720 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +61 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.js +717 -0
- package/dist/index.js.map +1 -0
- package/package.json +84 -5
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_dns_promises = require("node:dns/promises");
|
|
3
|
+
let node_http = require("node:http");
|
|
4
|
+
let node_https = require("node:https");
|
|
5
|
+
let node_zlib = require("node:zlib");
|
|
6
|
+
// src/errors.ts
|
|
7
|
+
/**
|
|
8
|
+
* Typed error thrown by the safe fetch. Messages never contain the URL, its
|
|
9
|
+
* query string or any credentials; they only name the blocked host or address.
|
|
10
|
+
*/
|
|
11
|
+
var SafeFetchError = class extends Error {
|
|
12
|
+
code;
|
|
13
|
+
constructor(code, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "SafeFetchError";
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
// src/body.ts
|
|
20
|
+
/**
|
|
21
|
+
* Reads a Response body into memory, failing once more than `maxBytes` bytes arrive.
|
|
22
|
+
* The stream is cancelled at the limit, so an oversized body is not buffered.
|
|
23
|
+
*/
|
|
24
|
+
async function readBodyWithLimit(response, maxBytes) {
|
|
25
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new TypeError("maxBytes must be a non-negative integer");
|
|
26
|
+
if (response.body === null) return /* @__PURE__ */ new Uint8Array(0);
|
|
27
|
+
const reader = response.body.getReader();
|
|
28
|
+
const chunks = [];
|
|
29
|
+
let total = 0;
|
|
30
|
+
for (;;) {
|
|
31
|
+
const { done, value } = await reader.read();
|
|
32
|
+
if (done) break;
|
|
33
|
+
total += value.byteLength;
|
|
34
|
+
if (total > maxBytes) {
|
|
35
|
+
await reader.cancel();
|
|
36
|
+
throw new SafeFetchError("BODY_TOO_LARGE", `Response body exceeds ${maxBytes} bytes`);
|
|
37
|
+
}
|
|
38
|
+
chunks.push(value);
|
|
39
|
+
}
|
|
40
|
+
const result = new Uint8Array(total);
|
|
41
|
+
let offset = 0;
|
|
42
|
+
for (const chunk of chunks) {
|
|
43
|
+
result.set(chunk, offset);
|
|
44
|
+
offset += chunk.byteLength;
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
// src/ip.ts
|
|
49
|
+
const IPV4_PART = /^(?:0|[1-9]\d{0,2})$/;
|
|
50
|
+
const IPV6_GROUP = /^[0-9a-fA-F]{1,4}$/;
|
|
51
|
+
/**
|
|
52
|
+
* Parses a dotted-decimal IPv4 address. Leading zeros are rejected because
|
|
53
|
+
* they are ambiguous (octal in some resolvers).
|
|
54
|
+
*/
|
|
55
|
+
function parseIpv4(text) {
|
|
56
|
+
const parts = text.split(".");
|
|
57
|
+
if (parts.length !== 4) return;
|
|
58
|
+
const bytes = /* @__PURE__ */ new Uint8Array(4);
|
|
59
|
+
for (let index = 0; index < 4; index++) {
|
|
60
|
+
const part = parts[index];
|
|
61
|
+
if (part === void 0 || !IPV4_PART.test(part)) return;
|
|
62
|
+
const value = Number(part);
|
|
63
|
+
if (value > 255) return;
|
|
64
|
+
bytes[index] = value;
|
|
65
|
+
}
|
|
66
|
+
return bytes;
|
|
67
|
+
}
|
|
68
|
+
/** Parses 8 groups of 16 bits from a side of an IPv6 literal. */
|
|
69
|
+
function parseGroups(side, allowIpv4Tail) {
|
|
70
|
+
if (side === "") return [];
|
|
71
|
+
const parts = side.split(":");
|
|
72
|
+
const groups = [];
|
|
73
|
+
for (let index = 0; index < parts.length; index++) {
|
|
74
|
+
const part = parts[index];
|
|
75
|
+
if (part === void 0) return;
|
|
76
|
+
if (allowIpv4Tail && index === parts.length - 1 && part.includes(".")) {
|
|
77
|
+
const v4 = parseIpv4(part);
|
|
78
|
+
if (v4 === void 0) return;
|
|
79
|
+
groups.push(byteAt(v4, 0) << 8 | byteAt(v4, 1), byteAt(v4, 2) << 8 | byteAt(v4, 3));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!IPV6_GROUP.test(part)) return;
|
|
83
|
+
groups.push(Number.parseInt(part, 16));
|
|
84
|
+
}
|
|
85
|
+
return groups;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Parses an IPv6 literal (without brackets or zone id). Supports `::`
|
|
89
|
+
* compression and an embedded dotted IPv4 tail. Zone ids are rejected.
|
|
90
|
+
*/
|
|
91
|
+
function parseIpv6(text) {
|
|
92
|
+
if (text.includes("%") || !text.includes(":")) return;
|
|
93
|
+
const compressionAt = text.indexOf("::");
|
|
94
|
+
if (compressionAt !== text.lastIndexOf("::")) return;
|
|
95
|
+
let groups;
|
|
96
|
+
if (compressionAt === -1) {
|
|
97
|
+
const all = parseGroups(text, true);
|
|
98
|
+
if (all === void 0 || all.length !== 8) return;
|
|
99
|
+
groups = all;
|
|
100
|
+
} else {
|
|
101
|
+
const head = parseGroups(text.slice(0, compressionAt), false);
|
|
102
|
+
const tail = parseGroups(text.slice(compressionAt + 2), true);
|
|
103
|
+
if (head === void 0 || tail === void 0) return;
|
|
104
|
+
const missing = 8 - head.length - tail.length;
|
|
105
|
+
if (missing < 1) return;
|
|
106
|
+
groups = [
|
|
107
|
+
...head,
|
|
108
|
+
...new Array(missing).fill(0),
|
|
109
|
+
...tail
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
const bytes = /* @__PURE__ */ new Uint8Array(16);
|
|
113
|
+
for (let index = 0; index < 8; index++) {
|
|
114
|
+
const group = groups[index] ?? 0;
|
|
115
|
+
bytes[index * 2] = group >> 8;
|
|
116
|
+
bytes[index * 2 + 1] = group & 255;
|
|
117
|
+
}
|
|
118
|
+
return bytes;
|
|
119
|
+
}
|
|
120
|
+
/** Parses an IPv4 or IPv6 literal. Returns undefined for anything else. */
|
|
121
|
+
function parseIp(text) {
|
|
122
|
+
if (text.includes(":")) {
|
|
123
|
+
const bytes = parseIpv6(text);
|
|
124
|
+
return bytes === void 0 ? void 0 : {
|
|
125
|
+
family: 6,
|
|
126
|
+
bytes
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const bytes = parseIpv4(text);
|
|
130
|
+
return bytes === void 0 ? void 0 : {
|
|
131
|
+
family: 4,
|
|
132
|
+
bytes
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function byteAt(bytes, index) {
|
|
136
|
+
return bytes[index] ?? 0;
|
|
137
|
+
}
|
|
138
|
+
function u32(a, b, c, d) {
|
|
139
|
+
return (a << 24 | b << 16 | c << 8 | d) >>> 0;
|
|
140
|
+
}
|
|
141
|
+
const BLOCKED_IPV4 = [
|
|
142
|
+
{
|
|
143
|
+
network: u32(0, 0, 0, 0),
|
|
144
|
+
prefix: 8,
|
|
145
|
+
name: "unspecified network (0.0.0.0/8)"
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
network: u32(10, 0, 0, 0),
|
|
149
|
+
prefix: 8,
|
|
150
|
+
name: "private (10.0.0.0/8)"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
network: u32(100, 64, 0, 0),
|
|
154
|
+
prefix: 10,
|
|
155
|
+
name: "shared address space (100.64.0.0/10)"
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
network: u32(127, 0, 0, 0),
|
|
159
|
+
prefix: 8,
|
|
160
|
+
name: "loopback (127.0.0.0/8)"
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
network: u32(169, 254, 0, 0),
|
|
164
|
+
prefix: 16,
|
|
165
|
+
name: "link-local (169.254.0.0/16)"
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
network: u32(172, 16, 0, 0),
|
|
169
|
+
prefix: 12,
|
|
170
|
+
name: "private (172.16.0.0/12)"
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
network: u32(192, 0, 0, 0),
|
|
174
|
+
prefix: 24,
|
|
175
|
+
name: "IETF protocol assignments (192.0.0.0/24)"
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
network: u32(192, 0, 2, 0),
|
|
179
|
+
prefix: 24,
|
|
180
|
+
name: "documentation (192.0.2.0/24)"
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
network: u32(192, 168, 0, 0),
|
|
184
|
+
prefix: 16,
|
|
185
|
+
name: "private (192.168.0.0/16)"
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
network: u32(198, 18, 0, 0),
|
|
189
|
+
prefix: 15,
|
|
190
|
+
name: "benchmarking (198.18.0.0/15)"
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
network: u32(198, 51, 100, 0),
|
|
194
|
+
prefix: 24,
|
|
195
|
+
name: "documentation (198.51.100.0/24)"
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
network: u32(203, 0, 113, 0),
|
|
199
|
+
prefix: 24,
|
|
200
|
+
name: "documentation (203.0.113.0/24)"
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
network: u32(224, 0, 0, 0),
|
|
204
|
+
prefix: 4,
|
|
205
|
+
name: "multicast (224.0.0.0/4)"
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
network: u32(240, 0, 0, 0),
|
|
209
|
+
prefix: 4,
|
|
210
|
+
name: "reserved (240.0.0.0/4)"
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
network: u32(255, 255, 255, 255),
|
|
214
|
+
prefix: 32,
|
|
215
|
+
name: "broadcast (255.255.255.255)"
|
|
216
|
+
}
|
|
217
|
+
];
|
|
218
|
+
function ipv4Value(bytes) {
|
|
219
|
+
return u32(byteAt(bytes, 0), byteAt(bytes, 1), byteAt(bytes, 2), byteAt(bytes, 3));
|
|
220
|
+
}
|
|
221
|
+
function inPrefix(value, network, prefix) {
|
|
222
|
+
const mask = 4294967295 << 32 - prefix >>> 0;
|
|
223
|
+
return (value & mask) >>> 0 === (network & mask) >>> 0;
|
|
224
|
+
}
|
|
225
|
+
/** Returns the name of the blocked IPv4 range, or undefined if the address is public. */
|
|
226
|
+
function classifyIpv4(bytes) {
|
|
227
|
+
const value = ipv4Value(bytes);
|
|
228
|
+
for (const range of BLOCKED_IPV4) if (inPrefix(value, range.network, range.prefix)) return range.name;
|
|
229
|
+
}
|
|
230
|
+
/** True if bytes[from..to) are all zero. */
|
|
231
|
+
function zeroBetween(bytes, from, to) {
|
|
232
|
+
for (let index = from; index < to; index++) if (byteAt(bytes, index) !== 0) return false;
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
function bytesEqual(bytes, expected) {
|
|
236
|
+
return expected.every((value, index) => byteAt(bytes, index) === value);
|
|
237
|
+
}
|
|
238
|
+
function classifyEmbeddedIpv4(bytes, start, label) {
|
|
239
|
+
const reason = classifyIpv4(bytes.subarray(start, start + 4));
|
|
240
|
+
return reason === void 0 ? void 0 : `${label}, embedded address in ${reason}`;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Returns the name of the blocked IPv6 range, or undefined if the address is public.
|
|
244
|
+
* Embedded IPv4 forms (mapped, NAT64, 6to4) are classified by their IPv4 address.
|
|
245
|
+
*/
|
|
246
|
+
function classifyIpv6(bytes) {
|
|
247
|
+
if (zeroBetween(bytes, 0, 12)) {
|
|
248
|
+
if (zeroBetween(bytes, 12, 15) && byteAt(bytes, 15) === 1) return "loopback (::1)";
|
|
249
|
+
return "unspecified or IPv4-compatible (::/96)";
|
|
250
|
+
}
|
|
251
|
+
if (zeroBetween(bytes, 0, 10) && bytesEqual(bytes.subarray(10, 12), [255, 255])) {
|
|
252
|
+
const reason = classifyIpv4(bytes.subarray(12, 16));
|
|
253
|
+
return reason === void 0 ? void 0 : `IPv4-mapped, ${reason}`;
|
|
254
|
+
}
|
|
255
|
+
if (zeroBetween(bytes, 0, 8) && bytesEqual(bytes.subarray(8, 12), [
|
|
256
|
+
255,
|
|
257
|
+
255,
|
|
258
|
+
0,
|
|
259
|
+
0
|
|
260
|
+
])) return "IPv4-translated (::ffff:0:0:0/96)";
|
|
261
|
+
if (bytesEqual(bytes, [
|
|
262
|
+
0,
|
|
263
|
+
100,
|
|
264
|
+
255,
|
|
265
|
+
155
|
|
266
|
+
]) && zeroBetween(bytes, 4, 12)) return classifyEmbeddedIpv4(bytes, 12, "NAT64 (64:ff9b::/96)");
|
|
267
|
+
if (bytesEqual(bytes, [32, 2])) return classifyEmbeddedIpv4(bytes, 2, "6to4 (2002::/16)");
|
|
268
|
+
if (bytesEqual(bytes, [
|
|
269
|
+
32,
|
|
270
|
+
1,
|
|
271
|
+
0,
|
|
272
|
+
0
|
|
273
|
+
])) return "Teredo (2001::/32)";
|
|
274
|
+
if (bytesEqual(bytes, [1, 0]) && zeroBetween(bytes, 2, 8)) return "discard-only (100::/64)";
|
|
275
|
+
if (bytesEqual(bytes, [
|
|
276
|
+
32,
|
|
277
|
+
1,
|
|
278
|
+
13,
|
|
279
|
+
184
|
|
280
|
+
])) return "documentation (2001:db8::/32)";
|
|
281
|
+
const first = byteAt(bytes, 0);
|
|
282
|
+
const second = byteAt(bytes, 1);
|
|
283
|
+
if ((first & 254) === 252) return "unique local (fc00::/7)";
|
|
284
|
+
if (first === 254 && (second & 192) === 128) return "link-local (fe80::/10)";
|
|
285
|
+
if (first === 254 && (second & 192) === 192) return "site-local (fec0::/10)";
|
|
286
|
+
if (first === 255) return "multicast (ff00::/8)";
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Returns a description of the blocked range the address falls into, or
|
|
290
|
+
* undefined if the address is globally routable (as far as this list knows).
|
|
291
|
+
*/
|
|
292
|
+
function getBlockedRange(ip) {
|
|
293
|
+
return ip.family === 4 ? classifyIpv4(ip.bytes) : classifyIpv6(ip.bytes);
|
|
294
|
+
}
|
|
295
|
+
// src/types.ts
|
|
296
|
+
const DEFAULT_ALLOWED_PROTOCOLS = ["https:", "http:"];
|
|
297
|
+
const DEFAULT_ALLOWED_PORTS = [80, 443];
|
|
298
|
+
const DEFAULT_BLOCKED_HOSTNAMES = [
|
|
299
|
+
"localhost",
|
|
300
|
+
"metadata.google.internal",
|
|
301
|
+
".localhost",
|
|
302
|
+
".internal"
|
|
303
|
+
];
|
|
304
|
+
// src/policy.ts
|
|
305
|
+
const DEFAULT_PORTS = {
|
|
306
|
+
"http:": 80,
|
|
307
|
+
"https:": 443
|
|
308
|
+
};
|
|
309
|
+
const PROTOCOL_PATTERN = /^[a-z][a-z0-9+.-]*:$/;
|
|
310
|
+
const defaultResolver = (hostname) => (0, node_dns_promises.lookup)(hostname, {
|
|
311
|
+
all: true,
|
|
312
|
+
verbatim: true
|
|
313
|
+
});
|
|
314
|
+
/** Validates caller options and applies defaults. Invalid options throw TypeError. */
|
|
315
|
+
function resolveOptions(options) {
|
|
316
|
+
const allowedProtocols = [...options.allowedProtocols ?? DEFAULT_ALLOWED_PROTOCOLS];
|
|
317
|
+
for (const protocol of allowedProtocols) if (!PROTOCOL_PATTERN.test(protocol)) throw new TypeError(`Invalid protocol in allowedProtocols: ${protocol}`);
|
|
318
|
+
const allowedPorts = [...options.allowedPorts ?? DEFAULT_ALLOWED_PORTS];
|
|
319
|
+
for (const port of allowedPorts) if (!Number.isInteger(port) || port < 1 || port > 65535) throw new TypeError(`Invalid port in allowedPorts: ${String(port)}`);
|
|
320
|
+
const blockedHostnames = (options.blockedHostnames ?? DEFAULT_BLOCKED_HOSTNAMES).map((entry) => entry.toLowerCase().replace(/\.$/, ""));
|
|
321
|
+
const maxRedirects = options.maxRedirects ?? 5;
|
|
322
|
+
if (!Number.isSafeInteger(maxRedirects) || maxRedirects < 0) throw new TypeError("maxRedirects must be a non-negative integer");
|
|
323
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
324
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2147483647) throw new TypeError("timeoutMs must be a positive number of milliseconds");
|
|
325
|
+
const maxBytes = options.maxBytes;
|
|
326
|
+
if (maxBytes !== void 0 && (!Number.isSafeInteger(maxBytes) || maxBytes < 0)) throw new TypeError("maxBytes must be a non-negative integer");
|
|
327
|
+
const resolver = options.resolver ?? defaultResolver;
|
|
328
|
+
if (typeof resolver !== "function") throw new TypeError("resolver must be a function");
|
|
329
|
+
return {
|
|
330
|
+
allowedProtocols,
|
|
331
|
+
allowPrivateNetworks: options.allowPrivateNetworks ?? false,
|
|
332
|
+
allowedPorts,
|
|
333
|
+
blockedHostnames,
|
|
334
|
+
maxRedirects,
|
|
335
|
+
timeoutMs,
|
|
336
|
+
maxBytes,
|
|
337
|
+
resolver
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/** Parses a URL, with or without base. Never echoes the input in the error. */
|
|
341
|
+
function parseUrl(input, base) {
|
|
342
|
+
try {
|
|
343
|
+
return new URL(input, base);
|
|
344
|
+
} catch {
|
|
345
|
+
throw new SafeFetchError("INVALID_URL", "Invalid URL");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Lowercases, strips IPv6 brackets and one trailing dot. Rejects names that
|
|
350
|
+
* are empty after normalization.
|
|
351
|
+
*/
|
|
352
|
+
function normalizeHostname(rawHostname) {
|
|
353
|
+
let hostname = rawHostname.toLowerCase();
|
|
354
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) hostname = hostname.slice(1, -1);
|
|
355
|
+
if (hostname.endsWith(".")) hostname = hostname.slice(0, -1);
|
|
356
|
+
if (hostname === "" || hostname.endsWith(".")) throw new SafeFetchError("INVALID_URL", "Invalid URL");
|
|
357
|
+
return hostname;
|
|
358
|
+
}
|
|
359
|
+
function isBlockedHostname(hostname, blocked) {
|
|
360
|
+
return blocked.some((entry) => {
|
|
361
|
+
if (entry.startsWith(".")) return hostname === entry.slice(1) || hostname.endsWith(entry);
|
|
362
|
+
return hostname === entry;
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
function assertNoCredentials(url) {
|
|
366
|
+
if (url.username !== "" || url.password !== "") throw new SafeFetchError("INVALID_URL", "URLs with credentials are not allowed");
|
|
367
|
+
}
|
|
368
|
+
function raceAbort(promise, signal) {
|
|
369
|
+
if (signal.aborted) {
|
|
370
|
+
promise.catch(() => void 0);
|
|
371
|
+
return Promise.reject(new DOMException("The operation was aborted", "AbortError"));
|
|
372
|
+
}
|
|
373
|
+
return new Promise((resolve, reject) => {
|
|
374
|
+
const onAbort = () => reject(new DOMException("The operation was aborted", "AbortError"));
|
|
375
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
376
|
+
promise.then((value) => {
|
|
377
|
+
signal.removeEventListener("abort", onAbort);
|
|
378
|
+
resolve(value);
|
|
379
|
+
}, (error) => {
|
|
380
|
+
signal.removeEventListener("abort", onAbort);
|
|
381
|
+
reject(error);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
async function resolveAddresses(options, hostname, signal) {
|
|
386
|
+
let answers;
|
|
387
|
+
try {
|
|
388
|
+
answers = await raceAbort(options.resolver(hostname), signal);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (signal.aborted) throw error;
|
|
391
|
+
throw new SafeFetchError("DNS_FAILURE", `DNS lookup failed for ${hostname}`);
|
|
392
|
+
}
|
|
393
|
+
if (!Array.isArray(answers) || answers.length === 0) throw new SafeFetchError("DNS_FAILURE", `DNS lookup returned no addresses for ${hostname}`);
|
|
394
|
+
return answers.map((answer) => {
|
|
395
|
+
if (typeof answer?.address !== "string") throw new SafeFetchError("BLOCKED_ADDRESS", `Resolver returned a non-IP value for ${hostname}`);
|
|
396
|
+
return answer.address;
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Validates one URL hop and resolves it to exactly one pinned address.
|
|
401
|
+
*
|
|
402
|
+
* Every address returned for the hostname is checked. If any of them is blocked,
|
|
403
|
+
* the whole hop is rejected, so a mixed answer cannot be used to smuggle a private
|
|
404
|
+
* address past the check.
|
|
405
|
+
*/
|
|
406
|
+
async function resolveTarget(url, options, signal) {
|
|
407
|
+
assertNoCredentials(url);
|
|
408
|
+
const protocol = url.protocol;
|
|
409
|
+
if (protocol !== "http:" && protocol !== "https:" || !options.allowedProtocols.includes(protocol)) throw new SafeFetchError("PROTOCOL_NOT_ALLOWED", `Protocol not allowed: ${protocol}`);
|
|
410
|
+
const port = url.port === "" ? DEFAULT_PORTS[protocol] : Number(url.port);
|
|
411
|
+
if (!options.allowedPorts.includes(port)) throw new SafeFetchError("PORT_NOT_ALLOWED", `Port not allowed: ${String(port)}`);
|
|
412
|
+
const hostname = normalizeHostname(url.hostname);
|
|
413
|
+
const literal = parseIp(hostname);
|
|
414
|
+
if (literal === void 0 && isBlockedHostname(hostname, options.blockedHostnames)) throw new SafeFetchError("BLOCKED_HOSTNAME", `Hostname not allowed: ${hostname}`);
|
|
415
|
+
const candidates = literal === void 0 ? await resolveAddresses(options, hostname, signal) : [hostname];
|
|
416
|
+
if (candidates.length === 0) throw new SafeFetchError("DNS_FAILURE", `DNS lookup returned no addresses for ${hostname}`);
|
|
417
|
+
let pinned;
|
|
418
|
+
for (const candidate of candidates) {
|
|
419
|
+
const ip = parseIp(candidate);
|
|
420
|
+
if (ip === void 0) throw new SafeFetchError("BLOCKED_ADDRESS", `Not a valid IP address for ${hostname}`);
|
|
421
|
+
if (!options.allowPrivateNetworks) {
|
|
422
|
+
const range = getBlockedRange(ip);
|
|
423
|
+
if (range !== void 0) throw new SafeFetchError("BLOCKED_ADDRESS", `Address ${candidate} of ${hostname} is in a blocked range: ${range}`);
|
|
424
|
+
}
|
|
425
|
+
pinned ??= {
|
|
426
|
+
address: candidate,
|
|
427
|
+
family: ip.family
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
if (pinned === void 0) throw new SafeFetchError("DNS_FAILURE", `DNS lookup returned no addresses for ${hostname}`);
|
|
431
|
+
return {
|
|
432
|
+
url,
|
|
433
|
+
protocol,
|
|
434
|
+
hostname,
|
|
435
|
+
isIpLiteral: literal !== void 0,
|
|
436
|
+
port,
|
|
437
|
+
path: `${url.pathname}${url.search}`,
|
|
438
|
+
address: pinned.address,
|
|
439
|
+
family: pinned.family
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
// src/transport.ts
|
|
443
|
+
const NULL_BODY_STATUS = /* @__PURE__ */ new Set([
|
|
444
|
+
204,
|
|
445
|
+
205,
|
|
446
|
+
304
|
|
447
|
+
]);
|
|
448
|
+
/** Lookup that returns exactly the address validated in resolveTarget. Node never re-resolves. */
|
|
449
|
+
function pinnedLookup(target) {
|
|
450
|
+
return ((_hostname, options, callback) => {
|
|
451
|
+
const done = callback;
|
|
452
|
+
if (options.all === true) done(null, [{
|
|
453
|
+
address: target.address,
|
|
454
|
+
family: target.family
|
|
455
|
+
}]);
|
|
456
|
+
else done(null, target.address, target.family);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
function hostHeaderValue(target) {
|
|
460
|
+
const host = target.hostname.includes(":") ? `[${target.hostname}]` : target.hostname;
|
|
461
|
+
return target.port === (target.protocol === "https:" ? 443 : 80) ? host : `${host}:${target.port}`;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Sends one request to the pinned address. `agent: false` prevents socket reuse,
|
|
465
|
+
* so a socket validated under one policy can never serve a request under another.
|
|
466
|
+
*/
|
|
467
|
+
function sendRequest(target, request, signal) {
|
|
468
|
+
return new Promise((resolve, reject) => {
|
|
469
|
+
const headers = {
|
|
470
|
+
...request.headers,
|
|
471
|
+
host: hostHeaderValue(target)
|
|
472
|
+
};
|
|
473
|
+
if (request.body !== void 0) headers["content-length"] = String(request.body.byteLength);
|
|
474
|
+
const options = {
|
|
475
|
+
protocol: target.protocol,
|
|
476
|
+
hostname: target.hostname,
|
|
477
|
+
port: target.port,
|
|
478
|
+
path: target.path,
|
|
479
|
+
method: request.method,
|
|
480
|
+
headers,
|
|
481
|
+
agent: false,
|
|
482
|
+
signal,
|
|
483
|
+
lookup: pinnedLookup(target)
|
|
484
|
+
};
|
|
485
|
+
if (target.protocol === "https:" && !target.isIpLiteral) options.servername = target.hostname;
|
|
486
|
+
const outgoing = (target.protocol === "https:" ? node_https.request : node_http.request)(options, resolve);
|
|
487
|
+
outgoing.on("error", reject);
|
|
488
|
+
outgoing.end(request.body);
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
function parseContentEncoding(value) {
|
|
492
|
+
if (value === void 0) return;
|
|
493
|
+
const token = value.trim().toLowerCase();
|
|
494
|
+
if (token === "" || token === "identity") return;
|
|
495
|
+
if (token === "gzip" || token === "x-gzip") return "gzip";
|
|
496
|
+
if (token === "deflate") return "deflate";
|
|
497
|
+
if (token === "br") return "br";
|
|
498
|
+
throw new SafeFetchError("NETWORK_ERROR", "Unsupported content-encoding in response");
|
|
499
|
+
}
|
|
500
|
+
function createDecoder(encoding) {
|
|
501
|
+
switch (encoding) {
|
|
502
|
+
case "gzip": return (0, node_zlib.createGunzip)();
|
|
503
|
+
case "deflate": return (0, node_zlib.createInflate)();
|
|
504
|
+
case "br": return (0, node_zlib.createBrotliDecompress)();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function safeStatusText(message) {
|
|
508
|
+
return message !== void 0 && /^[\x20-\x7e]*$/.test(message) ? message : "";
|
|
509
|
+
}
|
|
510
|
+
function createBodyStream(source, ctx) {
|
|
511
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
512
|
+
let received = 0;
|
|
513
|
+
return new ReadableStream({
|
|
514
|
+
async pull(controller) {
|
|
515
|
+
try {
|
|
516
|
+
const next = await iterator.next();
|
|
517
|
+
if (next.done === true) {
|
|
518
|
+
ctx.onSettled();
|
|
519
|
+
controller.close();
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const chunk = next.value;
|
|
523
|
+
received += chunk.byteLength;
|
|
524
|
+
if (ctx.maxBytes !== void 0 && received > ctx.maxBytes) throw new SafeFetchError("BODY_TOO_LARGE", `Response body exceeds ${ctx.maxBytes} bytes`);
|
|
525
|
+
controller.enqueue(chunk);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
ctx.onSettled();
|
|
528
|
+
source.destroy();
|
|
529
|
+
controller.error(ctx.toError(error));
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
cancel() {
|
|
533
|
+
ctx.onSettled();
|
|
534
|
+
source.destroy();
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Converts a Node response into a fetch-compatible Response. Redirects must
|
|
540
|
+
* already have been handled by the caller. Decodes gzip, deflate and br bodies.
|
|
541
|
+
*/
|
|
542
|
+
function toResponse(incoming, ctx) {
|
|
543
|
+
const status = incoming.statusCode ?? 0;
|
|
544
|
+
if (status < 200 || status > 599) {
|
|
545
|
+
incoming.destroy();
|
|
546
|
+
throw new SafeFetchError("NETWORK_ERROR", "Invalid HTTP status in response");
|
|
547
|
+
}
|
|
548
|
+
const headers = new Headers();
|
|
549
|
+
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
550
|
+
if (value === void 0) continue;
|
|
551
|
+
for (const item of Array.isArray(value) ? value : [value]) headers.append(name, item);
|
|
552
|
+
}
|
|
553
|
+
if (ctx.method === "HEAD" || NULL_BODY_STATUS.has(status)) {
|
|
554
|
+
incoming.resume();
|
|
555
|
+
ctx.onSettled();
|
|
556
|
+
return buildResponse(null, status, incoming.statusMessage, headers, ctx);
|
|
557
|
+
}
|
|
558
|
+
const encoding = parseContentEncoding(incoming.headers["content-encoding"]);
|
|
559
|
+
if (encoding === void 0) {
|
|
560
|
+
const declared = Number(incoming.headers["content-length"]);
|
|
561
|
+
if (ctx.maxBytes !== void 0 && Number.isFinite(declared) && declared > ctx.maxBytes) {
|
|
562
|
+
incoming.destroy();
|
|
563
|
+
throw new SafeFetchError("BODY_TOO_LARGE", `Response body exceeds ${ctx.maxBytes} bytes`);
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
headers.delete("content-encoding");
|
|
567
|
+
headers.delete("content-length");
|
|
568
|
+
}
|
|
569
|
+
incoming.on("error", () => void 0);
|
|
570
|
+
let source = incoming;
|
|
571
|
+
if (encoding !== void 0) {
|
|
572
|
+
const decoder = createDecoder(encoding);
|
|
573
|
+
decoder.on("error", () => incoming.destroy());
|
|
574
|
+
source = incoming.pipe(decoder);
|
|
575
|
+
incoming.on("error", (error) => decoder.destroy(error));
|
|
576
|
+
}
|
|
577
|
+
return buildResponse(createBodyStream(source, ctx), status, incoming.statusMessage, headers, ctx);
|
|
578
|
+
}
|
|
579
|
+
function buildResponse(body, status, statusMessage, headers, ctx) {
|
|
580
|
+
const response = new Response(body, {
|
|
581
|
+
status,
|
|
582
|
+
statusText: safeStatusText(statusMessage),
|
|
583
|
+
headers
|
|
584
|
+
});
|
|
585
|
+
Object.defineProperty(response, "url", { value: ctx.url.href });
|
|
586
|
+
Object.defineProperty(response, "redirected", { value: ctx.redirected });
|
|
587
|
+
return response;
|
|
588
|
+
}
|
|
589
|
+
// src/safe-fetch.ts
|
|
590
|
+
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([
|
|
591
|
+
301,
|
|
592
|
+
302,
|
|
593
|
+
303,
|
|
594
|
+
307,
|
|
595
|
+
308
|
|
596
|
+
]);
|
|
597
|
+
/** Request headers that are never forwarded. Host is always set to the validated target. */
|
|
598
|
+
const DROPPED_REQUEST_HEADERS = /* @__PURE__ */ new Set([
|
|
599
|
+
"host",
|
|
600
|
+
"content-length",
|
|
601
|
+
"transfer-encoding",
|
|
602
|
+
"connection"
|
|
603
|
+
]);
|
|
604
|
+
/** Credentials are not sent to a different origin after a redirect, matching fetch. */
|
|
605
|
+
const CROSS_ORIGIN_HEADERS = [
|
|
606
|
+
"authorization",
|
|
607
|
+
"cookie",
|
|
608
|
+
"proxy-authorization"
|
|
609
|
+
];
|
|
610
|
+
/**
|
|
611
|
+
* Creates a fetch-compatible function that validates every URL, resolves each
|
|
612
|
+
* hostname once, connects to the validated IP only, and validates every redirect.
|
|
613
|
+
*
|
|
614
|
+
* Throws SafeFetchError for policy violations. Throws TypeError for invalid options
|
|
615
|
+
* or unsupported body types.
|
|
616
|
+
*/
|
|
617
|
+
function createSafeFetch(options = {}) {
|
|
618
|
+
const config = resolveOptions(options);
|
|
619
|
+
return (input, init) => fetchSafely(config, input, init);
|
|
620
|
+
}
|
|
621
|
+
async function toBytes(body) {
|
|
622
|
+
if (typeof body === "string") return new TextEncoder().encode(body);
|
|
623
|
+
if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0));
|
|
624
|
+
if (ArrayBuffer.isView(body)) return new Uint8Array(body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength));
|
|
625
|
+
if (body instanceof URLSearchParams) return new TextEncoder().encode(body.toString());
|
|
626
|
+
if (body instanceof Blob) return new Uint8Array(await body.arrayBuffer());
|
|
627
|
+
throw new TypeError("Unsupported request body; use a string, ArrayBuffer, ArrayBufferView, URLSearchParams or Blob");
|
|
628
|
+
}
|
|
629
|
+
async function prepareRequest(input, init) {
|
|
630
|
+
const url = parseUrl(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
|
631
|
+
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
|
|
632
|
+
const merged = new Headers(input instanceof Request ? input.headers : void 0);
|
|
633
|
+
if (init?.headers !== void 0) new Headers(init.headers).forEach((value, key) => merged.set(key, value));
|
|
634
|
+
const headers = {};
|
|
635
|
+
merged.forEach((value, key) => {
|
|
636
|
+
if (!DROPPED_REQUEST_HEADERS.has(key)) headers[key] = value;
|
|
637
|
+
});
|
|
638
|
+
headers["accept-encoding"] ??= "identity";
|
|
639
|
+
let body = init?.body === void 0 || init.body === null ? void 0 : await toBytes(init.body);
|
|
640
|
+
if (body === void 0 && input instanceof Request && method !== "GET" && method !== "HEAD") body = new Uint8Array(await input.arrayBuffer());
|
|
641
|
+
if (body !== void 0 && (method === "GET" || method === "HEAD")) throw new TypeError("Request with GET or HEAD method cannot have a body");
|
|
642
|
+
return {
|
|
643
|
+
url,
|
|
644
|
+
method,
|
|
645
|
+
headers,
|
|
646
|
+
body
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
async function fetchSafely(config, input, init) {
|
|
650
|
+
const request = await prepareRequest(input, init);
|
|
651
|
+
const callerSignal = init?.signal ?? void 0;
|
|
652
|
+
const controller = new AbortController();
|
|
653
|
+
let timedOut = false;
|
|
654
|
+
let settled = false;
|
|
655
|
+
const onCallerAbort = () => controller.abort();
|
|
656
|
+
const timer = setTimeout(() => {
|
|
657
|
+
timedOut = true;
|
|
658
|
+
controller.abort();
|
|
659
|
+
}, config.timeoutMs);
|
|
660
|
+
callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
661
|
+
if (callerSignal?.aborted === true) controller.abort();
|
|
662
|
+
const settle = () => {
|
|
663
|
+
if (settled) return;
|
|
664
|
+
settled = true;
|
|
665
|
+
clearTimeout(timer);
|
|
666
|
+
callerSignal?.removeEventListener("abort", onCallerAbort);
|
|
667
|
+
};
|
|
668
|
+
const toError = (error) => {
|
|
669
|
+
if (error instanceof SafeFetchError) return error;
|
|
670
|
+
if (callerSignal?.aborted === true) return callerSignal.reason;
|
|
671
|
+
if (timedOut) return new SafeFetchError("TIMEOUT", `Request timed out after ${config.timeoutMs} ms`);
|
|
672
|
+
return new SafeFetchError("NETWORK_ERROR", "Network request failed");
|
|
673
|
+
};
|
|
674
|
+
try {
|
|
675
|
+
let url = request.url;
|
|
676
|
+
let method = request.method;
|
|
677
|
+
let headers = request.headers;
|
|
678
|
+
let body = request.body;
|
|
679
|
+
let redirects = 0;
|
|
680
|
+
for (;;) {
|
|
681
|
+
const incoming = await sendRequest(await resolveTarget(url, config, controller.signal), {
|
|
682
|
+
method,
|
|
683
|
+
headers,
|
|
684
|
+
body
|
|
685
|
+
}, controller.signal);
|
|
686
|
+
const status = incoming.statusCode ?? 0;
|
|
687
|
+
if (!REDIRECT_STATUSES.has(status)) return toResponse(incoming, {
|
|
688
|
+
url,
|
|
689
|
+
redirected: redirects > 0,
|
|
690
|
+
method,
|
|
691
|
+
maxBytes: config.maxBytes,
|
|
692
|
+
onSettled: settle,
|
|
693
|
+
toError
|
|
694
|
+
});
|
|
695
|
+
const location = incoming.headers.location;
|
|
696
|
+
incoming.destroy();
|
|
697
|
+
if (location === void 0) throw new SafeFetchError("REDIRECT_WITHOUT_LOCATION", "Redirect response has no Location header");
|
|
698
|
+
if (redirects >= config.maxRedirects) throw new SafeFetchError("REDIRECT_LIMIT", `Redirect limit of ${config.maxRedirects} exceeded`);
|
|
699
|
+
redirects++;
|
|
700
|
+
const next = parseUrl(location, url);
|
|
701
|
+
const nextHeaders = { ...headers };
|
|
702
|
+
if (status === 303 && method !== "HEAD" || (status === 301 || status === 302) && method === "POST") {
|
|
703
|
+
method = "GET";
|
|
704
|
+
body = void 0;
|
|
705
|
+
delete nextHeaders["content-type"];
|
|
706
|
+
}
|
|
707
|
+
if (next.origin !== url.origin) for (const name of CROSS_ORIGIN_HEADERS) delete nextHeaders[name];
|
|
708
|
+
headers = nextHeaders;
|
|
709
|
+
url = next;
|
|
710
|
+
}
|
|
711
|
+
} catch (error) {
|
|
712
|
+
settle();
|
|
713
|
+
throw toError(error);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
exports.SafeFetchError = SafeFetchError;
|
|
717
|
+
exports.createSafeFetch = createSafeFetch;
|
|
718
|
+
exports.readBodyWithLimit = readBodyWithLimit;
|
|
719
|
+
|
|
720
|
+
//# sourceMappingURL=index.cjs.map
|