@fedify/vocab-runtime 2.5.0-dev.2448 → 2.5.0-dev.2494

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.
Files changed (120) hide show
  1. package/deno.json +5 -2
  2. package/dist/{docloader-BBnBpbsG.d.cts → docloader-DD6binpN.d.cts} +4 -8
  3. package/dist/{docloader-BBnBpbsG.d.ts → docloader-DD6binpN.d.ts} +4 -8
  4. package/dist/internal/jsonld-cache.cjs +1 -1
  5. package/dist/internal/jsonld-cache.d.cts +1 -1
  6. package/dist/internal/jsonld-cache.d.ts +1 -1
  7. package/dist/internal/jsonld-cache.js +1 -1
  8. package/dist/internal/portable-dereference.cjs +1 -1
  9. package/dist/internal/portable-dereference.d.cts +2 -2
  10. package/dist/internal/portable-dereference.d.ts +2 -2
  11. package/dist/internal/portable-dereference.js +1 -1
  12. package/dist/internal.cjs +6 -0
  13. package/dist/internal.d.cts +108 -0
  14. package/dist/internal.d.ts +108 -0
  15. package/dist/internal.js +3 -0
  16. package/dist/mod.cjs +104 -38
  17. package/dist/mod.d.cts +3 -336
  18. package/dist/mod.d.ts +4 -336
  19. package/dist/mod.js +104 -38
  20. package/dist/{portable-BDydjp5H.d.ts → portable-1VSnSJFj.d.ts} +1 -1
  21. package/dist/{portable-Dfvcg_Wy.d.cts → portable-Cr7NwZwb.d.cts} +1 -1
  22. package/dist/public-fetch-Dvfvawei.cjs +527 -0
  23. package/dist/public-fetch-ziZ39L5I.js +510 -0
  24. package/dist/tests/{body-DP2u2wUX.cjs → body-0Or8U7lA.cjs} +1 -1
  25. package/dist/tests/{body-oV99TVdA.mjs → body-DfH6VTh9.mjs} +1 -1
  26. package/dist/tests/body.test.cjs +1 -1
  27. package/dist/tests/body.test.mjs +1 -1
  28. package/dist/tests/decimal.test.cjs +4 -4
  29. package/dist/tests/decimal.test.mjs +4 -4
  30. package/dist/tests/{docloader-D3mA7aPJ.mjs → docloader-BZvFOza_.mjs} +106 -40
  31. package/dist/tests/{docloader-CwxZguqN.cjs → docloader-W_RHEh5W.cjs} +111 -39
  32. package/dist/tests/docloader.test.cjs +531 -1293
  33. package/dist/tests/docloader.test.mjs +472 -1252
  34. package/dist/tests/esm-Ds-eHTfY.mjs +1229 -0
  35. package/dist/tests/esm-Q2tdh5f3.cjs +1215 -0
  36. package/dist/tests/internal/portable-dereference.test.cjs +3 -3
  37. package/dist/tests/internal/portable-dereference.test.mjs +3 -3
  38. package/dist/tests/{jsonld-cache-BEXiiM-V.cjs → jsonld-cache-DKIWKxEX.cjs} +1 -1
  39. package/dist/tests/{jsonld-cache-DFnWsLuZ.mjs → jsonld-cache-KH40z9_7.mjs} +1 -1
  40. package/dist/tests/jsonld-cache.test.cjs +2 -2
  41. package/dist/tests/jsonld-cache.test.mjs +2 -2
  42. package/dist/tests/node-CbBR-th-.mjs +84 -0
  43. package/dist/tests/node-Ch29PDXR.cjs +89 -0
  44. package/dist/tests/pin-grant-CPrpk1l4.cjs +70 -0
  45. package/dist/tests/pin-grant-H5XhM1Xs.mjs +53 -0
  46. package/dist/tests/pin-grant.test.cjs +87 -0
  47. package/dist/tests/pin-grant.test.d.cts +1 -0
  48. package/dist/tests/pin-grant.test.d.mts +1 -0
  49. package/dist/tests/pin-grant.test.mjs +88 -0
  50. package/dist/tests/pinners/bun.test.cjs +166 -0
  51. package/dist/tests/pinners/bun.test.d.cts +1 -0
  52. package/dist/tests/pinners/bun.test.d.mts +1 -0
  53. package/dist/tests/pinners/bun.test.mjs +167 -0
  54. package/dist/tests/pinners/node.test.cjs +269 -0
  55. package/dist/tests/pinners/node.test.d.cts +1 -0
  56. package/dist/tests/pinners/node.test.d.mts +1 -0
  57. package/dist/tests/pinners/node.test.mjs +268 -0
  58. package/dist/tests/pinners/pinners.test.cjs +196 -0
  59. package/dist/tests/pinners/pinners.test.d.cts +1 -0
  60. package/dist/tests/pinners/pinners.test.d.mts +1 -0
  61. package/dist/tests/pinners/pinners.test.mjs +197 -0
  62. package/dist/tests/pinners/testdata/node-child.cjs +135 -0
  63. package/dist/tests/pinners/testdata/node-child.d.cts +1 -0
  64. package/dist/tests/pinners/testdata/node-child.d.mts +1 -0
  65. package/dist/tests/pinners/testdata/node-child.mjs +134 -0
  66. package/dist/tests/pinners-DLH_npLf.cjs +220 -0
  67. package/dist/tests/pinners-tGcl49uF.mjs +203 -0
  68. package/dist/tests/{portable-dereference-DaTBBvCj.cjs → portable-dereference-BUoDzMxk.cjs} +2 -2
  69. package/dist/tests/{portable-dereference-sGRs_qdT.mjs → portable-dereference-Du2k3ngE.mjs} +2 -2
  70. package/dist/tests/{portable-media-B-oCeotd.mjs → portable-media-CqCq3wqj.mjs} +3 -3
  71. package/dist/tests/{portable-media-D7ClSjFO.cjs → portable-media-Ls_EOkMN.cjs} +3 -3
  72. package/dist/tests/portable-media.test.cjs +1 -1
  73. package/dist/tests/portable-media.test.mjs +1 -1
  74. package/dist/tests/portable-workers.test.cjs +2 -2
  75. package/dist/tests/portable-workers.test.mjs +2 -2
  76. package/dist/tests/public-fetch-BGj5pzUD.cjs +186 -0
  77. package/dist/tests/public-fetch-Cxo3Yzdf.mjs +170 -0
  78. package/dist/tests/public-fetch.test.cjs +1073 -0
  79. package/dist/tests/public-fetch.test.d.cts +1 -0
  80. package/dist/tests/public-fetch.test.d.mts +1 -0
  81. package/dist/tests/public-fetch.test.mjs +1074 -0
  82. package/dist/tests/{request-BlLYI4x3.mjs → request-Bo8cJJhV.mjs} +2 -2
  83. package/dist/tests/{request-B77_oZnk.cjs → request-Cb6h3X72.cjs} +2 -2
  84. package/dist/tests/request.test.cjs +2 -2
  85. package/dist/tests/request.test.mjs +2 -2
  86. package/dist/tests/support-Bj44xlQu.mjs +272 -0
  87. package/dist/tests/support-BrdlbLJk.cjs +363 -0
  88. package/dist/tests/{url-BNakuZ8k.cjs → url-Bdj-rTcC.cjs} +88 -12
  89. package/dist/tests/{url-DMxmp7ZG.mjs → url-COj5Q8mb.mjs} +65 -13
  90. package/dist/tests/url.test.cjs +4 -1
  91. package/dist/tests/url.test.mjs +4 -1
  92. package/dist/url-4EE56sNP.d.cts +352 -0
  93. package/dist/url-4EE56sNP.d.ts +352 -0
  94. package/dist/{url-DrGTR8yv.cjs → url-DIKZnaJv.cjs} +88 -12
  95. package/dist/{url-Dzyp-NsC.js → url-mWveR5GA.js} +65 -13
  96. package/package.json +14 -3
  97. package/src/docloader.test.ts +696 -48
  98. package/src/docloader.ts +186 -75
  99. package/src/internal.ts +13 -0
  100. package/src/pin-grant.test.ts +118 -0
  101. package/src/pin-grant.ts +99 -0
  102. package/src/pinners/bun.test.ts +278 -0
  103. package/src/pinners/bun.ts +223 -0
  104. package/src/pinners/mod.ts +59 -0
  105. package/src/pinners/node.test.ts +415 -0
  106. package/src/pinners/node.ts +144 -0
  107. package/src/pinners/pinners.test.ts +316 -0
  108. package/src/pinners/testdata/node-child.ts +147 -0
  109. package/src/pinners/testdata/proxy-child.ts +82 -0
  110. package/src/pinners/testdata/support.ts +343 -0
  111. package/src/pinners/testdata/tls-cert.pem +12 -0
  112. package/src/pinners/testdata/tls-child.ts +88 -0
  113. package/src/pinners/testdata/tls-key.pem +5 -0
  114. package/src/pinners/types.ts +48 -0
  115. package/src/public-fetch.test.ts +1286 -0
  116. package/src/public-fetch.ts +338 -0
  117. package/src/testdata/public-fetch-deno-worker.ts +74 -0
  118. package/src/url.test.ts +11 -0
  119. package/src/url.ts +113 -22
  120. package/tsdown.config.ts +7 -2
