@fedify/relay 2.2.18 → 2.2.20
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/chunk-D_gRT_KG.js +29 -0
- package/dist/litepub.test.js +1 -1
- package/dist/mastodon.test.js +1 -1
- package/dist/mod.d.cts +3 -1
- package/dist/mod.d.ts +3 -1
- package/dist/{types-DPCkOTiL.js → types-BBeli5Fc.js} +811 -268
- package/dist/undici-CjF8_EAQ.js +22748 -0
- package/package.json +4 -4
|
@@ -1,34 +1,736 @@
|
|
|
1
1
|
import "@js-temporal/polyfill";
|
|
2
2
|
import "urlpattern-polyfill";
|
|
3
3
|
globalThis.addEventListener = () => {};
|
|
4
|
-
import {
|
|
4
|
+
import { n as __require, r as __toESM, t as __commonJSMin } from "./chunk-D_gRT_KG.js";
|
|
5
5
|
import { getLogger } from "@logtape/logtape";
|
|
6
|
-
import process$1 from "node:process";
|
|
7
6
|
import dns from "node:dns/promises";
|
|
8
7
|
import { isIP } from "node:net";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
8
|
+
import process$1 from "node:process";
|
|
9
|
+
//#region ../vocab-runtime/dist/public-fetch-Dfmc_Kfy.js
|
|
10
|
+
const grants = /* @__PURE__ */ new WeakMap();
|
|
11
|
+
/**
|
|
12
|
+
* Issues a grant for the given URL's origin and addresses. This does not
|
|
13
|
+
* validate the addresses; callers validate them before issuing, and pinners
|
|
14
|
+
* validate them again right before connecting.
|
|
15
|
+
* @param url The URL whose origin the grant is bound to.
|
|
16
|
+
* @param addresses The addresses to connect to.
|
|
17
|
+
* @returns A new grant.
|
|
18
|
+
*/
|
|
19
|
+
function issuePinGrant(url, addresses) {
|
|
20
|
+
const grant = Object.freeze(Object.create(null));
|
|
21
|
+
let hostname = url.hostname;
|
|
22
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) hostname = hostname.slice(1, -1);
|
|
23
|
+
const record = Object.freeze({
|
|
24
|
+
origin: url.origin,
|
|
25
|
+
hostname,
|
|
26
|
+
addresses: Object.freeze(addresses.map(({ address, family }) => Object.freeze({
|
|
27
|
+
address,
|
|
28
|
+
family
|
|
29
|
+
})))
|
|
30
|
+
});
|
|
31
|
+
grants.set(grant, {
|
|
32
|
+
record,
|
|
33
|
+
revoked: false
|
|
34
|
+
});
|
|
35
|
+
return grant;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Checks that the given grant was issued by {@link issuePinGrant}, has not
|
|
39
|
+
* been revoked, and was issued for the origin of the given URL.
|
|
40
|
+
* @param grant The grant to redeem.
|
|
41
|
+
* @param url The URL to connect to.
|
|
42
|
+
* @returns What the grant stands for.
|
|
43
|
+
* @throws {TypeError} If the grant cannot be used for the URL.
|
|
44
|
+
*/
|
|
45
|
+
function redeemPinGrant(grant, url) {
|
|
46
|
+
const state = typeof grant === "object" && grant !== null ? grants.get(grant) : void 0;
|
|
47
|
+
if (state == null) throw new TypeError("Invalid pin grant.");
|
|
48
|
+
if (state.revoked) throw new TypeError("The pin grant has been revoked.");
|
|
49
|
+
if (url.origin !== state.record.origin) throw new TypeError(`The pin grant for ${state.record.origin} cannot be used for ${url.origin}.`);
|
|
50
|
+
return state.record;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Revokes the given grant so that it can no longer be redeemed.
|
|
54
|
+
* @param grant The grant to revoke.
|
|
55
|
+
*/
|
|
56
|
+
function revokePinGrant(grant) {
|
|
57
|
+
const state = grants.get(grant);
|
|
58
|
+
if (state != null) state.revoked = true;
|
|
59
|
+
}
|
|
60
|
+
var UrlError = class extends Error {
|
|
61
|
+
/**
|
|
62
|
+
* The failure category. Defaults to `"disallowed"`.
|
|
63
|
+
* `"dns"` includes lookup errors and results with no usable IP addresses.
|
|
64
|
+
* @since 2.0.29
|
|
65
|
+
*/
|
|
66
|
+
reason;
|
|
67
|
+
constructor(message, options) {
|
|
68
|
+
super(message, options);
|
|
69
|
+
this.name = "UrlError";
|
|
70
|
+
this.reason = options?.reason ?? "disallowed";
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Resolves all addresses of the given hostname through `node:dns/promises`.
|
|
75
|
+
* The default `dns` object is looked up on every call so that tests can still
|
|
76
|
+
* stub `dns.lookup()`.
|
|
77
|
+
* @internal
|
|
78
|
+
*/
|
|
79
|
+
function lookupAllAddresses(hostname) {
|
|
80
|
+
return dns.lookup(hostname, { all: true });
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Checks the parts of a URL that can be validated without resolving it:
|
|
84
|
+
* the protocol, the `localhost` hostname, and IP literal hostnames.
|
|
85
|
+
* @throws {UrlError} If the URL is not allowed.
|
|
86
|
+
* @internal
|
|
87
|
+
*/
|
|
88
|
+
function parsePublicUrl(url) {
|
|
89
|
+
const parsed = new URL(url);
|
|
90
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new UrlError(`Unsupported protocol: ${parsed.protocol}`);
|
|
91
|
+
let hostname = parsed.hostname;
|
|
92
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) hostname = hostname.slice(1, -1);
|
|
93
|
+
if (hostname === "localhost") throw new UrlError("Localhost is not allowed");
|
|
94
|
+
const family = isIP(hostname);
|
|
95
|
+
if (family !== 0) validatePublicIpAddress(hostname, family);
|
|
96
|
+
return {
|
|
97
|
+
url: parsed,
|
|
98
|
+
hostname,
|
|
99
|
+
family
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Resolves the given hostname and checks that every returned IP address is
|
|
104
|
+
* public.
|
|
105
|
+
* @returns The IP addresses of the hostname, without non-IP entries.
|
|
106
|
+
* @throws {UrlError} If the lookup fails, returns no IP address, or returns
|
|
107
|
+
* any non-public address.
|
|
108
|
+
* @internal
|
|
109
|
+
*/
|
|
110
|
+
async function lookupPublicAddresses(hostname, lookup) {
|
|
111
|
+
let addresses;
|
|
112
|
+
try {
|
|
113
|
+
addresses = await lookup(hostname);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new UrlError("DNS lookup failed", {
|
|
116
|
+
cause: error,
|
|
117
|
+
reason: "dns"
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
validateLookupAddresses(addresses);
|
|
121
|
+
return addresses.filter(({ address }) => isIP(address) !== 0).map(({ address }) => ({
|
|
122
|
+
address,
|
|
123
|
+
family: isIP(address)
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Checks that the given IP address is public. This is used right before
|
|
128
|
+
* connecting to an address that passed {@link lookupPublicAddresses}.
|
|
129
|
+
* @throws {UrlError} If the address is not a public IP address.
|
|
130
|
+
* @internal
|
|
131
|
+
*/
|
|
132
|
+
function assertPublicAddress(address) {
|
|
133
|
+
const family = isIP(address.address);
|
|
134
|
+
if (family === 0 || family !== address.family) throw new UrlError(`Invalid or private address: ${address.address}`);
|
|
135
|
+
validatePublicIpAddress(address.address, family);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Validates the IP addresses returned by `node:dns.lookup()`.
|
|
139
|
+
*
|
|
140
|
+
* Cloudflare Workers' `node:dns` implementation currently maps every record
|
|
141
|
+
* in a DNS-over-HTTPS `Answer` array—including CNAME records—to a
|
|
142
|
+
* `LookupAddress`, even though Node.js specifies that the `address` field must
|
|
143
|
+
* contain an IPv4 or IPv6 literal. See:
|
|
144
|
+
* https://github.com/cloudflare/workerd/issues/6886
|
|
145
|
+
*
|
|
146
|
+
* Work around that bug by ignoring non-IP entries only when the lookup also
|
|
147
|
+
* returns at least one actual IP address. This remains fail-closed: a result
|
|
148
|
+
* containing no IP addresses is rejected, and every returned IP address is
|
|
149
|
+
* still validated and must be public. This workaround can be revisited once
|
|
150
|
+
* the Workerd issue is fixed in supported Cloudflare Workers runtimes.
|
|
151
|
+
*
|
|
152
|
+
* @internal
|
|
153
|
+
*/
|
|
154
|
+
function validateLookupAddresses(addresses) {
|
|
155
|
+
let ipAddressCount = 0;
|
|
156
|
+
for (const { address } of addresses) {
|
|
157
|
+
const family = isIP(address);
|
|
158
|
+
if (family === 0) continue;
|
|
159
|
+
ipAddressCount++;
|
|
160
|
+
validatePublicIpAddress(address, family);
|
|
161
|
+
}
|
|
162
|
+
if (ipAddressCount === 0) throw new UrlError("DNS lookup did not return any IP address", { reason: "dns" });
|
|
163
|
+
}
|
|
164
|
+
function validatePublicIpAddress(address, family) {
|
|
165
|
+
if (family === 4 && isValidPublicIPv4Address(address) || family === 6 && isValidPublicIPv6Address(address)) return;
|
|
166
|
+
throw new UrlError(`Invalid or private address: ${address}`);
|
|
167
|
+
}
|
|
168
|
+
function isValidPublicIPv4Address(address) {
|
|
169
|
+
const parts = parseIPv4Address(address);
|
|
170
|
+
if (parts == null) return false;
|
|
171
|
+
const value = ipv4PartsToNumber(parts);
|
|
172
|
+
return !nonPublicIPv4Prefixes.some(({ base, prefix }) => matchesIPv4Prefix(value, base, prefix));
|
|
173
|
+
}
|
|
174
|
+
function isValidPublicIPv6Address(address) {
|
|
175
|
+
const words = parseIPv6Address(address);
|
|
176
|
+
if (words == null) return false;
|
|
177
|
+
if (nonPublicIPv6Prefixes.some(({ words: prefixWords, prefix }) => matchesIPv6Prefix(words, prefixWords, prefix))) return false;
|
|
178
|
+
for (const { extractIPv4, prefix, words: prefixWords } of ipv6WithIPv4Prefixes) {
|
|
179
|
+
if (!matchesIPv6Prefix(words, prefixWords, prefix)) continue;
|
|
180
|
+
const ipv4Address = extractIPv4(words);
|
|
181
|
+
if (ipv4Address != null && !isValidPublicIPv4Address(ipv4Address)) return false;
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
function expandIPv6Address(address) {
|
|
186
|
+
address = address.toLowerCase();
|
|
187
|
+
const ipv4Delimiter = address.lastIndexOf(":");
|
|
188
|
+
if (address.includes(".") && ipv4Delimiter >= 0) {
|
|
189
|
+
const ipv4Parts = parseIPv4Address(address.substring(ipv4Delimiter + 1));
|
|
190
|
+
if (ipv4Parts == null) return address;
|
|
191
|
+
const high = (ipv4Parts[0] << 8) + ipv4Parts[1];
|
|
192
|
+
const low = (ipv4Parts[2] << 8) + ipv4Parts[3];
|
|
193
|
+
address = address.substring(0, ipv4Delimiter + 1) + high.toString(16) + ":" + low.toString(16);
|
|
194
|
+
}
|
|
195
|
+
if (address === "::") return "0000:0000:0000:0000:0000:0000:0000:0000";
|
|
196
|
+
if (address.startsWith("::")) address = "0000" + address;
|
|
197
|
+
if (address.endsWith("::")) address = address + "0000";
|
|
198
|
+
address = address.replace("::", ":0000".repeat(8 - (address.match(/:/g) || []).length) + ":");
|
|
199
|
+
return address.split(":").map((part) => part.padStart(4, "0")).join(":");
|
|
200
|
+
}
|
|
201
|
+
const nonPublicIPv4Prefixes = [
|
|
202
|
+
ipv4Prefix("0.0.0.0/8", "RFC 6890"),
|
|
203
|
+
ipv4Prefix("10.0.0.0/8", "RFC 1918"),
|
|
204
|
+
ipv4Prefix("100.64.0.0/10", "RFC 6598"),
|
|
205
|
+
ipv4Prefix("127.0.0.0/8", "RFC 1122"),
|
|
206
|
+
ipv4Prefix("169.254.0.0/16", "RFC 3927"),
|
|
207
|
+
ipv4Prefix("172.16.0.0/12", "RFC 1918"),
|
|
208
|
+
ipv4Prefix("192.0.0.0/24", "RFC 6890"),
|
|
209
|
+
ipv4Prefix("192.0.2.0/24", "RFC 5737"),
|
|
210
|
+
ipv4Prefix("192.88.99.0/24", "RFC 7526"),
|
|
211
|
+
ipv4Prefix("192.168.0.0/16", "RFC 1918"),
|
|
212
|
+
ipv4Prefix("198.18.0.0/15", "RFC 2544"),
|
|
213
|
+
ipv4Prefix("198.51.100.0/24", "RFC 5737"),
|
|
214
|
+
ipv4Prefix("203.0.113.0/24", "RFC 5737"),
|
|
215
|
+
ipv4Prefix("224.0.0.0/4", "RFC 5771"),
|
|
216
|
+
ipv4Prefix("240.0.0.0/4", "RFC 1112")
|
|
217
|
+
];
|
|
218
|
+
const nonPublicIPv6Prefixes = [
|
|
219
|
+
ipv6Prefix("::/16", "RFC 4291"),
|
|
220
|
+
ipv6Prefix("2001::/32", "RFC 4380"),
|
|
221
|
+
ipv6Prefix("2002::/16", "RFC 3056"),
|
|
222
|
+
ipv6Prefix("64:ff9b:1::/48", "RFC 8215"),
|
|
223
|
+
ipv6Prefix("fc00::/7", "RFC 4193"),
|
|
224
|
+
ipv6Prefix("fe80::/10", "RFC 4291"),
|
|
225
|
+
ipv6Prefix("ff00::/8", "RFC 4291")
|
|
226
|
+
];
|
|
227
|
+
const ipv6WithIPv4Prefixes = [{
|
|
228
|
+
...ipv6Prefix("64:ff9b::/96", "RFC 6052"),
|
|
229
|
+
extractIPv4: (words) => ipv4FromWords(words[6], words[7])
|
|
230
|
+
}];
|
|
231
|
+
function ipv4Prefix(cidr, rfc) {
|
|
232
|
+
const [address, prefixText] = cidr.split("/");
|
|
233
|
+
const prefix = parseInt(prefixText, 10);
|
|
234
|
+
const parts = parseIPv4Address(address);
|
|
235
|
+
if (parts == null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) throw new Error(`Invalid IPv4 prefix: ${cidr}`);
|
|
236
|
+
return {
|
|
237
|
+
cidr,
|
|
238
|
+
base: ipv4PartsToNumber(parts),
|
|
239
|
+
prefix,
|
|
240
|
+
rfc
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function ipv6Prefix(cidr, rfc) {
|
|
244
|
+
const [address, prefixText] = cidr.split("/");
|
|
245
|
+
const prefix = parseInt(prefixText, 10);
|
|
246
|
+
const words = parseIPv6Address(address);
|
|
247
|
+
if (words == null || !Number.isInteger(prefix) || prefix < 0 || prefix > 128) throw new Error(`Invalid IPv6 prefix: ${cidr}`);
|
|
248
|
+
return {
|
|
249
|
+
cidr,
|
|
250
|
+
words,
|
|
251
|
+
prefix,
|
|
252
|
+
rfc
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function parseIPv4Address(address) {
|
|
256
|
+
const parts = address.split(".").map((part) => {
|
|
257
|
+
if (!/^\d+$/.test(part)) return NaN;
|
|
258
|
+
return parseInt(part, 10);
|
|
259
|
+
});
|
|
260
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
|
|
261
|
+
return parts;
|
|
262
|
+
}
|
|
263
|
+
function parseIPv6Address(address) {
|
|
264
|
+
const parts = expandIPv6Address(address).split(":");
|
|
265
|
+
if (parts.length !== 8) return null;
|
|
266
|
+
const words = parts.map((part) => {
|
|
267
|
+
if (!/^[0-9a-f]{1,4}$/i.test(part)) return NaN;
|
|
268
|
+
return parseInt(part, 16);
|
|
269
|
+
});
|
|
270
|
+
if (words.some((word) => !Number.isInteger(word) || word < 0 || word > 65535)) return null;
|
|
271
|
+
return words;
|
|
272
|
+
}
|
|
273
|
+
function ipv4PartsToNumber(parts) {
|
|
274
|
+
return parts[0] * 2 ** 24 + parts[1] * 2 ** 16 + parts[2] * 2 ** 8 + parts[3];
|
|
275
|
+
}
|
|
276
|
+
function ipv4FromWords(highWord, lowWord) {
|
|
277
|
+
return [
|
|
278
|
+
highWord >> 8,
|
|
279
|
+
highWord & 255,
|
|
280
|
+
lowWord >> 8,
|
|
281
|
+
lowWord & 255
|
|
282
|
+
].join(".");
|
|
283
|
+
}
|
|
284
|
+
function matchesIPv4Prefix(address, prefixBase, prefixLength) {
|
|
285
|
+
const blockSize = 2 ** (32 - prefixLength);
|
|
286
|
+
return Math.floor(address / blockSize) === Math.floor(prefixBase / blockSize);
|
|
287
|
+
}
|
|
288
|
+
function matchesIPv6Prefix(address, prefixWords, prefixLength) {
|
|
289
|
+
let remaining = prefixLength;
|
|
290
|
+
for (let i = 0; i < 8 && remaining > 0; i++) if (remaining >= 16) {
|
|
291
|
+
if (address[i] !== prefixWords[i]) return false;
|
|
292
|
+
remaining -= 16;
|
|
293
|
+
} else {
|
|
294
|
+
const mask = 65535 << 16 - remaining & 65535;
|
|
295
|
+
if ((address[i] & mask) !== (prefixWords[i] & mask)) return false;
|
|
296
|
+
remaining = 0;
|
|
297
|
+
}
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
const logger$1 = getLogger([
|
|
301
|
+
"fedify",
|
|
302
|
+
"runtime",
|
|
303
|
+
"fetch"
|
|
304
|
+
]);
|
|
305
|
+
/**
|
|
306
|
+
* The first Bun version that honors `proxy: false` when proxy environment
|
|
307
|
+
* variables are set at startup. Earlier versions send pinned requests
|
|
308
|
+
* through the proxy, with the validated IP address as the target.
|
|
309
|
+
*/
|
|
310
|
+
const PROXY_FALSE_MIN_VERSION = "1.4.3";
|
|
311
|
+
const PROXY_VARIABLES = [
|
|
312
|
+
"HTTP_PROXY",
|
|
313
|
+
"http_proxy",
|
|
314
|
+
"HTTPS_PROXY",
|
|
315
|
+
"https_proxy",
|
|
316
|
+
"ALL_PROXY",
|
|
317
|
+
"all_proxy"
|
|
318
|
+
];
|
|
319
|
+
const FALLBACK_ERROR_CODES = new Set([
|
|
320
|
+
"ConnectionRefused",
|
|
321
|
+
"ECONNREFUSED",
|
|
322
|
+
"EHOSTUNREACH",
|
|
323
|
+
"ENETUNREACH",
|
|
324
|
+
"ETIMEDOUT"
|
|
325
|
+
]);
|
|
326
|
+
/**
|
|
327
|
+
* Bun versions before 1.3.11 verify the server certificate against the `Host`
|
|
328
|
+
* header, including a non-default port, instead of `tls.serverName`, so
|
|
329
|
+
* pinned requests to HTTPS servers on non-default ports always fail the check
|
|
330
|
+
* there. A custom `checkServerIdentity` cannot stand in for it: these
|
|
331
|
+
* versions send the whole request before a failing custom check aborts it
|
|
332
|
+
* (and 1.3.10 even reuses the connection for the next request), so such
|
|
333
|
+
* requests are refused instead.
|
|
334
|
+
*/
|
|
335
|
+
const HOST_HEADER_IDENTITY_RANGE = "<1.3.11";
|
|
336
|
+
/**
|
|
337
|
+
* Creates a pinner for Bun. Every request is sent to an IP literal URL with
|
|
338
|
+
* the original `Host` header, `tls.serverName` set to the original hostname
|
|
339
|
+
* (so that the certificate is still verified against it), and `proxy: false`.
|
|
340
|
+
*
|
|
341
|
+
* GET and HEAD requests fall back to the next address on transport errors
|
|
342
|
+
* raised before a response arrives. Other methods use only the first
|
|
343
|
+
* address, since a transport error does not guarantee that the request did
|
|
344
|
+
* not reach the server, and retrying it could deliver an activity twice.
|
|
345
|
+
* Together with the transport retries of `doubleKnock()`, a GET request is
|
|
346
|
+
* attempted at most twice per address. Note that Bun 1.4 also reports
|
|
347
|
+
* a connection reset after the request was sent as a `TypeError`, so GET and
|
|
348
|
+
* HEAD requests may be repeated against the next address in that case, which
|
|
349
|
+
* is acceptable for these idempotent methods.
|
|
350
|
+
*
|
|
351
|
+
* Bun versions before 1.3.11 verify certificates against the `Host` header,
|
|
352
|
+
* which includes a non-default port, and cannot safely check them against
|
|
353
|
+
* the original hostname in any other way, so requests to HTTPS servers on
|
|
354
|
+
* non-default ports fail there with a `TypeError` before connecting.
|
|
355
|
+
*
|
|
356
|
+
* Bun versions before 1.4.3 ignore `proxy: false` when proxy environment
|
|
357
|
+
* variables are set, so requests go through the proxy with the validated IP
|
|
358
|
+
* address as the target. In that case a warning is logged once.
|
|
359
|
+
* @param options The pinner options.
|
|
360
|
+
* @returns The pinner.
|
|
361
|
+
*/
|
|
362
|
+
function createBunPinner({ verifyAddress }) {
|
|
363
|
+
let proxyWarningPending = ignoresProxyFalse() && hasProxyVariables();
|
|
364
|
+
return {
|
|
365
|
+
supports(url) {
|
|
366
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
367
|
+
},
|
|
368
|
+
async fetch(grant, request, signal) {
|
|
369
|
+
const url = new URL(request.url);
|
|
370
|
+
const { hostname, addresses } = redeemPinGrant(grant, url);
|
|
371
|
+
if (proxyWarningPending) {
|
|
372
|
+
proxyWarningPending = false;
|
|
373
|
+
logger$1.warn("Bun {version} ignores proxy: false while proxy environment variables are set, so outbound requests go through the proxy instead of connecting directly to the validated addresses. Upgrade to Bun {minVersion} or later, or unset the proxy environment variables; see {url}.", {
|
|
374
|
+
version: getBun()?.version,
|
|
375
|
+
minVersion: PROXY_FALSE_MIN_VERSION,
|
|
376
|
+
url: "https://fedify.dev/manual/deploy#bun-behind-an-http-proxy"
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
const fallback = request.method === "GET" || request.method === "HEAD";
|
|
380
|
+
const candidates = fallback ? addresses : addresses.slice(0, 1);
|
|
381
|
+
const body = request.body == null ? null : await request.arrayBuffer();
|
|
382
|
+
let lastError = /* @__PURE__ */ new TypeError(`No address to connect to for ${url.host}.`);
|
|
383
|
+
for (const address of candidates) {
|
|
384
|
+
throwIfAborted(signal, request.signal);
|
|
385
|
+
verifyAddress(address);
|
|
386
|
+
const headers = new Headers(request.headers);
|
|
387
|
+
headers.set("Host", url.host);
|
|
388
|
+
const init = {
|
|
389
|
+
method: request.method,
|
|
390
|
+
headers,
|
|
391
|
+
body,
|
|
392
|
+
redirect: "manual",
|
|
393
|
+
signal: signal == null || signal === request.signal ? request.signal : anySignal(signal, request.signal),
|
|
394
|
+
proxy: false
|
|
395
|
+
};
|
|
396
|
+
if (url.protocol === "https:") init.tls = getTlsOptions(url, hostname);
|
|
397
|
+
try {
|
|
398
|
+
return await fetch(toAddressUrl(url, address), init);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
throwIfAborted(signal, request.signal);
|
|
401
|
+
if (!fallback || !isFallbackError(error)) throw error;
|
|
402
|
+
lastError = error;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
throw lastError;
|
|
406
|
+
},
|
|
407
|
+
release() {}
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function getTlsOptions(url, hostname) {
|
|
411
|
+
const bun = getBun();
|
|
412
|
+
if (url.port !== "" && bun != null && bun.semver.satisfies(bun.version, HOST_HEADER_IDENTITY_RANGE)) throw new TypeError(`Bun ${bun.version} cannot verify the certificate of ${url.host} when connecting to a validated address; upgrade to Bun 1.3.11 or later to fetch from HTTPS servers on non-default ports.`);
|
|
413
|
+
return isIP(hostname) === 0 ? { serverName: hostname } : {};
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Combines two signals without `AbortSignal.any()`, which Bun 1.1.0 lacks.
|
|
417
|
+
*/
|
|
418
|
+
function anySignal(a, b) {
|
|
419
|
+
if (a.aborted) return a;
|
|
420
|
+
if (b.aborted) return b;
|
|
421
|
+
const controller = new AbortController();
|
|
422
|
+
const abort = (event) => {
|
|
423
|
+
controller.abort(event.target.reason);
|
|
424
|
+
a.removeEventListener("abort", abort);
|
|
425
|
+
b.removeEventListener("abort", abort);
|
|
426
|
+
};
|
|
427
|
+
a.addEventListener("abort", abort);
|
|
428
|
+
b.addEventListener("abort", abort);
|
|
429
|
+
return controller.signal;
|
|
430
|
+
}
|
|
431
|
+
function toAddressUrl(url, address) {
|
|
432
|
+
const result = new URL(url);
|
|
433
|
+
result.hostname = address.family === 6 ? `[${address.address}]` : address.address;
|
|
434
|
+
return result;
|
|
435
|
+
}
|
|
436
|
+
function isFallbackError(error) {
|
|
437
|
+
if (error instanceof Error && error.name === "AbortError") return false;
|
|
438
|
+
if (error instanceof TypeError) return true;
|
|
439
|
+
const code = error?.code;
|
|
440
|
+
return typeof code === "string" && FALLBACK_ERROR_CODES.has(code);
|
|
441
|
+
}
|
|
442
|
+
function throwIfAborted(...signals) {
|
|
443
|
+
for (const signal of signals) signal?.throwIfAborted();
|
|
444
|
+
}
|
|
445
|
+
function getBun() {
|
|
446
|
+
return globalThis.Bun;
|
|
447
|
+
}
|
|
448
|
+
function ignoresProxyFalse() {
|
|
449
|
+
const bun = getBun();
|
|
450
|
+
if (bun == null) return false;
|
|
451
|
+
return !bun.semver.satisfies(bun.version, `>=${PROXY_FALSE_MIN_VERSION}`);
|
|
452
|
+
}
|
|
453
|
+
function hasProxyVariables() {
|
|
454
|
+
const env = globalThis.process?.env;
|
|
455
|
+
if (env == null) return false;
|
|
456
|
+
return PROXY_VARIABLES.some((name) => (env[name] ?? "") !== "");
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Creates a pinner for Node.js. Every request is sent through the global
|
|
460
|
+
* `fetch()` with an undici `Agent` as the `dispatcher`. The agent's
|
|
461
|
+
* `connect.lookup` answers only with the addresses of live grants for
|
|
462
|
+
* the hostname, checking each of them again right before connecting, and
|
|
463
|
+
* fails when there are none, so a connection can never be made to an address
|
|
464
|
+
* that was not validated.
|
|
465
|
+
*
|
|
466
|
+
* The agent is shared by the requests of all grants, so keep-alive
|
|
467
|
+
* connections are reused. A pooled connection was made to an address of
|
|
468
|
+
* a grant that was valid when it was connected, so reusing it from another
|
|
469
|
+
* session of the same origin still reaches a validated address. Response
|
|
470
|
+
* bodies have to be consumed or cancelled for their connections to return to
|
|
471
|
+
* the pool, which sessions do when they are closed.
|
|
472
|
+
*
|
|
473
|
+
* Since the dispatcher is passed explicitly, neither proxy environment
|
|
474
|
+
* variables nor the global dispatcher (e.g., `NODE_USE_ENV_PROXY=1`) apply.
|
|
475
|
+
* @param options The pinner options.
|
|
476
|
+
* @returns The pinner.
|
|
477
|
+
*/
|
|
478
|
+
function createNodePinner({ verifyAddress, undici }) {
|
|
479
|
+
const grants = /* @__PURE__ */ new Map();
|
|
480
|
+
function lookup(hostname, options, callback) {
|
|
481
|
+
let addresses;
|
|
482
|
+
try {
|
|
483
|
+
addresses = getGrantedAddresses(hostname, options.family);
|
|
484
|
+
} catch (error) {
|
|
485
|
+
callback(error, []);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (options.all) callback(null, addresses);
|
|
489
|
+
else callback(null, addresses[0].address, addresses[0].family);
|
|
490
|
+
}
|
|
491
|
+
function getGrantedAddresses(hostname, family) {
|
|
492
|
+
const addresses = /* @__PURE__ */ new Map();
|
|
493
|
+
for (const [grant, record] of grants.get(hostname) ?? []) {
|
|
494
|
+
try {
|
|
495
|
+
redeemPinGrant(grant, new URL(record.origin));
|
|
496
|
+
} catch {
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
for (const address of record.addresses) addresses.set(address.address, address);
|
|
500
|
+
}
|
|
501
|
+
const familyNumber = family === "IPv4" ? 4 : family === "IPv6" ? 6 : typeof family === "number" ? family : 0;
|
|
502
|
+
const result = [...addresses.values()].filter((address) => familyNumber === 0 || address.family === familyNumber);
|
|
503
|
+
if (result.length < 1) throw new UrlError(`No validated address to connect to: ${hostname}`);
|
|
504
|
+
for (const address of result) verifyAddress(address);
|
|
505
|
+
return result;
|
|
506
|
+
}
|
|
507
|
+
const agent = new undici.Agent({ connect: { lookup } });
|
|
508
|
+
return {
|
|
509
|
+
supports(url) {
|
|
510
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
511
|
+
},
|
|
512
|
+
async fetch(grant, request, signal) {
|
|
513
|
+
const record = redeemPinGrant(grant, new URL(request.url));
|
|
514
|
+
if (isIP(record.hostname) !== 0) for (const address of record.addresses) verifyAddress(address);
|
|
515
|
+
let registered = grants.get(record.hostname);
|
|
516
|
+
if (registered == null) {
|
|
517
|
+
registered = /* @__PURE__ */ new Map();
|
|
518
|
+
grants.set(record.hostname, registered);
|
|
519
|
+
}
|
|
520
|
+
registered.set(grant, record);
|
|
521
|
+
return await fetch(request, {
|
|
522
|
+
redirect: "manual",
|
|
523
|
+
signal: signal == null || signal === request.signal ? request.signal : AbortSignal.any([signal, request.signal]),
|
|
524
|
+
dispatcher: agent
|
|
525
|
+
});
|
|
526
|
+
},
|
|
527
|
+
release(grant) {
|
|
528
|
+
for (const [hostname, registered] of grants) {
|
|
529
|
+
if (!registered.delete(grant)) continue;
|
|
530
|
+
if (registered.size < 1) grants.delete(hostname);
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Detects the current runtime. Cloudflare Workers is checked first, since
|
|
538
|
+
* `process` can exist there with `nodejs_compat`, and Deno and Bun before
|
|
539
|
+
* Node.js, since both provide `process.versions.node`.
|
|
540
|
+
* @returns The current runtime.
|
|
541
|
+
*/
|
|
542
|
+
function detectRuntime() {
|
|
543
|
+
const g = globalThis;
|
|
544
|
+
if (g.navigator?.userAgent === "Cloudflare-Workers") return "cfworkers";
|
|
545
|
+
if ("Deno" in globalThis) return "deno";
|
|
546
|
+
if ("Bun" in globalThis) return "bun";
|
|
547
|
+
if (g.process?.versions?.node != null) return "node";
|
|
548
|
+
return "other";
|
|
549
|
+
}
|
|
550
|
+
let runtimePinner;
|
|
551
|
+
/**
|
|
552
|
+
* Gets the production pinner of the current runtime, which rejects
|
|
553
|
+
* non-public addresses right before connecting. Deno and Cloudflare Workers
|
|
554
|
+
* have no pinner.
|
|
555
|
+
* @returns The pinner, or `null` if the runtime has none.
|
|
556
|
+
*/
|
|
557
|
+
function getRuntimePinner() {
|
|
558
|
+
runtimePinner ??= createRuntimePinner(detectRuntime());
|
|
559
|
+
return runtimePinner;
|
|
560
|
+
}
|
|
561
|
+
async function createRuntimePinner(runtime) {
|
|
562
|
+
switch (runtime) {
|
|
563
|
+
case "bun": return createBunPinner({ verifyAddress: assertPublicAddress });
|
|
564
|
+
case "node": return createNodePinner({
|
|
565
|
+
verifyAddress: assertPublicAddress,
|
|
566
|
+
undici: await import("./undici-CjF8_EAQ.js").then((m) => /* @__PURE__ */ __toESM(m.default, 1))
|
|
567
|
+
});
|
|
568
|
+
default: return null;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const logger$2 = getLogger([
|
|
572
|
+
"fedify",
|
|
573
|
+
"runtime",
|
|
574
|
+
"fetch"
|
|
575
|
+
]);
|
|
576
|
+
/**
|
|
577
|
+
* Creates {@link PublicFetch.openPublicFetch} and
|
|
578
|
+
* {@link PublicFetch.withPublicFetch} bound to the given pinner and lookup.
|
|
579
|
+
* This is exported only for tests; it is not exported from any entry point.
|
|
580
|
+
* @param dependencies The pinner and the default lookup function.
|
|
581
|
+
* @returns The functions.
|
|
582
|
+
*/
|
|
583
|
+
function createPublicFetch(dependencies) {
|
|
584
|
+
async function openPublicFetch(url, options = {}) {
|
|
585
|
+
const { url: parsed, hostname, family } = parsePublicUrl(url);
|
|
586
|
+
if (parsed.username !== "" || parsed.password !== "") throw new UrlError("URLs with credentials are not allowed");
|
|
587
|
+
const lookup = options.lookup ?? dependencies.lookup;
|
|
588
|
+
let addresses;
|
|
589
|
+
if (family !== 0) addresses = [{
|
|
590
|
+
address: hostname,
|
|
591
|
+
family
|
|
592
|
+
}];
|
|
593
|
+
else if (lookup === lookupAllAddresses && !await canResolve()) return createSession(parsed, globalFetch);
|
|
594
|
+
else {
|
|
595
|
+
options.signal?.throwIfAborted();
|
|
596
|
+
addresses = await lookupPublicAddresses(hostname, lookup);
|
|
597
|
+
options.signal?.throwIfAborted();
|
|
598
|
+
}
|
|
599
|
+
const pinner = await dependencies.pinner();
|
|
600
|
+
if (pinner == null || !pinner.supports(parsed)) {
|
|
601
|
+
logUnpinned(parsed);
|
|
602
|
+
return createSession(parsed, globalFetch);
|
|
603
|
+
}
|
|
604
|
+
const grant = issuePinGrant(parsed, addresses);
|
|
605
|
+
return createSession(parsed, (input, init) => pinner.fetch(grant, new Request(input, init), init.signal ?? void 0), {
|
|
606
|
+
grant,
|
|
607
|
+
pinner
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
async function withPublicFetch(url, fn, options) {
|
|
611
|
+
const session = await openPublicFetch(url, options);
|
|
612
|
+
try {
|
|
613
|
+
const result = await fn(session.fetch);
|
|
614
|
+
if (result instanceof Response) throw new TypeError("withPublicFetch() callbacks must not return a Response, since its body is cancelled when the session is closed.");
|
|
615
|
+
return result;
|
|
616
|
+
} catch (error) {
|
|
617
|
+
logger$2.debug("Failed while fetching {url}; closing the session: {error}", {
|
|
618
|
+
url: session.url.href,
|
|
619
|
+
error
|
|
620
|
+
});
|
|
621
|
+
throw error;
|
|
622
|
+
} finally {
|
|
623
|
+
await session.close();
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
openPublicFetch,
|
|
628
|
+
withPublicFetch
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
const runtimePublicFetch = createPublicFetch({
|
|
632
|
+
pinner: getRuntimePinner,
|
|
633
|
+
lookup: lookupAllAddresses
|
|
634
|
+
});
|
|
635
|
+
/**
|
|
636
|
+
* Validates the URL, resolves its hostname, checks that every resolved
|
|
637
|
+
* address is public, and opens a session that connects only to those
|
|
638
|
+
* addresses. Connections are pinned on Node.js and Bun. On other runtimes,
|
|
639
|
+
* the session uses the global `fetch()` after the check.
|
|
640
|
+
* @param url The URL to open a session for.
|
|
641
|
+
* @param options The options.
|
|
642
|
+
* @returns The session. It must be closed.
|
|
643
|
+
* @throws {UrlError} If the URL is not allowed, or its hostname cannot be
|
|
644
|
+
* resolved (with `reason: "dns"`).
|
|
645
|
+
*/
|
|
646
|
+
const openPublicFetch = runtimePublicFetch.openPublicFetch;
|
|
647
|
+
runtimePublicFetch.withPublicFetch;
|
|
648
|
+
/**
|
|
649
|
+
* Opens a session that neither validates nor pins addresses, for callers that
|
|
650
|
+
* allow private addresses. It has the same rules as {@link openPublicFetch}
|
|
651
|
+
* sessions otherwise: requests are limited to the URL's origin, redirects are
|
|
652
|
+
* never followed, and unconsumed response bodies are cancelled on close.
|
|
653
|
+
* It grants nothing beyond calling the global `fetch()` directly.
|
|
654
|
+
* @param url The URL to open a session for.
|
|
655
|
+
* @returns The session. It must be closed.
|
|
656
|
+
*/
|
|
657
|
+
function openDirectFetch(url) {
|
|
658
|
+
return Promise.resolve(createSession(new URL(url), globalFetch));
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Starts cancelling the body of a response without waiting for it to settle.
|
|
662
|
+
* The cancellation of a branch of a tee (e.g., a body cloned by a fetch mock)
|
|
663
|
+
* settles only after the other branch is cancelled too, per the Streams
|
|
664
|
+
* standard, so awaiting it could block forever. Failures are only logged.
|
|
665
|
+
*/
|
|
666
|
+
function cancelResponseBody$1(response, url) {
|
|
667
|
+
response.body?.cancel().catch((error) => {
|
|
668
|
+
logger$2.debug("Failed to cancel a response body from {url}: {error}", {
|
|
669
|
+
url: url.href,
|
|
670
|
+
error
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
function globalFetch(input, init) {
|
|
675
|
+
return fetch(input, init);
|
|
676
|
+
}
|
|
677
|
+
function createSession(url, send, pin) {
|
|
678
|
+
const origin = url.origin;
|
|
679
|
+
const tracked = [];
|
|
680
|
+
let closed = false;
|
|
681
|
+
async function sessionFetch(input, init) {
|
|
682
|
+
if (closed) throw new TypeError("The fetch session is closed.");
|
|
683
|
+
const target = new URL(input instanceof Request ? input.url : input);
|
|
684
|
+
if (target.origin !== origin) throw new TypeError(`A fetch session for ${origin} cannot fetch ${target.origin}.`);
|
|
685
|
+
const response = await send(input, {
|
|
686
|
+
...init,
|
|
687
|
+
redirect: "manual"
|
|
23
688
|
});
|
|
689
|
+
if (closed) {
|
|
690
|
+
cancelResponseBody$1(response, url);
|
|
691
|
+
throw new TypeError("The fetch session is closed.");
|
|
692
|
+
}
|
|
693
|
+
tracked.push(response);
|
|
694
|
+
return response;
|
|
24
695
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
696
|
+
async function close() {
|
|
697
|
+
if (closed) return;
|
|
698
|
+
closed = true;
|
|
699
|
+
for (const response of tracked) {
|
|
700
|
+
if (response.body == null || response.bodyUsed) continue;
|
|
701
|
+
if (response.body.locked) {
|
|
702
|
+
logger$2.debug("Cannot cancel a locked response body from {url}.", { url: url.href });
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
cancelResponseBody$1(response, url);
|
|
706
|
+
}
|
|
707
|
+
if (pin == null) return;
|
|
708
|
+
revokePinGrant(pin.grant);
|
|
709
|
+
await pin.pinner.release(pin.grant);
|
|
710
|
+
}
|
|
711
|
+
return {
|
|
712
|
+
get url() {
|
|
713
|
+
return new URL(url);
|
|
714
|
+
},
|
|
715
|
+
fetch: sessionFetch,
|
|
716
|
+
close
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
async function canResolve() {
|
|
720
|
+
if (!("Deno" in globalThis)) return true;
|
|
721
|
+
return (await Deno.permissions.query({ name: "net" })).state === "granted";
|
|
722
|
+
}
|
|
723
|
+
const loggedUnpinned = /* @__PURE__ */ new Set();
|
|
724
|
+
function logUnpinned(url) {
|
|
725
|
+
const runtime = detectRuntime();
|
|
726
|
+
const key = `${runtime} ${url.protocol}`;
|
|
727
|
+
if (loggedUnpinned.has(key)) return;
|
|
728
|
+
loggedUnpinned.add(key);
|
|
729
|
+
logger$2.debug("Outbound {protocol} connections are not pinned to validated addresses on {runtime}; the hostname is resolved again when connecting.", {
|
|
730
|
+
runtime,
|
|
731
|
+
protocol: url.protocol
|
|
732
|
+
});
|
|
733
|
+
}
|
|
32
734
|
//#endregion
|
|
33
735
|
//#region ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/esm/platform/node/globalThis.js
|
|
34
736
|
/** only globals that common to node and browsers are allowed */
|
|
@@ -25775,7 +26477,7 @@ const preloadedContexts = {
|
|
|
25775
26477
|
"https://purl.archive.org/miscellany": miscellany_default
|
|
25776
26478
|
};
|
|
25777
26479
|
var name = "@fedify/vocab-runtime";
|
|
25778
|
-
var version = "2.2.
|
|
26480
|
+
var version = "2.2.20";
|
|
25779
26481
|
/**
|
|
25780
26482
|
* Error thrown when fetching a JSON-LD document failed.
|
|
25781
26483
|
*/
|
|
@@ -26095,214 +26797,6 @@ var HttpHeaderLink = class HttpHeaderLink {
|
|
|
26095
26797
|
return new this(...inputs).toString();
|
|
26096
26798
|
}
|
|
26097
26799
|
};
|
|
26098
|
-
var UrlError = class extends Error {
|
|
26099
|
-
/**
|
|
26100
|
-
* The failure category. Defaults to `"disallowed"`.
|
|
26101
|
-
* `"dns"` includes lookup errors and results with no usable IP addresses.
|
|
26102
|
-
* @since 2.0.29
|
|
26103
|
-
*/
|
|
26104
|
-
reason;
|
|
26105
|
-
constructor(message, options) {
|
|
26106
|
-
super(message, options);
|
|
26107
|
-
this.name = "UrlError";
|
|
26108
|
-
this.reason = options?.reason ?? "disallowed";
|
|
26109
|
-
}
|
|
26110
|
-
};
|
|
26111
|
-
/**
|
|
26112
|
-
* Validates a URL to prevent SSRF attacks.
|
|
26113
|
-
*/
|
|
26114
|
-
async function validatePublicUrl(url) {
|
|
26115
|
-
const parsed = new URL(url);
|
|
26116
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new UrlError(`Unsupported protocol: ${parsed.protocol}`);
|
|
26117
|
-
let hostname = parsed.hostname;
|
|
26118
|
-
if (hostname.startsWith("[") && hostname.endsWith("]")) hostname = hostname.slice(1, -1);
|
|
26119
|
-
if (hostname === "localhost") throw new UrlError("Localhost is not allowed");
|
|
26120
|
-
const hostnameFamily = isIP(hostname);
|
|
26121
|
-
if (hostnameFamily !== 0) {
|
|
26122
|
-
validatePublicIpAddress(hostname, hostnameFamily);
|
|
26123
|
-
return;
|
|
26124
|
-
}
|
|
26125
|
-
if ("Deno" in globalThis && !isIP(hostname)) {
|
|
26126
|
-
if ((await Deno.permissions.query({ name: "net" })).state !== "granted") return;
|
|
26127
|
-
}
|
|
26128
|
-
if ("Bun" in globalThis) {
|
|
26129
|
-
if (hostname === "example.com" || hostname.endsWith(".example.com")) return;
|
|
26130
|
-
else if (hostname === "fedify-test.internal") throw new UrlError("Invalid or private address: fedify-test.internal");
|
|
26131
|
-
}
|
|
26132
|
-
let addresses;
|
|
26133
|
-
try {
|
|
26134
|
-
addresses = await dns.lookup(hostname, { all: true });
|
|
26135
|
-
} catch (error) {
|
|
26136
|
-
throw new UrlError("DNS lookup failed", {
|
|
26137
|
-
cause: error,
|
|
26138
|
-
reason: "dns"
|
|
26139
|
-
});
|
|
26140
|
-
}
|
|
26141
|
-
validateLookupAddresses(addresses);
|
|
26142
|
-
}
|
|
26143
|
-
/**
|
|
26144
|
-
* Validates the IP addresses returned by `node:dns.lookup()`.
|
|
26145
|
-
*
|
|
26146
|
-
* Cloudflare Workers' `node:dns` implementation currently maps every record
|
|
26147
|
-
* in a DNS-over-HTTPS `Answer` array—including CNAME records—to a
|
|
26148
|
-
* `LookupAddress`, even though Node.js specifies that the `address` field must
|
|
26149
|
-
* contain an IPv4 or IPv6 literal. See:
|
|
26150
|
-
* https://github.com/cloudflare/workerd/issues/6886
|
|
26151
|
-
*
|
|
26152
|
-
* Work around that bug by ignoring non-IP entries only when the lookup also
|
|
26153
|
-
* returns at least one actual IP address. This remains fail-closed: a result
|
|
26154
|
-
* containing no IP addresses is rejected, and every returned IP address is
|
|
26155
|
-
* still validated and must be public. This workaround can be revisited once
|
|
26156
|
-
* the Workerd issue is fixed in supported Cloudflare Workers runtimes.
|
|
26157
|
-
*
|
|
26158
|
-
* @internal
|
|
26159
|
-
*/
|
|
26160
|
-
function validateLookupAddresses(addresses) {
|
|
26161
|
-
let ipAddressCount = 0;
|
|
26162
|
-
for (const { address } of addresses) {
|
|
26163
|
-
const family = isIP(address);
|
|
26164
|
-
if (family === 0) continue;
|
|
26165
|
-
ipAddressCount++;
|
|
26166
|
-
validatePublicIpAddress(address, family);
|
|
26167
|
-
}
|
|
26168
|
-
if (ipAddressCount === 0) throw new UrlError("DNS lookup did not return any IP address", { reason: "dns" });
|
|
26169
|
-
}
|
|
26170
|
-
function validatePublicIpAddress(address, family) {
|
|
26171
|
-
if (family === 4 && isValidPublicIPv4Address(address) || family === 6 && isValidPublicIPv6Address(address)) return;
|
|
26172
|
-
throw new UrlError(`Invalid or private address: ${address}`);
|
|
26173
|
-
}
|
|
26174
|
-
function isValidPublicIPv4Address(address) {
|
|
26175
|
-
const parts = parseIPv4Address(address);
|
|
26176
|
-
if (parts == null) return false;
|
|
26177
|
-
const value = ipv4PartsToNumber(parts);
|
|
26178
|
-
return !nonPublicIPv4Prefixes.some(({ base, prefix }) => matchesIPv4Prefix(value, base, prefix));
|
|
26179
|
-
}
|
|
26180
|
-
function isValidPublicIPv6Address(address) {
|
|
26181
|
-
const words = parseIPv6Address(address);
|
|
26182
|
-
if (words == null) return false;
|
|
26183
|
-
if (nonPublicIPv6Prefixes.some(({ words: prefixWords, prefix }) => matchesIPv6Prefix(words, prefixWords, prefix))) return false;
|
|
26184
|
-
for (const { extractIPv4, prefix, words: prefixWords } of ipv6WithIPv4Prefixes) {
|
|
26185
|
-
if (!matchesIPv6Prefix(words, prefixWords, prefix)) continue;
|
|
26186
|
-
const ipv4Address = extractIPv4(words);
|
|
26187
|
-
if (ipv4Address != null && !isValidPublicIPv4Address(ipv4Address)) return false;
|
|
26188
|
-
}
|
|
26189
|
-
return true;
|
|
26190
|
-
}
|
|
26191
|
-
function expandIPv6Address(address) {
|
|
26192
|
-
address = address.toLowerCase();
|
|
26193
|
-
const ipv4Delimiter = address.lastIndexOf(":");
|
|
26194
|
-
if (address.includes(".") && ipv4Delimiter >= 0) {
|
|
26195
|
-
const ipv4Parts = parseIPv4Address(address.substring(ipv4Delimiter + 1));
|
|
26196
|
-
if (ipv4Parts == null) return address;
|
|
26197
|
-
const high = (ipv4Parts[0] << 8) + ipv4Parts[1];
|
|
26198
|
-
const low = (ipv4Parts[2] << 8) + ipv4Parts[3];
|
|
26199
|
-
address = address.substring(0, ipv4Delimiter + 1) + high.toString(16) + ":" + low.toString(16);
|
|
26200
|
-
}
|
|
26201
|
-
if (address === "::") return "0000:0000:0000:0000:0000:0000:0000:0000";
|
|
26202
|
-
if (address.startsWith("::")) address = "0000" + address;
|
|
26203
|
-
if (address.endsWith("::")) address = address + "0000";
|
|
26204
|
-
address = address.replace("::", ":0000".repeat(8 - (address.match(/:/g) || []).length) + ":");
|
|
26205
|
-
return address.split(":").map((part) => part.padStart(4, "0")).join(":");
|
|
26206
|
-
}
|
|
26207
|
-
const nonPublicIPv4Prefixes = [
|
|
26208
|
-
ipv4Prefix("0.0.0.0/8", "RFC 6890"),
|
|
26209
|
-
ipv4Prefix("10.0.0.0/8", "RFC 1918"),
|
|
26210
|
-
ipv4Prefix("100.64.0.0/10", "RFC 6598"),
|
|
26211
|
-
ipv4Prefix("127.0.0.0/8", "RFC 1122"),
|
|
26212
|
-
ipv4Prefix("169.254.0.0/16", "RFC 3927"),
|
|
26213
|
-
ipv4Prefix("172.16.0.0/12", "RFC 1918"),
|
|
26214
|
-
ipv4Prefix("192.0.0.0/24", "RFC 6890"),
|
|
26215
|
-
ipv4Prefix("192.0.2.0/24", "RFC 5737"),
|
|
26216
|
-
ipv4Prefix("192.88.99.0/24", "RFC 7526"),
|
|
26217
|
-
ipv4Prefix("192.168.0.0/16", "RFC 1918"),
|
|
26218
|
-
ipv4Prefix("198.18.0.0/15", "RFC 2544"),
|
|
26219
|
-
ipv4Prefix("198.51.100.0/24", "RFC 5737"),
|
|
26220
|
-
ipv4Prefix("203.0.113.0/24", "RFC 5737"),
|
|
26221
|
-
ipv4Prefix("224.0.0.0/4", "RFC 5771"),
|
|
26222
|
-
ipv4Prefix("240.0.0.0/4", "RFC 1112")
|
|
26223
|
-
];
|
|
26224
|
-
const nonPublicIPv6Prefixes = [
|
|
26225
|
-
ipv6Prefix("::/16", "RFC 4291"),
|
|
26226
|
-
ipv6Prefix("2001::/32", "RFC 4380"),
|
|
26227
|
-
ipv6Prefix("2002::/16", "RFC 3056"),
|
|
26228
|
-
ipv6Prefix("64:ff9b:1::/48", "RFC 8215"),
|
|
26229
|
-
ipv6Prefix("fc00::/7", "RFC 4193"),
|
|
26230
|
-
ipv6Prefix("fe80::/10", "RFC 4291"),
|
|
26231
|
-
ipv6Prefix("ff00::/8", "RFC 4291")
|
|
26232
|
-
];
|
|
26233
|
-
const ipv6WithIPv4Prefixes = [{
|
|
26234
|
-
...ipv6Prefix("64:ff9b::/96", "RFC 6052"),
|
|
26235
|
-
extractIPv4: (words) => ipv4FromWords(words[6], words[7])
|
|
26236
|
-
}];
|
|
26237
|
-
function ipv4Prefix(cidr, rfc) {
|
|
26238
|
-
const [address, prefixText] = cidr.split("/");
|
|
26239
|
-
const prefix = parseInt(prefixText, 10);
|
|
26240
|
-
const parts = parseIPv4Address(address);
|
|
26241
|
-
if (parts == null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) throw new Error(`Invalid IPv4 prefix: ${cidr}`);
|
|
26242
|
-
return {
|
|
26243
|
-
cidr,
|
|
26244
|
-
base: ipv4PartsToNumber(parts),
|
|
26245
|
-
prefix,
|
|
26246
|
-
rfc
|
|
26247
|
-
};
|
|
26248
|
-
}
|
|
26249
|
-
function ipv6Prefix(cidr, rfc) {
|
|
26250
|
-
const [address, prefixText] = cidr.split("/");
|
|
26251
|
-
const prefix = parseInt(prefixText, 10);
|
|
26252
|
-
const words = parseIPv6Address(address);
|
|
26253
|
-
if (words == null || !Number.isInteger(prefix) || prefix < 0 || prefix > 128) throw new Error(`Invalid IPv6 prefix: ${cidr}`);
|
|
26254
|
-
return {
|
|
26255
|
-
cidr,
|
|
26256
|
-
words,
|
|
26257
|
-
prefix,
|
|
26258
|
-
rfc
|
|
26259
|
-
};
|
|
26260
|
-
}
|
|
26261
|
-
function parseIPv4Address(address) {
|
|
26262
|
-
const parts = address.split(".").map((part) => {
|
|
26263
|
-
if (!/^\d+$/.test(part)) return NaN;
|
|
26264
|
-
return parseInt(part, 10);
|
|
26265
|
-
});
|
|
26266
|
-
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
|
|
26267
|
-
return parts;
|
|
26268
|
-
}
|
|
26269
|
-
function parseIPv6Address(address) {
|
|
26270
|
-
const parts = expandIPv6Address(address).split(":");
|
|
26271
|
-
if (parts.length !== 8) return null;
|
|
26272
|
-
const words = parts.map((part) => {
|
|
26273
|
-
if (!/^[0-9a-f]{1,4}$/i.test(part)) return NaN;
|
|
26274
|
-
return parseInt(part, 16);
|
|
26275
|
-
});
|
|
26276
|
-
if (words.some((word) => !Number.isInteger(word) || word < 0 || word > 65535)) return null;
|
|
26277
|
-
return words;
|
|
26278
|
-
}
|
|
26279
|
-
function ipv4PartsToNumber(parts) {
|
|
26280
|
-
return parts[0] * 2 ** 24 + parts[1] * 2 ** 16 + parts[2] * 2 ** 8 + parts[3];
|
|
26281
|
-
}
|
|
26282
|
-
function ipv4FromWords(highWord, lowWord) {
|
|
26283
|
-
return [
|
|
26284
|
-
highWord >> 8,
|
|
26285
|
-
highWord & 255,
|
|
26286
|
-
lowWord >> 8,
|
|
26287
|
-
lowWord & 255
|
|
26288
|
-
].join(".");
|
|
26289
|
-
}
|
|
26290
|
-
function matchesIPv4Prefix(address, prefixBase, prefixLength) {
|
|
26291
|
-
const blockSize = 2 ** (32 - prefixLength);
|
|
26292
|
-
return Math.floor(address / blockSize) === Math.floor(prefixBase / blockSize);
|
|
26293
|
-
}
|
|
26294
|
-
function matchesIPv6Prefix(address, prefixWords, prefixLength) {
|
|
26295
|
-
let remaining = prefixLength;
|
|
26296
|
-
for (let i = 0; i < 8 && remaining > 0; i++) if (remaining >= 16) {
|
|
26297
|
-
if (address[i] !== prefixWords[i]) return false;
|
|
26298
|
-
remaining -= 16;
|
|
26299
|
-
} else {
|
|
26300
|
-
const mask = 65535 << 16 - remaining & 65535;
|
|
26301
|
-
if ((address[i] & mask) !== (prefixWords[i] & mask)) return false;
|
|
26302
|
-
remaining = 0;
|
|
26303
|
-
}
|
|
26304
|
-
return true;
|
|
26305
|
-
}
|
|
26306
26800
|
const logger = getLogger([
|
|
26307
26801
|
"fedify",
|
|
26308
26802
|
"runtime",
|
|
@@ -26310,16 +26804,34 @@ const logger = getLogger([
|
|
|
26310
26804
|
]);
|
|
26311
26805
|
const DEFAULT_MAX_REDIRECTION = 20;
|
|
26312
26806
|
/**
|
|
26313
|
-
*
|
|
26807
|
+
* Starts cancelling the body of a response without waiting for it to settle.
|
|
26808
|
+
* The cancellation of a branch of a tee (e.g., a body cloned by a fetch mock)
|
|
26809
|
+
* settles only after the other branch is cancelled too, per the Streams
|
|
26810
|
+
* standard, so awaiting it could block forever. Failures are only logged.
|
|
26811
|
+
* @param response The response whose body is cancelled.
|
|
26812
|
+
* @param url The URL of the response, for logging.
|
|
26813
|
+
*/
|
|
26814
|
+
function cancelResponseBody(response, url) {
|
|
26815
|
+
response.body?.cancel().catch((error) => {
|
|
26816
|
+
logger.debug("Failed to cancel the response body from {url}: {error}", {
|
|
26817
|
+
url,
|
|
26818
|
+
error
|
|
26819
|
+
});
|
|
26820
|
+
});
|
|
26821
|
+
}
|
|
26822
|
+
/**
|
|
26823
|
+
* Gets a {@link RemoteDocument} from the given response, or the URL of an
|
|
26824
|
+
* alternate document to load instead. When an alternate document is found,
|
|
26825
|
+
* the response body has been either read or its cancellation has been started
|
|
26826
|
+
* (without waiting for it), so that the alternate document can be loaded after
|
|
26827
|
+
* the response's session is closed.
|
|
26314
26828
|
* @param url The URL of the document to load.
|
|
26315
26829
|
* @param response The response to get the document from.
|
|
26316
|
-
* @
|
|
26317
|
-
* @returns The loaded remote document.
|
|
26830
|
+
* @returns The loaded remote document, or the alternate document URL.
|
|
26318
26831
|
* @throws {FetchError} If the response is not OK.
|
|
26319
|
-
* @internal
|
|
26320
26832
|
*/
|
|
26321
|
-
async function
|
|
26322
|
-
const documentUrl =
|
|
26833
|
+
async function parseRemoteDocument(url, response) {
|
|
26834
|
+
const documentUrl = url;
|
|
26323
26835
|
const docUrl = new URL(documentUrl);
|
|
26324
26836
|
if (!response.ok) {
|
|
26325
26837
|
logger.error("Failed to fetch document: {status} {url} {headers}", {
|
|
@@ -26356,7 +26868,8 @@ async function getRemoteDocument(url, response, fetch) {
|
|
|
26356
26868
|
alternateUrl: altUri.href,
|
|
26357
26869
|
url: documentUrl
|
|
26358
26870
|
});
|
|
26359
|
-
|
|
26871
|
+
cancelResponseBody(response, documentUrl);
|
|
26872
|
+
return { alternate: altUri.href };
|
|
26360
26873
|
}
|
|
26361
26874
|
}
|
|
26362
26875
|
}
|
|
@@ -26381,7 +26894,7 @@ async function getRemoteDocument(url, response, fetch) {
|
|
|
26381
26894
|
alternateUrl: attribs.href,
|
|
26382
26895
|
url: documentUrl
|
|
26383
26896
|
});
|
|
26384
|
-
return
|
|
26897
|
+
return { alternate: new URL(attribs.href, docUrl).href };
|
|
26385
26898
|
}
|
|
26386
26899
|
}
|
|
26387
26900
|
document = JSON.parse(html);
|
|
@@ -26391,11 +26904,11 @@ async function getRemoteDocument(url, response, fetch) {
|
|
|
26391
26904
|
url: documentUrl,
|
|
26392
26905
|
headers: Object.fromEntries(response.headers.entries())
|
|
26393
26906
|
});
|
|
26394
|
-
return {
|
|
26907
|
+
return { document: {
|
|
26395
26908
|
contextUrl,
|
|
26396
26909
|
document,
|
|
26397
26910
|
documentUrl
|
|
26398
|
-
};
|
|
26911
|
+
} };
|
|
26399
26912
|
}
|
|
26400
26913
|
/**
|
|
26401
26914
|
* Creates a JSON-LD document loader that utilizes the browser's `fetch` API.
|
|
@@ -26421,13 +26934,59 @@ async function getRemoteDocument(url, response, fetch) {
|
|
|
26421
26934
|
* @returns The document loader.
|
|
26422
26935
|
* @since 1.3.0
|
|
26423
26936
|
*/
|
|
26424
|
-
function getDocumentLoader(
|
|
26937
|
+
function getDocumentLoader(options = {}) {
|
|
26938
|
+
return createDocumentLoader(options);
|
|
26939
|
+
}
|
|
26940
|
+
/**
|
|
26941
|
+
* The implementation of {@link getDocumentLoader}, with injectable
|
|
26942
|
+
* dependencies. This is not exported from any entry point.
|
|
26943
|
+
* @param options Options for the document loader.
|
|
26944
|
+
* @param deps The dependencies.
|
|
26945
|
+
* @returns The document loader.
|
|
26946
|
+
*/
|
|
26947
|
+
function createDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedContexts, userAgent } = {}, deps = {}) {
|
|
26425
26948
|
const tracer = trace.getTracerProvider().getTracer(name, version);
|
|
26426
26949
|
const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
|
|
26950
|
+
async function openHop(url, signal) {
|
|
26951
|
+
try {
|
|
26952
|
+
if (deps.openHop != null) return await deps.openHop(url);
|
|
26953
|
+
return allowPrivateAddress ? await openDirectFetch(url) : await openPublicFetch(url, { signal });
|
|
26954
|
+
} catch (error) {
|
|
26955
|
+
if (error instanceof UrlError) if (error.reason === "dns") logger.debug("DNS lookup failed for {url}", {
|
|
26956
|
+
url,
|
|
26957
|
+
error
|
|
26958
|
+
});
|
|
26959
|
+
else logger.error("Disallowed private URL: {url}", {
|
|
26960
|
+
url,
|
|
26961
|
+
error
|
|
26962
|
+
});
|
|
26963
|
+
throw error;
|
|
26964
|
+
}
|
|
26965
|
+
}
|
|
26966
|
+
async function fetchHop(session, url, span, options) {
|
|
26967
|
+
try {
|
|
26968
|
+
const request = createActivityPubRequest(url, { userAgent });
|
|
26969
|
+
logRequest(logger, request);
|
|
26970
|
+
const response = await session.fetch(request, {
|
|
26971
|
+
redirect: "manual",
|
|
26972
|
+
signal: options?.signal
|
|
26973
|
+
});
|
|
26974
|
+
span.setAttribute("http.response.status_code", response.status);
|
|
26975
|
+
if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
|
|
26976
|
+
const location = response.headers.get("Location");
|
|
26977
|
+
cancelResponseBody(response, url);
|
|
26978
|
+
return { location };
|
|
26979
|
+
}
|
|
26980
|
+
return await parseRemoteDocument(url, response);
|
|
26981
|
+
} finally {
|
|
26982
|
+
await session.close();
|
|
26983
|
+
}
|
|
26984
|
+
}
|
|
26427
26985
|
async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
|
|
26428
26986
|
options?.signal?.throwIfAborted();
|
|
26429
26987
|
const parsedUrl = new URL(url);
|
|
26430
26988
|
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") throw new UrlError(`Unsupported protocol: ${parsedUrl.protocol}`);
|
|
26989
|
+
if (parsedUrl.username !== "" || parsedUrl.password !== "") throw new UrlError("URLs with credentials are not allowed");
|
|
26431
26990
|
const currentUrl = parsedUrl.href;
|
|
26432
26991
|
if (!skipPreloadedContexts && currentUrl in preloadedContexts) {
|
|
26433
26992
|
logger.debug("Using preloaded context: {url}.", { url: currentUrl });
|
|
@@ -26437,33 +26996,15 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
|
|
|
26437
26996
|
documentUrl: currentUrl
|
|
26438
26997
|
};
|
|
26439
26998
|
}
|
|
26440
|
-
|
|
26441
|
-
await validatePublicUrl(currentUrl);
|
|
26442
|
-
} catch (error) {
|
|
26443
|
-
if (error instanceof UrlError) if (error.reason === "dns") logger.debug("DNS lookup failed for {url}", {
|
|
26444
|
-
url: currentUrl,
|
|
26445
|
-
error
|
|
26446
|
-
});
|
|
26447
|
-
else logger.error("Disallowed private URL: {url}", {
|
|
26448
|
-
url: currentUrl,
|
|
26449
|
-
error
|
|
26450
|
-
});
|
|
26451
|
-
throw error;
|
|
26452
|
-
}
|
|
26999
|
+
const session = await openHop(currentUrl, options?.signal);
|
|
26453
27000
|
visited.add(currentUrl);
|
|
26454
27001
|
return await tracer.startActiveSpan("activitypub.fetch_document", {
|
|
26455
27002
|
kind: SpanKind.CLIENT,
|
|
26456
27003
|
attributes: { "url.full": currentUrl }
|
|
26457
27004
|
}, async (span) => {
|
|
26458
27005
|
try {
|
|
26459
|
-
const
|
|
26460
|
-
|
|
26461
|
-
const response = await fetch(request, {
|
|
26462
|
-
redirect: "manual",
|
|
26463
|
-
signal: options?.signal
|
|
26464
|
-
});
|
|
26465
|
-
span.setAttribute("http.response.status_code", response.status);
|
|
26466
|
-
if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
|
|
27006
|
+
const outcome = await fetchHop(session, currentUrl, span, options);
|
|
27007
|
+
if ("location" in outcome) {
|
|
26467
27008
|
if (redirected >= maximumRedirection) {
|
|
26468
27009
|
logger.error("Too many redirections ({redirections}) while fetching document.", {
|
|
26469
27010
|
redirections: redirected + 1,
|
|
@@ -26471,7 +27012,7 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
|
|
|
26471
27012
|
});
|
|
26472
27013
|
throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
|
|
26473
27014
|
}
|
|
26474
|
-
const redirectUrl = new URL(
|
|
27015
|
+
const redirectUrl = new URL(outcome.location, currentUrl).href;
|
|
26475
27016
|
span.setAttribute("http.redirect.url", redirectUrl);
|
|
26476
27017
|
if (visited.has(redirectUrl)) {
|
|
26477
27018
|
logger.error("Detected a redirect loop while fetching document: {url} -> {redirectUrl}", {
|
|
@@ -26482,12 +27023,14 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
|
|
|
26482
27023
|
}
|
|
26483
27024
|
return await load(redirectUrl, options, redirected + 1, visited);
|
|
26484
27025
|
}
|
|
26485
|
-
|
|
27026
|
+
let result;
|
|
27027
|
+
if ("alternate" in outcome) {
|
|
27028
|
+
const alternateUrl = outcome.alternate;
|
|
26486
27029
|
options?.signal?.throwIfAborted();
|
|
26487
27030
|
if (redirected >= DEFAULT_MAX_REDIRECTION) throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
|
|
26488
27031
|
if (visited.has(alternateUrl)) throw new FetchError(currentUrl, `Redirect loop detected: ${alternateUrl}`);
|
|
26489
|
-
|
|
26490
|
-
}
|
|
27032
|
+
result = await load(alternateUrl, options, redirected + 1, visited);
|
|
27033
|
+
} else result = outcome.document;
|
|
26491
27034
|
span.setAttribute("docloader.document_url", result.documentUrl);
|
|
26492
27035
|
if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
|
|
26493
27036
|
return result;
|