@fedify/vocab-runtime 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/deno.json +5 -2
- package/dist/internal.cjs +6 -0
- package/dist/internal.d.cts +3 -0
- package/dist/internal.d.ts +3 -0
- package/dist/internal.js +3 -0
- package/dist/mod.cjs +105 -252
- package/dist/mod.d.cts +5 -28
- package/dist/mod.d.ts +6 -28
- package/dist/mod.js +99 -245
- package/dist/public-fetch-BC-nWJNM.d.ts +143 -0
- package/dist/public-fetch-CFnW7XMn.cjs +821 -0
- package/dist/public-fetch-DBpt3xxf.d.cts +143 -0
- package/dist/public-fetch-Dfmc_Kfy.js +772 -0
- package/dist/tests/{body-CbvYxP3V.mjs → body-B9_hb-GO.mjs} +1 -1
- package/dist/tests/{body-CaJBg0tr.cjs → body-pOSG2RFb.cjs} +1 -1
- package/dist/tests/body.test.cjs +1 -1
- package/dist/tests/body.test.mjs +1 -1
- package/dist/tests/decimal.test.cjs +6 -6
- package/dist/tests/decimal.test.mjs +6 -6
- package/dist/tests/{docloader-jQMjHIRI.mjs → docloader-Bdlp7FkK.mjs} +103 -37
- package/dist/tests/{docloader-k2T5kUlV.cjs → docloader-Ck3VfY29.cjs} +107 -35
- package/dist/tests/docloader.test.cjs +569 -1273
- package/dist/tests/docloader.test.mjs +515 -1238
- package/dist/tests/esm-CnLt0wAF.mjs +1225 -0
- package/dist/tests/esm-CxbqfkLP.cjs +1208 -0
- package/dist/tests/internal/multicodec.test.cjs +1 -1
- package/dist/tests/internal/multicodec.test.mjs +1 -1
- package/dist/tests/{key-BDIsC6hV.mjs → key-GEdpxA4M.mjs} +2 -2
- package/dist/tests/{key-BarKGLQz.cjs → key-L6ReMuUb.cjs} +2 -2
- package/dist/tests/key.test.cjs +2 -2
- package/dist/tests/key.test.mjs +2 -2
- package/dist/tests/{langstr-BsqFJupv.cjs → langstr-CZpWmpo5.cjs} +1 -1
- package/dist/tests/{langstr-ABdAOKlF.mjs → langstr-DTB4sl0a.mjs} +1 -1
- package/dist/tests/langstr.test.cjs +1 -1
- package/dist/tests/langstr.test.mjs +1 -1
- package/dist/tests/langtag.test.cjs +1 -1
- package/dist/tests/langtag.test.mjs +1 -1
- package/dist/tests/link.test.mjs +1 -1
- package/dist/tests/multibase/multibase.test.cjs +1 -1
- package/dist/tests/multibase/multibase.test.mjs +1 -1
- package/dist/tests/node-C3bjFjL1.mjs +84 -0
- package/dist/tests/node-DWD76vPo.cjs +90 -0
- package/dist/tests/pin-grant-CU-o367k.cjs +70 -0
- package/dist/tests/pin-grant-HW3B6SKv.mjs +53 -0
- package/dist/tests/pin-grant.test.cjs +88 -0
- package/dist/tests/pin-grant.test.d.cts +1 -0
- package/dist/tests/pin-grant.test.d.mts +1 -0
- package/dist/tests/pin-grant.test.mjs +88 -0
- package/dist/tests/pinners/bun.test.cjs +167 -0
- package/dist/tests/pinners/bun.test.d.cts +1 -0
- package/dist/tests/pinners/bun.test.d.mts +1 -0
- package/dist/tests/pinners/bun.test.mjs +167 -0
- package/dist/tests/pinners/node.test.cjs +269 -0
- package/dist/tests/pinners/node.test.d.cts +1 -0
- package/dist/tests/pinners/node.test.d.mts +1 -0
- package/dist/tests/pinners/node.test.mjs +268 -0
- package/dist/tests/pinners/pinners.test.cjs +197 -0
- package/dist/tests/pinners/pinners.test.d.cts +1 -0
- package/dist/tests/pinners/pinners.test.d.mts +1 -0
- package/dist/tests/pinners/pinners.test.mjs +197 -0
- package/dist/tests/pinners/testdata/node-child.cjs +135 -0
- package/dist/tests/pinners/testdata/node-child.d.cts +1 -0
- package/dist/tests/pinners/testdata/node-child.d.mts +1 -0
- package/dist/tests/pinners/testdata/node-child.mjs +134 -0
- package/dist/tests/pinners-BjE-YJLL.cjs +221 -0
- package/dist/tests/pinners-ZgZDx9y6.mjs +203 -0
- package/dist/tests/public-fetch-Ddjfxbwc.cjs +187 -0
- package/dist/tests/public-fetch-Yi7Vks9s.mjs +170 -0
- package/dist/tests/public-fetch.test.cjs +1074 -0
- package/dist/tests/public-fetch.test.d.cts +1 -0
- package/dist/tests/public-fetch.test.d.mts +1 -0
- package/dist/tests/public-fetch.test.mjs +1074 -0
- package/dist/tests/{request-BngWcP0t.cjs → request-B3NUKbyk.cjs} +1 -1
- package/dist/tests/{request-BE7XIrGw.mjs → request-BSg3IQzf.mjs} +1 -1
- package/dist/tests/request.test.cjs +1 -1
- package/dist/tests/request.test.mjs +1 -1
- package/dist/tests/support-D5-KVjWZ.cjs +363 -0
- package/dist/tests/support-M5ClN-no.mjs +272 -0
- package/dist/tests/{url-vZTgkIZg.cjs → url-DLuSj57U.cjs} +88 -12
- package/dist/tests/{url-3fyJuqC0.mjs → url-DQWrMGhu.mjs} +65 -13
- package/dist/tests/url.test.cjs +4 -1
- package/dist/tests/url.test.mjs +4 -1
- package/package.json +14 -3
- package/src/docloader.test.ts +784 -32
- package/src/docloader.ts +179 -67
- package/src/internal.ts +13 -0
- package/src/pin-grant.test.ts +118 -0
- package/src/pin-grant.ts +99 -0
- package/src/pinners/bun.test.ts +278 -0
- package/src/pinners/bun.ts +223 -0
- package/src/pinners/mod.ts +59 -0
- package/src/pinners/node.test.ts +415 -0
- package/src/pinners/node.ts +144 -0
- package/src/pinners/pinners.test.ts +316 -0
- package/src/pinners/testdata/node-child.ts +147 -0
- package/src/pinners/testdata/proxy-child.ts +82 -0
- package/src/pinners/testdata/support.ts +343 -0
- package/src/pinners/testdata/tls-cert.pem +12 -0
- package/src/pinners/testdata/tls-child.ts +88 -0
- package/src/pinners/testdata/tls-key.pem +5 -0
- package/src/pinners/types.ts +48 -0
- package/src/public-fetch.test.ts +1286 -0
- package/src/public-fetch.ts +338 -0
- package/src/testdata/public-fetch-deno-worker.ts +74 -0
- package/src/url.test.ts +11 -0
- package/src/url.ts +113 -22
- package/tsdown.config.ts +12 -3
- /package/dist/tests/{langtag-LDBqlxHv.mjs → langtag-D8FWxjv8.mjs} +0 -0
- /package/dist/tests/{langtag-DgySESmk.cjs → langtag-a2-s9uyi.cjs} +0 -0
- /package/dist/tests/{link-CYszIegt.mjs → link-CHMtH2-k.mjs} +0 -0
- /package/dist/tests/{multibase-8zhYPa7o.cjs → multibase-BmWaK56H.cjs} +0 -0
- /package/dist/tests/{multibase-Ds75BvXs.mjs → multibase-D642Uv3S.mjs} +0 -0
- /package/dist/tests/{multicodec-ByS9XLsw.cjs → multicodec-B-_CSt1M.cjs} +0 -0
- /package/dist/tests/{multicodec-BBPZjRfg.mjs → multicodec-caWdG98t.mjs} +0 -0
package/dist/mod.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
|
|
2
|
+
import { a as expandIPv6Address, c as validatePublicUrl, i as UrlError, n as openPublicFetch, o as isValidPublicIPv4Address, s as isValidPublicIPv6Address, t as openDirectFetch } from "./public-fetch-Dfmc_Kfy.js";
|
|
2
3
|
import { getLogger } from "@logtape/logtape";
|
|
3
4
|
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
4
5
|
import process from "node:process";
|
|
5
|
-
import dns from "node:dns/promises";
|
|
6
|
-
import { isIP } from "node:net";
|
|
7
6
|
import { Integer, Sequence } from "asn1js";
|
|
8
7
|
import { decodeBase64, encodeBase64 } from "byte-encodings/base64";
|
|
9
8
|
import { decodeBase64Url } from "byte-encodings/base64url";
|
|
@@ -4588,7 +4587,7 @@ const preloadedContexts = {
|
|
|
4588
4587
|
//#endregion
|
|
4589
4588
|
//#region deno.json
|
|
4590
4589
|
var name = "@fedify/vocab-runtime";
|
|
4591
|
-
var version = "2.2.
|
|
4590
|
+
var version = "2.2.20";
|
|
4592
4591
|
//#endregion
|
|
4593
4592
|
//#region src/request.ts
|
|
4594
4593
|
/**
|
|
@@ -4915,216 +4914,6 @@ var HttpHeaderLink = class HttpHeaderLink {
|
|
|
4915
4914
|
}
|
|
4916
4915
|
};
|
|
4917
4916
|
//#endregion
|
|
4918
|
-
//#region src/url.ts
|
|
4919
|
-
var UrlError = class extends Error {
|
|
4920
|
-
/**
|
|
4921
|
-
* The failure category. Defaults to `"disallowed"`.
|
|
4922
|
-
* `"dns"` includes lookup errors and results with no usable IP addresses.
|
|
4923
|
-
* @since 2.0.29
|
|
4924
|
-
*/
|
|
4925
|
-
reason;
|
|
4926
|
-
constructor(message, options) {
|
|
4927
|
-
super(message, options);
|
|
4928
|
-
this.name = "UrlError";
|
|
4929
|
-
this.reason = options?.reason ?? "disallowed";
|
|
4930
|
-
}
|
|
4931
|
-
};
|
|
4932
|
-
/**
|
|
4933
|
-
* Validates a URL to prevent SSRF attacks.
|
|
4934
|
-
*/
|
|
4935
|
-
async function validatePublicUrl(url) {
|
|
4936
|
-
const parsed = new URL(url);
|
|
4937
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new UrlError(`Unsupported protocol: ${parsed.protocol}`);
|
|
4938
|
-
let hostname = parsed.hostname;
|
|
4939
|
-
if (hostname.startsWith("[") && hostname.endsWith("]")) hostname = hostname.slice(1, -1);
|
|
4940
|
-
if (hostname === "localhost") throw new UrlError("Localhost is not allowed");
|
|
4941
|
-
const hostnameFamily = isIP(hostname);
|
|
4942
|
-
if (hostnameFamily !== 0) {
|
|
4943
|
-
validatePublicIpAddress(hostname, hostnameFamily);
|
|
4944
|
-
return;
|
|
4945
|
-
}
|
|
4946
|
-
if ("Deno" in globalThis && !isIP(hostname)) {
|
|
4947
|
-
if ((await Deno.permissions.query({ name: "net" })).state !== "granted") return;
|
|
4948
|
-
}
|
|
4949
|
-
if ("Bun" in globalThis) {
|
|
4950
|
-
if (hostname === "example.com" || hostname.endsWith(".example.com")) return;
|
|
4951
|
-
else if (hostname === "fedify-test.internal") throw new UrlError("Invalid or private address: fedify-test.internal");
|
|
4952
|
-
}
|
|
4953
|
-
let addresses;
|
|
4954
|
-
try {
|
|
4955
|
-
addresses = await dns.lookup(hostname, { all: true });
|
|
4956
|
-
} catch (error) {
|
|
4957
|
-
throw new UrlError("DNS lookup failed", {
|
|
4958
|
-
cause: error,
|
|
4959
|
-
reason: "dns"
|
|
4960
|
-
});
|
|
4961
|
-
}
|
|
4962
|
-
validateLookupAddresses(addresses);
|
|
4963
|
-
}
|
|
4964
|
-
/**
|
|
4965
|
-
* Validates the IP addresses returned by `node:dns.lookup()`.
|
|
4966
|
-
*
|
|
4967
|
-
* Cloudflare Workers' `node:dns` implementation currently maps every record
|
|
4968
|
-
* in a DNS-over-HTTPS `Answer` array—including CNAME records—to a
|
|
4969
|
-
* `LookupAddress`, even though Node.js specifies that the `address` field must
|
|
4970
|
-
* contain an IPv4 or IPv6 literal. See:
|
|
4971
|
-
* https://github.com/cloudflare/workerd/issues/6886
|
|
4972
|
-
*
|
|
4973
|
-
* Work around that bug by ignoring non-IP entries only when the lookup also
|
|
4974
|
-
* returns at least one actual IP address. This remains fail-closed: a result
|
|
4975
|
-
* containing no IP addresses is rejected, and every returned IP address is
|
|
4976
|
-
* still validated and must be public. This workaround can be revisited once
|
|
4977
|
-
* the Workerd issue is fixed in supported Cloudflare Workers runtimes.
|
|
4978
|
-
*
|
|
4979
|
-
* @internal
|
|
4980
|
-
*/
|
|
4981
|
-
function validateLookupAddresses(addresses) {
|
|
4982
|
-
let ipAddressCount = 0;
|
|
4983
|
-
for (const { address } of addresses) {
|
|
4984
|
-
const family = isIP(address);
|
|
4985
|
-
if (family === 0) continue;
|
|
4986
|
-
ipAddressCount++;
|
|
4987
|
-
validatePublicIpAddress(address, family);
|
|
4988
|
-
}
|
|
4989
|
-
if (ipAddressCount === 0) throw new UrlError("DNS lookup did not return any IP address", { reason: "dns" });
|
|
4990
|
-
}
|
|
4991
|
-
function validatePublicIpAddress(address, family) {
|
|
4992
|
-
if (family === 4 && isValidPublicIPv4Address(address) || family === 6 && isValidPublicIPv6Address(address)) return;
|
|
4993
|
-
throw new UrlError(`Invalid or private address: ${address}`);
|
|
4994
|
-
}
|
|
4995
|
-
function isValidPublicIPv4Address(address) {
|
|
4996
|
-
const parts = parseIPv4Address(address);
|
|
4997
|
-
if (parts == null) return false;
|
|
4998
|
-
const value = ipv4PartsToNumber(parts);
|
|
4999
|
-
return !nonPublicIPv4Prefixes.some(({ base, prefix }) => matchesIPv4Prefix(value, base, prefix));
|
|
5000
|
-
}
|
|
5001
|
-
function isValidPublicIPv6Address(address) {
|
|
5002
|
-
const words = parseIPv6Address(address);
|
|
5003
|
-
if (words == null) return false;
|
|
5004
|
-
if (nonPublicIPv6Prefixes.some(({ words: prefixWords, prefix }) => matchesIPv6Prefix(words, prefixWords, prefix))) return false;
|
|
5005
|
-
for (const { extractIPv4, prefix, words: prefixWords } of ipv6WithIPv4Prefixes) {
|
|
5006
|
-
if (!matchesIPv6Prefix(words, prefixWords, prefix)) continue;
|
|
5007
|
-
const ipv4Address = extractIPv4(words);
|
|
5008
|
-
if (ipv4Address != null && !isValidPublicIPv4Address(ipv4Address)) return false;
|
|
5009
|
-
}
|
|
5010
|
-
return true;
|
|
5011
|
-
}
|
|
5012
|
-
function expandIPv6Address(address) {
|
|
5013
|
-
address = address.toLowerCase();
|
|
5014
|
-
const ipv4Delimiter = address.lastIndexOf(":");
|
|
5015
|
-
if (address.includes(".") && ipv4Delimiter >= 0) {
|
|
5016
|
-
const ipv4Parts = parseIPv4Address(address.substring(ipv4Delimiter + 1));
|
|
5017
|
-
if (ipv4Parts == null) return address;
|
|
5018
|
-
const high = (ipv4Parts[0] << 8) + ipv4Parts[1];
|
|
5019
|
-
const low = (ipv4Parts[2] << 8) + ipv4Parts[3];
|
|
5020
|
-
address = address.substring(0, ipv4Delimiter + 1) + high.toString(16) + ":" + low.toString(16);
|
|
5021
|
-
}
|
|
5022
|
-
if (address === "::") return "0000:0000:0000:0000:0000:0000:0000:0000";
|
|
5023
|
-
if (address.startsWith("::")) address = "0000" + address;
|
|
5024
|
-
if (address.endsWith("::")) address = address + "0000";
|
|
5025
|
-
address = address.replace("::", ":0000".repeat(8 - (address.match(/:/g) || []).length) + ":");
|
|
5026
|
-
return address.split(":").map((part) => part.padStart(4, "0")).join(":");
|
|
5027
|
-
}
|
|
5028
|
-
const nonPublicIPv4Prefixes = [
|
|
5029
|
-
ipv4Prefix("0.0.0.0/8", "RFC 6890"),
|
|
5030
|
-
ipv4Prefix("10.0.0.0/8", "RFC 1918"),
|
|
5031
|
-
ipv4Prefix("100.64.0.0/10", "RFC 6598"),
|
|
5032
|
-
ipv4Prefix("127.0.0.0/8", "RFC 1122"),
|
|
5033
|
-
ipv4Prefix("169.254.0.0/16", "RFC 3927"),
|
|
5034
|
-
ipv4Prefix("172.16.0.0/12", "RFC 1918"),
|
|
5035
|
-
ipv4Prefix("192.0.0.0/24", "RFC 6890"),
|
|
5036
|
-
ipv4Prefix("192.0.2.0/24", "RFC 5737"),
|
|
5037
|
-
ipv4Prefix("192.88.99.0/24", "RFC 7526"),
|
|
5038
|
-
ipv4Prefix("192.168.0.0/16", "RFC 1918"),
|
|
5039
|
-
ipv4Prefix("198.18.0.0/15", "RFC 2544"),
|
|
5040
|
-
ipv4Prefix("198.51.100.0/24", "RFC 5737"),
|
|
5041
|
-
ipv4Prefix("203.0.113.0/24", "RFC 5737"),
|
|
5042
|
-
ipv4Prefix("224.0.0.0/4", "RFC 5771"),
|
|
5043
|
-
ipv4Prefix("240.0.0.0/4", "RFC 1112")
|
|
5044
|
-
];
|
|
5045
|
-
const nonPublicIPv6Prefixes = [
|
|
5046
|
-
ipv6Prefix("::/16", "RFC 4291"),
|
|
5047
|
-
ipv6Prefix("2001::/32", "RFC 4380"),
|
|
5048
|
-
ipv6Prefix("2002::/16", "RFC 3056"),
|
|
5049
|
-
ipv6Prefix("64:ff9b:1::/48", "RFC 8215"),
|
|
5050
|
-
ipv6Prefix("fc00::/7", "RFC 4193"),
|
|
5051
|
-
ipv6Prefix("fe80::/10", "RFC 4291"),
|
|
5052
|
-
ipv6Prefix("ff00::/8", "RFC 4291")
|
|
5053
|
-
];
|
|
5054
|
-
const ipv6WithIPv4Prefixes = [{
|
|
5055
|
-
...ipv6Prefix("64:ff9b::/96", "RFC 6052"),
|
|
5056
|
-
extractIPv4: (words) => ipv4FromWords(words[6], words[7])
|
|
5057
|
-
}];
|
|
5058
|
-
function ipv4Prefix(cidr, rfc) {
|
|
5059
|
-
const [address, prefixText] = cidr.split("/");
|
|
5060
|
-
const prefix = parseInt(prefixText, 10);
|
|
5061
|
-
const parts = parseIPv4Address(address);
|
|
5062
|
-
if (parts == null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) throw new Error(`Invalid IPv4 prefix: ${cidr}`);
|
|
5063
|
-
return {
|
|
5064
|
-
cidr,
|
|
5065
|
-
base: ipv4PartsToNumber(parts),
|
|
5066
|
-
prefix,
|
|
5067
|
-
rfc
|
|
5068
|
-
};
|
|
5069
|
-
}
|
|
5070
|
-
function ipv6Prefix(cidr, rfc) {
|
|
5071
|
-
const [address, prefixText] = cidr.split("/");
|
|
5072
|
-
const prefix = parseInt(prefixText, 10);
|
|
5073
|
-
const words = parseIPv6Address(address);
|
|
5074
|
-
if (words == null || !Number.isInteger(prefix) || prefix < 0 || prefix > 128) throw new Error(`Invalid IPv6 prefix: ${cidr}`);
|
|
5075
|
-
return {
|
|
5076
|
-
cidr,
|
|
5077
|
-
words,
|
|
5078
|
-
prefix,
|
|
5079
|
-
rfc
|
|
5080
|
-
};
|
|
5081
|
-
}
|
|
5082
|
-
function parseIPv4Address(address) {
|
|
5083
|
-
const parts = address.split(".").map((part) => {
|
|
5084
|
-
if (!/^\d+$/.test(part)) return NaN;
|
|
5085
|
-
return parseInt(part, 10);
|
|
5086
|
-
});
|
|
5087
|
-
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
|
|
5088
|
-
return parts;
|
|
5089
|
-
}
|
|
5090
|
-
function parseIPv6Address(address) {
|
|
5091
|
-
const parts = expandIPv6Address(address).split(":");
|
|
5092
|
-
if (parts.length !== 8) return null;
|
|
5093
|
-
const words = parts.map((part) => {
|
|
5094
|
-
if (!/^[0-9a-f]{1,4}$/i.test(part)) return NaN;
|
|
5095
|
-
return parseInt(part, 16);
|
|
5096
|
-
});
|
|
5097
|
-
if (words.some((word) => !Number.isInteger(word) || word < 0 || word > 65535)) return null;
|
|
5098
|
-
return words;
|
|
5099
|
-
}
|
|
5100
|
-
function ipv4PartsToNumber(parts) {
|
|
5101
|
-
return parts[0] * 2 ** 24 + parts[1] * 2 ** 16 + parts[2] * 2 ** 8 + parts[3];
|
|
5102
|
-
}
|
|
5103
|
-
function ipv4FromWords(highWord, lowWord) {
|
|
5104
|
-
return [
|
|
5105
|
-
highWord >> 8,
|
|
5106
|
-
highWord & 255,
|
|
5107
|
-
lowWord >> 8,
|
|
5108
|
-
lowWord & 255
|
|
5109
|
-
].join(".");
|
|
5110
|
-
}
|
|
5111
|
-
function matchesIPv4Prefix(address, prefixBase, prefixLength) {
|
|
5112
|
-
const blockSize = 2 ** (32 - prefixLength);
|
|
5113
|
-
return Math.floor(address / blockSize) === Math.floor(prefixBase / blockSize);
|
|
5114
|
-
}
|
|
5115
|
-
function matchesIPv6Prefix(address, prefixWords, prefixLength) {
|
|
5116
|
-
let remaining = prefixLength;
|
|
5117
|
-
for (let i = 0; i < 8 && remaining > 0; i++) if (remaining >= 16) {
|
|
5118
|
-
if (address[i] !== prefixWords[i]) return false;
|
|
5119
|
-
remaining -= 16;
|
|
5120
|
-
} else {
|
|
5121
|
-
const mask = 65535 << 16 - remaining & 65535;
|
|
5122
|
-
if ((address[i] & mask) !== (prefixWords[i] & mask)) return false;
|
|
5123
|
-
remaining = 0;
|
|
5124
|
-
}
|
|
5125
|
-
return true;
|
|
5126
|
-
}
|
|
5127
|
-
//#endregion
|
|
5128
4917
|
//#region src/docloader.ts
|
|
5129
4918
|
const logger = getLogger([
|
|
5130
4919
|
"fedify",
|
|
@@ -5136,13 +4925,47 @@ const DEFAULT_MAX_REDIRECTION = 20;
|
|
|
5136
4925
|
* Gets a {@link RemoteDocument} from the given response.
|
|
5137
4926
|
* @param url The URL of the document to load.
|
|
5138
4927
|
* @param response The response to get the document from.
|
|
5139
|
-
* @param fetch The function to fetch the document.
|
|
4928
|
+
* @param fetch The function to fetch the document. It is called with the URL
|
|
4929
|
+
* of an alternate document, if any, after the response body has
|
|
4930
|
+
* been either read or its cancellation has been started.
|
|
5140
4931
|
* @returns The loaded remote document.
|
|
5141
4932
|
* @throws {FetchError} If the response is not OK.
|
|
5142
4933
|
* @internal
|
|
5143
4934
|
*/
|
|
5144
4935
|
async function getRemoteDocument(url, response, fetch) {
|
|
5145
|
-
const
|
|
4936
|
+
const result = await parseRemoteDocument(url, response);
|
|
4937
|
+
if ("alternate" in result) return await fetch(result.alternate);
|
|
4938
|
+
return result.document;
|
|
4939
|
+
}
|
|
4940
|
+
/**
|
|
4941
|
+
* Starts cancelling the body of a response without waiting for it to settle.
|
|
4942
|
+
* The cancellation of a branch of a tee (e.g., a body cloned by a fetch mock)
|
|
4943
|
+
* settles only after the other branch is cancelled too, per the Streams
|
|
4944
|
+
* standard, so awaiting it could block forever. Failures are only logged.
|
|
4945
|
+
* @param response The response whose body is cancelled.
|
|
4946
|
+
* @param url The URL of the response, for logging.
|
|
4947
|
+
*/
|
|
4948
|
+
function cancelResponseBody(response, url) {
|
|
4949
|
+
response.body?.cancel().catch((error) => {
|
|
4950
|
+
logger.debug("Failed to cancel the response body from {url}: {error}", {
|
|
4951
|
+
url,
|
|
4952
|
+
error
|
|
4953
|
+
});
|
|
4954
|
+
});
|
|
4955
|
+
}
|
|
4956
|
+
/**
|
|
4957
|
+
* Gets a {@link RemoteDocument} from the given response, or the URL of an
|
|
4958
|
+
* alternate document to load instead. When an alternate document is found,
|
|
4959
|
+
* the response body has been either read or its cancellation has been started
|
|
4960
|
+
* (without waiting for it), so that the alternate document can be loaded after
|
|
4961
|
+
* the response's session is closed.
|
|
4962
|
+
* @param url The URL of the document to load.
|
|
4963
|
+
* @param response The response to get the document from.
|
|
4964
|
+
* @returns The loaded remote document, or the alternate document URL.
|
|
4965
|
+
* @throws {FetchError} If the response is not OK.
|
|
4966
|
+
*/
|
|
4967
|
+
async function parseRemoteDocument(url, response) {
|
|
4968
|
+
const documentUrl = url;
|
|
5146
4969
|
const docUrl = new URL(documentUrl);
|
|
5147
4970
|
if (!response.ok) {
|
|
5148
4971
|
logger.error("Failed to fetch document: {status} {url} {headers}", {
|
|
@@ -5179,7 +5002,8 @@ async function getRemoteDocument(url, response, fetch) {
|
|
|
5179
5002
|
alternateUrl: altUri.href,
|
|
5180
5003
|
url: documentUrl
|
|
5181
5004
|
});
|
|
5182
|
-
|
|
5005
|
+
cancelResponseBody(response, documentUrl);
|
|
5006
|
+
return { alternate: altUri.href };
|
|
5183
5007
|
}
|
|
5184
5008
|
}
|
|
5185
5009
|
}
|
|
@@ -5204,7 +5028,7 @@ async function getRemoteDocument(url, response, fetch) {
|
|
|
5204
5028
|
alternateUrl: attribs.href,
|
|
5205
5029
|
url: documentUrl
|
|
5206
5030
|
});
|
|
5207
|
-
return
|
|
5031
|
+
return { alternate: new URL(attribs.href, docUrl).href };
|
|
5208
5032
|
}
|
|
5209
5033
|
}
|
|
5210
5034
|
document = JSON.parse(html);
|
|
@@ -5214,11 +5038,11 @@ async function getRemoteDocument(url, response, fetch) {
|
|
|
5214
5038
|
url: documentUrl,
|
|
5215
5039
|
headers: Object.fromEntries(response.headers.entries())
|
|
5216
5040
|
});
|
|
5217
|
-
return {
|
|
5041
|
+
return { document: {
|
|
5218
5042
|
contextUrl,
|
|
5219
5043
|
document,
|
|
5220
5044
|
documentUrl
|
|
5221
|
-
};
|
|
5045
|
+
} };
|
|
5222
5046
|
}
|
|
5223
5047
|
/**
|
|
5224
5048
|
* Creates a JSON-LD document loader that utilizes the browser's `fetch` API.
|
|
@@ -5244,13 +5068,59 @@ async function getRemoteDocument(url, response, fetch) {
|
|
|
5244
5068
|
* @returns The document loader.
|
|
5245
5069
|
* @since 1.3.0
|
|
5246
5070
|
*/
|
|
5247
|
-
function getDocumentLoader(
|
|
5071
|
+
function getDocumentLoader(options = {}) {
|
|
5072
|
+
return createDocumentLoader(options);
|
|
5073
|
+
}
|
|
5074
|
+
/**
|
|
5075
|
+
* The implementation of {@link getDocumentLoader}, with injectable
|
|
5076
|
+
* dependencies. This is not exported from any entry point.
|
|
5077
|
+
* @param options Options for the document loader.
|
|
5078
|
+
* @param deps The dependencies.
|
|
5079
|
+
* @returns The document loader.
|
|
5080
|
+
*/
|
|
5081
|
+
function createDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedContexts, userAgent } = {}, deps = {}) {
|
|
5248
5082
|
const tracer = trace.getTracerProvider().getTracer(name, version);
|
|
5249
5083
|
const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
|
|
5084
|
+
async function openHop(url, signal) {
|
|
5085
|
+
try {
|
|
5086
|
+
if (deps.openHop != null) return await deps.openHop(url);
|
|
5087
|
+
return allowPrivateAddress ? await openDirectFetch(url) : await openPublicFetch(url, { signal });
|
|
5088
|
+
} catch (error) {
|
|
5089
|
+
if (error instanceof UrlError) if (error.reason === "dns") logger.debug("DNS lookup failed for {url}", {
|
|
5090
|
+
url,
|
|
5091
|
+
error
|
|
5092
|
+
});
|
|
5093
|
+
else logger.error("Disallowed private URL: {url}", {
|
|
5094
|
+
url,
|
|
5095
|
+
error
|
|
5096
|
+
});
|
|
5097
|
+
throw error;
|
|
5098
|
+
}
|
|
5099
|
+
}
|
|
5100
|
+
async function fetchHop(session, url, span, options) {
|
|
5101
|
+
try {
|
|
5102
|
+
const request = createActivityPubRequest(url, { userAgent });
|
|
5103
|
+
logRequest(logger, request);
|
|
5104
|
+
const response = await session.fetch(request, {
|
|
5105
|
+
redirect: "manual",
|
|
5106
|
+
signal: options?.signal
|
|
5107
|
+
});
|
|
5108
|
+
span.setAttribute("http.response.status_code", response.status);
|
|
5109
|
+
if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
|
|
5110
|
+
const location = response.headers.get("Location");
|
|
5111
|
+
cancelResponseBody(response, url);
|
|
5112
|
+
return { location };
|
|
5113
|
+
}
|
|
5114
|
+
return await parseRemoteDocument(url, response);
|
|
5115
|
+
} finally {
|
|
5116
|
+
await session.close();
|
|
5117
|
+
}
|
|
5118
|
+
}
|
|
5250
5119
|
async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
|
|
5251
5120
|
options?.signal?.throwIfAborted();
|
|
5252
5121
|
const parsedUrl = new URL(url);
|
|
5253
5122
|
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") throw new UrlError(`Unsupported protocol: ${parsedUrl.protocol}`);
|
|
5123
|
+
if (parsedUrl.username !== "" || parsedUrl.password !== "") throw new UrlError("URLs with credentials are not allowed");
|
|
5254
5124
|
const currentUrl = parsedUrl.href;
|
|
5255
5125
|
if (!skipPreloadedContexts && currentUrl in preloadedContexts) {
|
|
5256
5126
|
logger.debug("Using preloaded context: {url}.", { url: currentUrl });
|
|
@@ -5260,33 +5130,15 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
|
|
|
5260
5130
|
documentUrl: currentUrl
|
|
5261
5131
|
};
|
|
5262
5132
|
}
|
|
5263
|
-
|
|
5264
|
-
await validatePublicUrl(currentUrl);
|
|
5265
|
-
} catch (error) {
|
|
5266
|
-
if (error instanceof UrlError) if (error.reason === "dns") logger.debug("DNS lookup failed for {url}", {
|
|
5267
|
-
url: currentUrl,
|
|
5268
|
-
error
|
|
5269
|
-
});
|
|
5270
|
-
else logger.error("Disallowed private URL: {url}", {
|
|
5271
|
-
url: currentUrl,
|
|
5272
|
-
error
|
|
5273
|
-
});
|
|
5274
|
-
throw error;
|
|
5275
|
-
}
|
|
5133
|
+
const session = await openHop(currentUrl, options?.signal);
|
|
5276
5134
|
visited.add(currentUrl);
|
|
5277
5135
|
return await tracer.startActiveSpan("activitypub.fetch_document", {
|
|
5278
5136
|
kind: SpanKind.CLIENT,
|
|
5279
5137
|
attributes: { "url.full": currentUrl }
|
|
5280
5138
|
}, async (span) => {
|
|
5281
5139
|
try {
|
|
5282
|
-
const
|
|
5283
|
-
|
|
5284
|
-
const response = await fetch(request, {
|
|
5285
|
-
redirect: "manual",
|
|
5286
|
-
signal: options?.signal
|
|
5287
|
-
});
|
|
5288
|
-
span.setAttribute("http.response.status_code", response.status);
|
|
5289
|
-
if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
|
|
5140
|
+
const outcome = await fetchHop(session, currentUrl, span, options);
|
|
5141
|
+
if ("location" in outcome) {
|
|
5290
5142
|
if (redirected >= maximumRedirection) {
|
|
5291
5143
|
logger.error("Too many redirections ({redirections}) while fetching document.", {
|
|
5292
5144
|
redirections: redirected + 1,
|
|
@@ -5294,7 +5146,7 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
|
|
|
5294
5146
|
});
|
|
5295
5147
|
throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
|
|
5296
5148
|
}
|
|
5297
|
-
const redirectUrl = new URL(
|
|
5149
|
+
const redirectUrl = new URL(outcome.location, currentUrl).href;
|
|
5298
5150
|
span.setAttribute("http.redirect.url", redirectUrl);
|
|
5299
5151
|
if (visited.has(redirectUrl)) {
|
|
5300
5152
|
logger.error("Detected a redirect loop while fetching document: {url} -> {redirectUrl}", {
|
|
@@ -5305,12 +5157,14 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
|
|
|
5305
5157
|
}
|
|
5306
5158
|
return await load(redirectUrl, options, redirected + 1, visited);
|
|
5307
5159
|
}
|
|
5308
|
-
|
|
5160
|
+
let result;
|
|
5161
|
+
if ("alternate" in outcome) {
|
|
5162
|
+
const alternateUrl = outcome.alternate;
|
|
5309
5163
|
options?.signal?.throwIfAborted();
|
|
5310
5164
|
if (redirected >= DEFAULT_MAX_REDIRECTION) throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
|
|
5311
5165
|
if (visited.has(alternateUrl)) throw new FetchError(currentUrl, `Redirect loop detected: ${alternateUrl}`);
|
|
5312
|
-
|
|
5313
|
-
}
|
|
5166
|
+
result = await load(alternateUrl, options, redirected + 1, visited);
|
|
5167
|
+
} else result = outcome.document;
|
|
5314
5168
|
span.setAttribute("docloader.document_url", result.documentUrl);
|
|
5315
5169
|
if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
|
|
5316
5170
|
return result;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/// <reference lib="esnext.temporal" />
|
|
2
|
+
//#region src/url.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* An address returned by a DNS lookup. It has the same shape as
|
|
5
|
+
* `ResolvedAddress` of `node:dns`, without depending on its type declarations.
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
interface ResolvedAddress {
|
|
9
|
+
/** An IPv4 or IPv6 address. */
|
|
10
|
+
readonly address: string;
|
|
11
|
+
/** The IP family of the address: `4` or `6`. */
|
|
12
|
+
readonly family: number;
|
|
13
|
+
}
|
|
14
|
+
declare class UrlError extends Error {
|
|
15
|
+
/**
|
|
16
|
+
* The failure category. Defaults to `"disallowed"`.
|
|
17
|
+
* `"dns"` includes lookup errors and results with no usable IP addresses.
|
|
18
|
+
* @since 2.0.29
|
|
19
|
+
*/
|
|
20
|
+
readonly reason: "dns" | "disallowed";
|
|
21
|
+
constructor(message: string, options?: {
|
|
22
|
+
cause?: unknown;
|
|
23
|
+
reason?: "dns" | "disallowed";
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Validates a URL to prevent SSRF attacks.
|
|
28
|
+
*
|
|
29
|
+
* This function only checks the addresses that the hostname currently
|
|
30
|
+
* resolves to; it does not pin the connection to those addresses. A request
|
|
31
|
+
* made after this check resolves the hostname again, so this function alone
|
|
32
|
+
* does not prevent DNS rebinding.
|
|
33
|
+
*/
|
|
34
|
+
declare function validatePublicUrl(url: string): Promise<void>;
|
|
35
|
+
declare function isValidPublicIPv4Address(address: string): boolean;
|
|
36
|
+
declare function isValidPublicIPv6Address(address: string): boolean;
|
|
37
|
+
declare function expandIPv6Address(address: string): string;
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/public-fetch.d.ts
|
|
40
|
+
/**
|
|
41
|
+
* Options for {@link openPublicFetch} and {@link withPublicFetch}.
|
|
42
|
+
*/
|
|
43
|
+
interface PublicFetchOptions {
|
|
44
|
+
/**
|
|
45
|
+
* An `AbortSignal` for cancelling the DNS lookup.
|
|
46
|
+
*/
|
|
47
|
+
readonly signal?: AbortSignal;
|
|
48
|
+
/**
|
|
49
|
+
* A function that resolves all addresses of a hostname, used instead of
|
|
50
|
+
* `node:dns.lookup()`. The addresses it returns are validated in the same
|
|
51
|
+
* way as the default lookup's.
|
|
52
|
+
*/
|
|
53
|
+
readonly lookup?: (hostname: string) => Promise<readonly ResolvedAddress[]>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A session for fetching resources from a single origin. Its `fetch()`
|
|
57
|
+
* connects only to the addresses validated when the session was opened, on
|
|
58
|
+
* runtimes that support it.
|
|
59
|
+
*/
|
|
60
|
+
interface PublicFetchSession {
|
|
61
|
+
/**
|
|
62
|
+
* A copy of the URL the session was opened for.
|
|
63
|
+
*/
|
|
64
|
+
readonly url: URL;
|
|
65
|
+
/**
|
|
66
|
+
* Fetches a resource from the session's origin. Redirects are never
|
|
67
|
+
* followed; the redirect mode is always `"manual"`.
|
|
68
|
+
* @throws {TypeError} If the input's origin differs from the session's, or
|
|
69
|
+
* if the session is closed.
|
|
70
|
+
*/
|
|
71
|
+
fetch(input: Request | URL | string, init?: RequestInit): Promise<Response>;
|
|
72
|
+
/**
|
|
73
|
+
* Closes the session. The cancellation of response bodies that have been
|
|
74
|
+
* neither consumed nor cancelled is started (without waiting for it), and
|
|
75
|
+
* the session can no longer fetch. Calling this more than once has no
|
|
76
|
+
* effect.
|
|
77
|
+
*/
|
|
78
|
+
close(): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The functions created by {@link createPublicFetch}.
|
|
82
|
+
*/
|
|
83
|
+
interface PublicFetch {
|
|
84
|
+
/**
|
|
85
|
+
* Validates the URL, resolves its hostname, checks that every resolved
|
|
86
|
+
* address is public, and opens a session that connects only to those
|
|
87
|
+
* addresses.
|
|
88
|
+
* @param url The URL to open a session for.
|
|
89
|
+
* @param options The options.
|
|
90
|
+
* @returns The session. It must be closed.
|
|
91
|
+
* @throws {UrlError} If the URL is not allowed, or its hostname cannot be
|
|
92
|
+
* resolved (with `reason: "dns"`).
|
|
93
|
+
*/
|
|
94
|
+
openPublicFetch(url: URL | string, options?: PublicFetchOptions): Promise<PublicFetchSession>;
|
|
95
|
+
/**
|
|
96
|
+
* Opens a session with {@link PublicFetch.openPublicFetch}, runs the given
|
|
97
|
+
* function with its `fetch()`, and closes the session afterwards.
|
|
98
|
+
* @param url The URL to open a session for.
|
|
99
|
+
* @param fn The function to run. It must not return a `Response`, since
|
|
100
|
+
* the response bodies are cancelled when the session is closed.
|
|
101
|
+
* @param options The options.
|
|
102
|
+
* @returns The value returned by `fn`.
|
|
103
|
+
* @throws {UrlError} If the URL is not allowed.
|
|
104
|
+
* @throws {TypeError} If `fn` returns a `Response`.
|
|
105
|
+
*/
|
|
106
|
+
withPublicFetch<T>(url: URL | string, fn: (fetch: PublicFetchSession["fetch"]) => Promise<T>, options?: PublicFetchOptions): Promise<T>;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Validates the URL, resolves its hostname, checks that every resolved
|
|
110
|
+
* address is public, and opens a session that connects only to those
|
|
111
|
+
* addresses. Connections are pinned on Node.js and Bun. On other runtimes,
|
|
112
|
+
* the session uses the global `fetch()` after the check.
|
|
113
|
+
* @param url The URL to open a session for.
|
|
114
|
+
* @param options The options.
|
|
115
|
+
* @returns The session. It must be closed.
|
|
116
|
+
* @throws {UrlError} If the URL is not allowed, or its hostname cannot be
|
|
117
|
+
* resolved (with `reason: "dns"`).
|
|
118
|
+
*/
|
|
119
|
+
declare const openPublicFetch: PublicFetch["openPublicFetch"];
|
|
120
|
+
/**
|
|
121
|
+
* Opens a session with {@link openPublicFetch}, runs the given function with
|
|
122
|
+
* its `fetch()`, and closes the session afterwards.
|
|
123
|
+
* @param url The URL to open a session for.
|
|
124
|
+
* @param fn The function to run. It must not return a `Response`, since
|
|
125
|
+
* the response bodies are cancelled when the session is closed.
|
|
126
|
+
* @param options The options.
|
|
127
|
+
* @returns The value returned by `fn`.
|
|
128
|
+
* @throws {UrlError} If the URL is not allowed.
|
|
129
|
+
* @throws {TypeError} If `fn` returns a `Response`.
|
|
130
|
+
*/
|
|
131
|
+
declare const withPublicFetch: PublicFetch["withPublicFetch"];
|
|
132
|
+
/**
|
|
133
|
+
* Opens a session that neither validates nor pins addresses, for callers that
|
|
134
|
+
* allow private addresses. It has the same rules as {@link openPublicFetch}
|
|
135
|
+
* sessions otherwise: requests are limited to the URL's origin, redirects are
|
|
136
|
+
* never followed, and unconsumed response bodies are cancelled on close.
|
|
137
|
+
* It grants nothing beyond calling the global `fetch()` directly.
|
|
138
|
+
* @param url The URL to open a session for.
|
|
139
|
+
* @returns The session. It must be closed.
|
|
140
|
+
*/
|
|
141
|
+
declare function openDirectFetch(url: URL | string): Promise<PublicFetchSession>;
|
|
142
|
+
//#endregion
|
|
143
|
+
export { withPublicFetch as a, isValidPublicIPv4Address as c, openPublicFetch as i, isValidPublicIPv6Address as l, PublicFetchSession as n, UrlError as o, openDirectFetch as r, expandIPv6Address as s, PublicFetchOptions as t, validatePublicUrl as u };
|