@@ -664,28 +664,65 @@ function replaceGatewayHints(parts, hints) {
664
664
  }
665
665
  /**
666
666
  * Validates a URL to prevent SSRF attacks.
667
+ *
668
+ * This function only checks the addresses that the hostname currently
669
+ * resolves to; it does not pin the connection to those addresses. A request
670
+ * made after this check resolves the hostname again, so this function alone
671
+ * does not prevent DNS rebinding.
667
672
  */
668
673
  async function validatePublicUrl(url) {
669
- const parsed = new URL(url);
670
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new UrlError(`Unsupported protocol: ${parsed.protocol}`);
671
- let hostname = parsed.hostname;
672
- if (hostname.startsWith("[") && hostname.endsWith("]")) hostname = hostname.slice(1, -1);
673
- if (hostname === "localhost") throw new UrlError("Localhost is not allowed");
674
- const hostnameFamily = (0, node_net.isIP)(hostname);
675
- if (hostnameFamily !== 0) {
676
- validatePublicIpAddress(hostname, hostnameFamily);
677
- return;
678
- }
679
- if ("Deno" in globalThis && !(0, node_net.isIP)(hostname)) {
674
+ const { hostname, family } = parsePublicUrl(url);
675
+ if (family !== 0) return;
676
+ if ("Deno" in globalThis) {
680
677
  if ((await Deno.permissions.query({ name: "net" })).state !== "granted") return;
681
678
  }
682
679
  if ("Bun" in globalThis) {
683
680
  if (hostname === "example.com" || hostname.endsWith(".example.com")) return;
684
681
  else if (hostname === "fedify-test.internal") throw new UrlError("Invalid or private address: fedify-test.internal");
685
682
  }
683
+ await lookupPublicAddresses(hostname, lookupAllAddresses);
684
+ }
685
+ /**
686
+ * Resolves all addresses of the given hostname through `node:dns/promises`.
687
+ * The default `dns` object is looked up on every call so that tests can still
688
+ * stub `dns.lookup()`.
689
+ * @internal
690
+ */
691
+ function lookupAllAddresses(hostname) {
692
+ return node_dns_promises.default.lookup(hostname, { all: true });
693
+ }
694
+ /**
695
+ * Checks the parts of a URL that can be validated without resolving it:
696
+ * the protocol, the `localhost` hostname, and IP literal hostnames.
697
+ * @throws {UrlError} If the URL is not allowed.
698
+ * @internal
699
+ */
700
+ function parsePublicUrl(url) {
701
+ const parsed = new URL(url);
702
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new UrlError(`Unsupported protocol: ${parsed.protocol}`);
703
+ let hostname = parsed.hostname;
704
+ if (hostname.startsWith("[") && hostname.endsWith("]")) hostname = hostname.slice(1, -1);
705
+ if (hostname === "localhost") throw new UrlError("Localhost is not allowed");
706
+ const family = (0, node_net.isIP)(hostname);
707
+ if (family !== 0) validatePublicIpAddress(hostname, family);
708
+ return {
709
+ url: parsed,
710
+ hostname,
711
+ family
712
+ };
713
+ }
714
+ /**
715
+ * Resolves the given hostname and checks that every returned IP address is
716
+ * public.
717
+ * @returns The IP addresses of the hostname, without non-IP entries.
718
+ * @throws {UrlError} If the lookup fails, returns no IP address, or returns
719
+ * any non-public address.
720
+ * @internal
721
+ */
722
+ async function lookupPublicAddresses(hostname, lookup) {
686
723
  let addresses;
687
724
  try {
688
- addresses = await node_dns_promises.default.lookup(hostname, { all: true });
725
+ addresses = await lookup(hostname);
689
726
  } catch (error) {
690
727
  throw new UrlError("DNS lookup failed", {
691
728
  cause: error,
@@ -693,6 +730,21 @@ async function validatePublicUrl(url) {
693
730
  });
694
731
  }
695
732
  validateLookupAddresses(addresses);
733
+ return addresses.filter(({ address }) => (0, node_net.isIP)(address) !== 0).map(({ address }) => ({
734
+ address,
735
+ family: (0, node_net.isIP)(address)
736
+ }));
737
+ }
738
+ /**
739
+ * Checks that the given IP address is public. This is used right before
740
+ * connecting to an address that passed {@link lookupPublicAddresses}.
741
+ * @throws {UrlError} If the address is not a public IP address.
742
+ * @internal
743
+ */
744
+ function assertPublicAddress(address) {
745
+ const family = (0, node_net.isIP)(address.address);
746
+ if (family === 0 || family !== address.family) throw new UrlError(`Invalid or private address: ${address.address}`);
747
+ validatePublicIpAddress(address.address, family);
696
748
  }
697
749
  /**
698
750
  * Validates the IP addresses returned by `node:dns.lookup()`.
@@ -876,6 +928,12 @@ Object.defineProperty(exports, "arePortableUrisEqual", {
876
928
  return arePortableUrisEqual;
877
929
  }
878
930
  });
931
+ Object.defineProperty(exports, "assertPublicAddress", {
932
+ enumerable: true,
933
+ get: function() {
934
+ return assertPublicAddress;
935
+ }
936
+ });
879
937
  Object.defineProperty(exports, "canonicalizePortableUri", {
880
938
  enumerable: true,
881
939
  get: function() {
@@ -942,6 +1000,18 @@ Object.defineProperty(exports, "isValidPublicIPv6Address", {
942
1000
  return isValidPublicIPv6Address;
943
1001
  }
944
1002
  });
1003
+ Object.defineProperty(exports, "lookupAllAddresses", {
1004
+ enumerable: true,
1005
+ get: function() {
1006
+ return lookupAllAddresses;
1007
+ }
1008
+ });
1009
+ Object.defineProperty(exports, "lookupPublicAddresses", {
1010
+ enumerable: true,
1011
+ get: function() {
1012
+ return lookupPublicAddresses;
1013
+ }
1014
+ });
945
1015
  Object.defineProperty(exports, "parseGatewayOrigin", {
946
1016
  enumerable: true,
947
1017
  get: function() {
@@ -966,6 +1036,12 @@ Object.defineProperty(exports, "parseJsonLdId", {
966
1036
  return parseJsonLdId;
967
1037
  }
968
1038
  });
1039
+ Object.defineProperty(exports, "parsePublicUrl", {
1040
+ enumerable: true,
1041
+ get: function() {
1042
+ return parsePublicUrl;
1043
+ }
1044
+ });
969
1045
  Object.defineProperty(exports, "toCompatibleEf61Id", {
970
1046
  enumerable: true,
971
1047
  get: function() {
@@ -662,28 +662,65 @@ function replaceGatewayHints(parts, hints) {
662
662
  }
663
663
  /**
664
664
  * Validates a URL to prevent SSRF attacks.
665
+ *
666
+ * This function only checks the addresses that the hostname currently
667
+ * resolves to; it does not pin the connection to those addresses. A request
668
+ * made after this check resolves the hostname again, so this function alone
669
+ * does not prevent DNS rebinding.
665
670
  */
666
671
  async function validatePublicUrl(url) {
667
- const parsed = new URL(url);
668
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new UrlError(`Unsupported protocol: ${parsed.protocol}`);
669
- let hostname = parsed.hostname;
670
- if (hostname.startsWith("[") && hostname.endsWith("]")) hostname = hostname.slice(1, -1);
671
- if (hostname === "localhost") throw new UrlError("Localhost is not allowed");
672
- const hostnameFamily = isIP(hostname);
673
- if (hostnameFamily !== 0) {
674
- validatePublicIpAddress(hostname, hostnameFamily);
675
- return;
676
- }
677
- if ("Deno" in globalThis && !isIP(hostname)) {
672
+ const { hostname, family } = parsePublicUrl(url);
673
+ if (family !== 0) return;
674
+ if ("Deno" in globalThis) {
678
675
  if ((await Deno.permissions.query({ name: "net" })).state !== "granted") return;
679
676
  }
680
677
  if ("Bun" in globalThis) {
681
678
  if (hostname === "example.com" || hostname.endsWith(".example.com")) return;
682
679
  else if (hostname === "fedify-test.internal") throw new UrlError("Invalid or private address: fedify-test.internal");
683
680
  }
681
+ await lookupPublicAddresses(hostname, lookupAllAddresses);
682
+ }
683
+ /**
684
+ * Resolves all addresses of the given hostname through `node:dns/promises`.
685
+ * The default `dns` object is looked up on every call so that tests can still
686
+ * stub `dns.lookup()`.
687
+ * @internal
688
+ */
689
+ function lookupAllAddresses(hostname) {
690
+ return dns.lookup(hostname, { all: true });
691
+ }
692
+ /**
693
+ * Checks the parts of a URL that can be validated without resolving it:
694
+ * the protocol, the `localhost` hostname, and IP literal hostnames.
695
+ * @throws {UrlError} If the URL is not allowed.
696
+ * @internal
697
+ */
698
+ function parsePublicUrl(url) {
699
+ const parsed = new URL(url);
700
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new UrlError(`Unsupported protocol: ${parsed.protocol}`);
701
+ let hostname = parsed.hostname;
702
+ if (hostname.startsWith("[") && hostname.endsWith("]")) hostname = hostname.slice(1, -1);
703
+ if (hostname === "localhost") throw new UrlError("Localhost is not allowed");
704
+ const family = isIP(hostname);
705
+ if (family !== 0) validatePublicIpAddress(hostname, family);
706
+ return {
707
+ url: parsed,
708
+ hostname,
709
+ family
710
+ };
711
+ }
712
+ /**
713
+ * Resolves the given hostname and checks that every returned IP address is
714
+ * public.
715
+ * @returns The IP addresses of the hostname, without non-IP entries.
716
+ * @throws {UrlError} If the lookup fails, returns no IP address, or returns
717
+ * any non-public address.
718
+ * @internal
719
+ */
720
+ async function lookupPublicAddresses(hostname, lookup) {
684
721
  let addresses;
685
722
  try {
686
- addresses = await dns.lookup(hostname, { all: true });
723
+ addresses = await lookup(hostname);
687
724
  } catch (error) {
688
725
  throw new UrlError("DNS lookup failed", {
689
726
  cause: error,
@@ -691,6 +728,21 @@ async function validatePublicUrl(url) {
691
728
  });
692
729
  }
693
730
  validateLookupAddresses(addresses);
731
+ return addresses.filter(({ address }) => isIP(address) !== 0).map(({ address }) => ({
732
+ address,
733
+ family: isIP(address)
734
+ }));
735
+ }
736
+ /**
737
+ * Checks that the given IP address is public. This is used right before
738
+ * connecting to an address that passed {@link lookupPublicAddresses}.
739
+ * @throws {UrlError} If the address is not a public IP address.
740
+ * @internal
741
+ */
742
+ function assertPublicAddress(address) {
743
+ const family = isIP(address.address);
744
+ if (family === 0 || family !== address.family) throw new UrlError(`Invalid or private address: ${address.address}`);
745
+ validatePublicIpAddress(address.address, family);
694
746
  }
695
747
  /**
696
748
  * Validates the IP addresses returned by `node:dns.lookup()`.
@@ -856,4 +908,4 @@ function matchesIPv6Prefix(address, prefixWords, prefixLength) {
856
908
  return true;
857
909
  }
858
910
  //#endregion
859
- export { withoutGatewayHints as C, withGatewayHints as S, parseIri as _, expandIPv6Address as a, validateLookupAddresses as b, getFe34Origin as c, haveSameIriOrigin as d, isGatewayUrl as f, parseGatewayUrl as g, parseGatewayOrigin as h, canonicalizePortableUri as i, getGatewayHints as l, isValidPublicIPv6Address as m, UrlError as n, formatIri as o, isValidPublicIPv4Address as p, arePortableUrisEqual as r, fromCompatibleEf61Id as s, GATEWAY_HINT_PARAMETER as t, haveSameFe34Origin as u, parseJsonLdId as v, validatePublicUrl as x, toCompatibleEf61Id as y };
911
+ export { toCompatibleEf61Id as C, withoutGatewayHints as D, withGatewayHints as E, parsePublicUrl as S, validatePublicUrl as T, lookupPublicAddresses as _, canonicalizePortableUri as a, parseIri as b, fromCompatibleEf61Id as c, haveSameFe34Origin as d, haveSameIriOrigin as f, lookupAllAddresses as g, isValidPublicIPv6Address as h, assertPublicAddress as i, getFe34Origin as l, isValidPublicIPv4Address as m, UrlError as n, expandIPv6Address as o, isGatewayUrl as p, arePortableUrisEqual as r, formatIri as s, GATEWAY_HINT_PARAMETER as t, getGatewayHints as u, parseGatewayOrigin as v, validateLookupAddresses as w, parseJsonLdId as x, parseGatewayUrl as y };
@@ -1,4 +1,4 @@
1
- const require_url = require("./url-BNakuZ8k.cjs");
1
+ const require_url = require("./url-Bdj-rTcC.cjs");
2
2
  let node_test = require("node:test");
3
3
  let node_assert = require("node:assert");
4
4
  //#region src/url.test.ts
@@ -597,6 +597,9 @@ let node_assert = require("node:assert");
597
597
  await require_url.validatePublicUrl("https://[2001:db8::1]");
598
598
  await require_url.validatePublicUrl("https://[64:ff9b::8.8.8.8]");
599
599
  });
600
+ (0, node_test.test)("validatePublicUrl() keeps rejecting fedify-test.internal on Bun", { skip: !("Bun" in globalThis) }, async () => {
601
+ await (0, node_assert.rejects)(() => require_url.validatePublicUrl("https://fedify-test.internal/"), (error) => error instanceof require_url.UrlError && error.reason === "disallowed");
602
+ });
600
603
  (0, node_test.test)("validateLookupAddresses() tolerates Cloudflare Workers CNAME entries", () => {
601
604
  require_url.validateLookupAddresses([
602
605
  {
@@ -1,4 +1,4 @@
1
- import { C as withoutGatewayHints, S as withGatewayHints, _ as parseIri, a as expandIPv6Address, b as validateLookupAddresses, c as getFe34Origin, d as haveSameIriOrigin, f as isGatewayUrl, g as parseGatewayUrl, i as canonicalizePortableUri, l as getGatewayHints, m as isValidPublicIPv6Address, n as UrlError, o as formatIri, p as isValidPublicIPv4Address, r as arePortableUrisEqual, s as fromCompatibleEf61Id, u as haveSameFe34Origin, v as parseJsonLdId, x as validatePublicUrl, y as toCompatibleEf61Id } from "./url-DMxmp7ZG.mjs";
1
+ import { C as toCompatibleEf61Id, D as withoutGatewayHints, E as withGatewayHints, T as validatePublicUrl, a as canonicalizePortableUri, b as parseIri, c as fromCompatibleEf61Id, d as haveSameFe34Origin, f as haveSameIriOrigin, h as isValidPublicIPv6Address, l as getFe34Origin, m as isValidPublicIPv4Address, n as UrlError, o as expandIPv6Address, p as isGatewayUrl, r as arePortableUrisEqual, s as formatIri, u as getGatewayHints, w as validateLookupAddresses, x as parseJsonLdId, y as parseGatewayUrl } from "./url-COj5Q8mb.mjs";
2
2
  import { test } from "node:test";
3
3
  import { deepStrictEqual, ok, rejects, strictEqual, throws } from "node:assert";
4
4
  //#region src/url.test.ts
@@ -597,6 +597,9 @@ test("validatePublicUrl()", async () => {
597
597
  await validatePublicUrl("https://[2001:db8::1]");
598
598
  await validatePublicUrl("https://[64:ff9b::8.8.8.8]");
599
599
  });
600
+ test("validatePublicUrl() keeps rejecting fedify-test.internal on Bun", { skip: !("Bun" in globalThis) }, async () => {
601
+ await rejects(() => validatePublicUrl("https://fedify-test.internal/"), (error) => error instanceof UrlError && error.reason === "disallowed");
602
+ });
600
603
  test("validateLookupAddresses() tolerates Cloudflare Workers CNAME entries", () => {
601
604
  validateLookupAddresses([
602
605
  {
@@ -0,0 +1,352 @@
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
+ * Parses a JSON-LD `@id` value as an IRI, including FEP-ef61 portable
28
+ * ActivityPub IRIs. See {@link parseIri} for how IRIs are parsed.
29
+ * @param id The `@id` value.
30
+ * @param base The base IRI to resolve a relative `@id` against.
31
+ * @returns The parsed IRI, or `undefined` if `id` is missing or a blank node
32
+ * identifier.
33
+ * @throws {TypeError} If `id` is not a valid IRI.
34
+ * @since 2.4.0
35
+ */
36
+ declare function parseJsonLdId(id: string | undefined, base?: string | URL): URL | undefined;
37
+ /**
38
+ * Parses an IRI as a URL, including FEP-ef61 portable ActivityPub IRIs.
39
+ * Portable URI and FEP-ef61 compatible identifier strings whose path contains
40
+ * a `.` or `..` segment, including percent-encoded spellings, throw a
41
+ * `TypeError`: JavaScript `URL` would otherwise identify a different object.
42
+ * This also applies to compatible identifier strings used as relative bases.
43
+ * A `URL` argument may already have lost such segments before this function
44
+ * receives it.
45
+ *
46
+ * Portable IRIs, e.g., `ap://did:key:z6Mk.../actor`, cannot be represented by
47
+ * JavaScript `URL` as they are, so the returned `URL` keeps the DID authority
48
+ * percent-encoded, e.g., `ap+ef61://did%3Akey%3Az6Mk.../actor`. Use
49
+ * {@link formatIri} to get the canonical string back.
50
+ * @param iri The IRI to parse.
51
+ * @param base The base IRI to resolve a relative IRI against.
52
+ * @returns The parsed IRI.
53
+ * @throws {TypeError} If the IRI is malformed.
54
+ * @since 2.4.0
55
+ */
56
+ declare function parseIri(iri: string | URL, base?: string | URL): URL;
57
+ /**
58
+ * Formats a URL as an IRI, including FEP-ef61 portable ActivityPub IRIs.
59
+ * Portable URI and FEP-ef61 compatible identifier strings with dot segments
60
+ * throw a `TypeError` because their paths cannot be represented by JavaScript
61
+ * `URL` without normalization.
62
+ *
63
+ * Portable IRIs are formatted with the `ap+ef61:` scheme and a decoded DID
64
+ * authority, even if they were parsed from `ap:` IRIs. FEP-ef61 currently
65
+ * recommends the `ap:` scheme, so a future major version of Fedify may change
66
+ * the scheme this function produces. Do not compare its results as strings to
67
+ * tell whether two portable IRIs identify the same object; use
68
+ * `arePortableUrisEqual()` instead.
69
+ * @param iri The IRI to format.
70
+ * @returns The formatted IRI. A string that cannot be parsed as a URL at all
71
+ * is returned unchanged.
72
+ * @throws {TypeError} If the IRI is a malformed portable IRI or FEP-ef61
73
+ * compatible identifier, e.g., one with dot segments.
74
+ * @since 2.4.0
75
+ */
76
+ declare function formatIri(iri: string | URL): string;
77
+ /**
78
+ * Canonicalizes a FEP-ef61 portable ActivityPub URI for comparison.
79
+ *
80
+ * This accepts both `ap:` and `ap+ef61:` URI strings with decoded or
81
+ * percent-encoded DID authorities. The returned value uses the `ap+ef61:`
82
+ * scheme, a decoded DID authority, and no query component. Pass the raw URI
83
+ * string, not a `URL` object, because JavaScript `URL` normalizes opaque path
84
+ * segments before Fedify can compare them.
85
+ *
86
+ * The `ap+ef61:` scheme of the result is a choice of Fedify's, whereas
87
+ * FEP-ef61 currently canonicalizes portable URIs with the `ap:` scheme.
88
+ * A future major version of Fedify may change the scheme of the result, so
89
+ * do not persist the result as the only copy of an identifier; keep the
90
+ * original URI, and canonicalize it again when comparing.
91
+ *
92
+ * @param input The raw portable ActivityPub URI string to canonicalize.
93
+ * @returns The canonical portable ActivityPub URI string.
94
+ * @throws {TypeError} If the input is not a valid portable ActivityPub IRI.
95
+ * @since 2.4.0
96
+ */
97
+ declare function canonicalizePortableUri(input: string): string;
98
+ /**
99
+ * Checks whether two FEP-ef61 portable ActivityPub URIs identify the same
100
+ * portable object.
101
+ *
102
+ * Non-string inputs return `false`. Non-portable URI strings use strict string
103
+ * equality. Portable URI strings are compared through
104
+ * {@link canonicalizePortableUri}; malformed portable URI strings return
105
+ * `false` unless they are exactly equal.
106
+ *
107
+ * @since 2.4.0
108
+ */
109
+ declare function arePortableUrisEqual(left: string, right: string): boolean;
110
+ /**
111
+ * Computes an IRI's FEP-fe34 origin.
112
+ *
113
+ * HTTP(S) IRIs use their web origin. FEP-ef61 portable ActivityPub IRIs and
114
+ * DID URLs use their DID as a cryptographic origin.
115
+ *
116
+ * @throws {TypeError} If the IRI does not have a supported FEP-fe34 origin.
117
+ * @since 2.4.0
118
+ */
119
+ declare function getFe34Origin(input: string | URL): string;
120
+ /**
121
+ * Checks whether two IRIs have the same FEP-fe34 origin.
122
+ *
123
+ * Malformed or unsupported IRIs are treated as non-matching.
124
+ *
125
+ * @since 2.4.0
126
+ */
127
+ declare function haveSameFe34Origin(left: string | URL, right: string | URL): boolean;
128
+ /**
129
+ * Checks whether two IRIs have the same origin. Unlike comparing
130
+ * `URL.origin`, which is `"null"` for URLs with non-special schemes, this
131
+ * compares FEP-ef61 portable ActivityPub IRIs by their schemes and DIDs, so
132
+ * that `ap:` and `ap+ef61:` IRIs of the same DID with decoded or
133
+ * percent-encoded authorities have the same origin. Other IRIs with
134
+ * a non-special scheme and a host are compared by their schemes and hosts.
135
+ *
136
+ * This is not an FEP-fe34 origin check: an `ap+ef61:` IRI and the `did:key`
137
+ * verification method of the same DID have different origins here. Use
138
+ * {@link haveSameFe34Origin} for that.
139
+ * @param left The first IRI.
140
+ * @param right The second IRI.
141
+ * @returns `true` if the IRIs have the same origin.
142
+ * @since 2.4.0
143
+ */
144
+ declare function haveSameIriOrigin(left: URL, right: URL): boolean;
145
+ /**
146
+ * Checks whether the URL is an FEP-ef61 gateway base URI, i.e., an HTTP(S)
147
+ * URI with no credentials, path, query, or fragment, such as
148
+ * `https://example.com/`. FEP-ef61 requires every item of a portable actor's
149
+ * `gateways` to be such a URI. A URI with an empty query or fragment
150
+ * delimiter, such as `https://example.com/?`, is not a gateway base URI.
151
+ *
152
+ * Note that the `URL` class normalizes `https://example.com` to
153
+ * `https://example.com/`, so both are gateway base URIs.
154
+ * @param url The URL to check.
155
+ * @returns `true` if the URL is an FEP-ef61 gateway base URI.
156
+ * @since 2.4.0
157
+ */
158
+ declare function isGatewayUrl(url: URL): boolean;
159
+ /**
160
+ * Parses and validates an FEP-ef61 gateway base URI, i.e., an HTTP(S) URI with
161
+ * no credentials, path, query, or fragment, such as `https://example.com`.
162
+ * See {@link isGatewayUrl} for the rules.
163
+ * @param url The gateway base URI to parse.
164
+ * @returns The parsed gateway base URI, whose path is `/`.
165
+ * @throws {TypeError} If the URI is malformed, or is not an HTTP(S) base URI
166
+ * with no credentials, path, query, or fragment. In the
167
+ * latter case, the message starts with
168
+ * `Invalid FEP-ef61 gateway:`.
169
+ * @since 2.4.0
170
+ */
171
+ declare function parseGatewayUrl(url: string): URL;
172
+ /**
173
+ * Converts an [FEP-ef61] compatible identifier into a portable ActivityPub
174
+ * URI.
175
+ *
176
+ * A compatible identifier is an HTTP(S) URL under a gateway's fixed
177
+ * `/.well-known/apgateway/` path, such as
178
+ * `https://server.example/.well-known/apgateway/did:key:z6Mk.../objects/1`.
179
+ * This function removes the gateway part and returns the corresponding
180
+ * portable URI, e.g., `ap+ef61://did:key:z6Mk.../objects/1`, in the same
181
+ * internal `URL` form that {@link parseIri} produces. The path, query, and
182
+ * fragment are preserved, so the result is a portable URI, not a comparison
183
+ * form; pass its `href` to {@link canonicalizePortableUri} or
184
+ * {@link arePortableUrisEqual} to compare it with other portable URIs.
185
+ *
186
+ * The conversion only reveals the *claimed* portable identifier. Anyone can
187
+ * publish a compatible identifier for any DID on their own server, so the
188
+ * gateway that served it is neither the object's origin nor authorized to act
189
+ * for the DID. Callers must still verify the retrieved document's Object
190
+ * Integrity Proof against the DID, as FEP-ef61 requires, and should keep the
191
+ * original URL if they need the gateway as a retrieval hint.
192
+ *
193
+ * Arbitrary gateway paths are not supported.
194
+ *
195
+ * [FEP-ef61]: https://w3id.org/fep/ef61
196
+ *
197
+ * @param input The URL to convert.
198
+ * @returns The portable ActivityPub URI, or `null` if the input is not an
199
+ * HTTP(S) URL whose path starts with `/.well-known/apgateway/did:`.
200
+ * Other gateway routes, such as the gateway discovery endpoint and
201
+ * hashlink media URLs, also yield `null`.
202
+ * @throws {TypeError} If the input looks like a compatible identifier but is
203
+ * malformed, e.g., it has an invalid DID, no object path,
204
+ * invalid percent-encoding, credentials, or location
205
+ * hints (`@gateway` query parameters, or the legacy
206
+ * `gateways` parameter), or its raw string path would
207
+ * change during URL parsing. Already-parsed `URL`
208
+ * arguments cannot reveal segments lost by their parser.
209
+ * @since 2.4.0
210
+ */
211
+ declare function fromCompatibleEf61Id(input: string | URL): URL | null;
212
+ /**
213
+ * Converts a portable ActivityPub URI into an [FEP-ef61] compatible
214
+ * identifier, which is an HTTP(S) URL under the gateway's fixed
215
+ * `/.well-known/apgateway/` path.
216
+ *
217
+ * For example, `ap+ef61://did:key:z6Mk.../objects/1` and
218
+ * `https://server.example` yield
219
+ * `https://server.example/.well-known/apgateway/did:key:z6Mk.../objects/1`.
220
+ * Both `ap:` and `ap+ef61:` URIs with decoded or percent-encoded DID
221
+ * authorities are accepted. Publishers should use the first gateway in the
222
+ * actor's `gateways` list, as FEP-ef61 requires.
223
+ *
224
+ * The path and fragment are preserved, with characters that are not allowed
225
+ * in HTTP(S) URLs percent-encoded the same way as
226
+ * {@link canonicalizePortableUri} does. FEP-ef61 location hints (`@gateway`
227
+ * query parameters, and the legacy `gateways` parameter) are removed, since
228
+ * compatible identifiers must not have them; other query parameters are kept
229
+ * in order.
230
+ *
231
+ * Arbitrary gateway paths are not supported.
232
+ *
233
+ * [FEP-ef61]: https://w3id.org/fep/ef61
234
+ *
235
+ * @param portableId The `ap:` or `ap+ef61:` URI to convert.
236
+ * @param gateway The gateway's HTTP(S) origin, e.g., `https://server.example`.
237
+ * @returns The compatible identifier.
238
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
239
+ * URI, if its path has `.` or `..` segments (which
240
+ * HTTP(S) URLs cannot represent without changing the
241
+ * identified object), or if the gateway is
242
+ * not an HTTP(S) origin with no credentials, path, query,
243
+ * or fragment.
244
+ * @since 2.4.0
245
+ */
246
+ declare function toCompatibleEf61Id(portableId: string | URL, gateway: string | URL): URL;
247
+ /**
248
+ * Returns a copy of an [FEP-ef61] portable ActivityPub URI with `@gateway`
249
+ * location hints for the given gateways, which tell consumers where they can
250
+ * retrieve the object. Put hints on *references* to portable actors, e.g.,
251
+ * in `actor`, `attributedTo`, `to`, or `cc`, when constructing an object, as
252
+ * FEP-ef61 recommends:
253
+ *
254
+ * ~~~~ typescript
255
+ * withGatewayHints("ap://did:key:z6Mk.../actor", [
256
+ * "https://server1.example",
257
+ * "https://server2.example",
258
+ * ]);
259
+ * // ap+ef61://did:key:z6Mk.../actor?@gateway=https%3A%2F%2Fserver1.example&@gateway=https%3A%2F%2Fserver2.example
260
+ * ~~~~
261
+ *
262
+ * Do not put hints on an object's own `id`. Hints do not change the
263
+ * identity of a portable URI, since FEP-ef61 drops the query when comparing
264
+ * portable URIs, but implementations that do not canonicalize portable URIs
265
+ * would take a hinted ID for another object. Add hints before signing the
266
+ * object, since its Object Integrity Proof covers its references too.
267
+ *
268
+ * The hints that the URI already has, including the legacy `gateways`
269
+ * parameter, are replaced. Each gateway becomes a `@gateway` query
270
+ * parameter whose value is its URI-encoded origin, e.g.,
271
+ * `@gateway=https%3A%2F%2Fserver1.example`, in the given order after the
272
+ * other query parameters. Duplicate gateways are dropped, and an empty list
273
+ * removes the hints as {@link withoutGatewayHints} does. The other query
274
+ * parameters, their order, and the fragment are kept, but percent-encoding
275
+ * is normalized the same way as {@link canonicalizePortableUri} does it.
276
+ *
277
+ * Fedify follows at most five hints when dereferencing a portable URI
278
+ * (three for the key ID of an HTTP Signature), so list the preferred
279
+ * gateways first; there is no limit on the number of hints added here.
280
+ *
281
+ * [FEP-ef61]: https://w3id.org/fep/ef61
282
+ *
283
+ * @param portableId The `ap:` or `ap+ef61:` URI. Pass the raw string rather
284
+ * than a `URL` if its path may have `.` or `..` segments,
285
+ * because the `URL` class resolves them.
286
+ * @param gateways The gateways, e.g., the `gateways` of the actor that the
287
+ * URI refers to. Each has to be an HTTP(S) origin with no
288
+ * credentials, path, query, or fragment.
289
+ * @returns The portable URI with the hints, in the same internal `URL` form
290
+ * as {@link parseIri} returns. Use {@link formatIri} to get its
291
+ * canonical string.
292
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
293
+ * URI, e.g., it is a compatible identifier, which must
294
+ * not have location hints; if its path has `.` or `..`
295
+ * segments, which the `URL` class cannot represent; or
296
+ * if a gateway is invalid.
297
+ * @since 2.4.0
298
+ */
299
+ declare function withGatewayHints(portableId: string | URL, gateways: Iterable<string | URL>): URL;
300
+ /**
301
+ * Returns a copy of an [FEP-ef61] portable ActivityPub URI without its
302
+ * location hints, i.e., `@gateway` query parameters and the legacy
303
+ * `gateways` parameter. The other query parameters, their order, and the
304
+ * fragment are kept, but percent-encoding is normalized the same way as
305
+ * {@link canonicalizePortableUri} does it.
306
+ *
307
+ * [FEP-ef61]: https://w3id.org/fep/ef61
308
+ *
309
+ * @param portableId The `ap:` or `ap+ef61:` URI. Pass the raw string rather
310
+ * than a `URL` if its path may have `.` or `..` segments,
311
+ * because the `URL` class resolves them.
312
+ * @returns The portable URI without the hints, in the same internal `URL`
313
+ * form as {@link parseIri} returns.
314
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
315
+ * URI, or if its path has `.` or `..` segments, which
316
+ * the `URL` class cannot represent.
317
+ * @since 2.4.0
318
+ */
319
+ declare function withoutGatewayHints(portableId: string | URL): URL;
320
+ /**
321
+ * Gets the gateways in the `@gateway` location hints of an [FEP-ef61]
322
+ * portable ActivityPub URI, in order. Hints that are not valid gateways,
323
+ * i.e., HTTP(S) origins with no credentials, path, query, or fragment, are
324
+ * skipped, and so are duplicates. The legacy `gateways` parameter is not
325
+ * read.
326
+ *
327
+ * Unlike Fedify's dereferencing, which follows at most five hints, this
328
+ * returns all of them.
329
+ *
330
+ * [FEP-ef61]: https://w3id.org/fep/ef61
331
+ *
332
+ * @param portableId The `ap:` or `ap+ef61:` URI.
333
+ * @returns The gateways, e.g., `https://server1.example/`.
334
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
335
+ * URI.
336
+ * @since 2.4.0
337
+ */
338
+ declare function getGatewayHints(portableId: string | URL): URL[];
339
+ /**
340
+ * Validates a URL to prevent SSRF attacks.
341
+ *
342
+ * This function only checks the addresses that the hostname currently
343
+ * resolves to; it does not pin the connection to those addresses. A request
344
+ * made after this check resolves the hostname again, so this function alone
345
+ * does not prevent DNS rebinding.
346
+ */
347
+ declare function validatePublicUrl(url: string): Promise<void>;
348
+ declare function isValidPublicIPv4Address(address: string): boolean;
349
+ declare function isValidPublicIPv6Address(address: string): boolean;
350
+ declare function expandIPv6Address(address: string): string;
351
+ //#endregion
352
+ export { parseJsonLdId as _, expandIPv6Address as a, withGatewayHints as b, getFe34Origin as c, haveSameIriOrigin as d, isGatewayUrl as f, parseIri as g, parseGatewayUrl as h, canonicalizePortableUri as i, getGatewayHints as l, isValidPublicIPv6Address as m, UrlError as n, formatIri as o, isValidPublicIPv4Address as p, arePortableUrisEqual as r, fromCompatibleEf61Id as s, ResolvedAddress as t, haveSameFe34Origin as u, toCompatibleEf61Id as v, withoutGatewayHints as x, validatePublicUrl as y };