@fedify/vocab-runtime 2.3.2-dev.1556 → 2.3.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab-runtime",
3
- "version": "2.3.2-dev.1556+3e14f2d7",
3
+ "version": "2.3.3",
4
4
  "license": "MIT",
5
5
  "exports": {
6
6
  ".": "./src/mod.ts",
package/dist/mod.cjs CHANGED
@@ -4346,7 +4346,7 @@ const preloadedContexts = {
4346
4346
  //#endregion
4347
4347
  //#region deno.json
4348
4348
  var name = "@fedify/vocab-runtime";
4349
- var version = "2.3.2-dev.1556+3e14f2d7";
4349
+ var version = "2.3.3";
4350
4350
  //#endregion
4351
4351
  //#region src/link.ts
4352
4352
  const parametersNeedLowerCase = ["rel", "type"];
@@ -4601,8 +4601,8 @@ function logRequest(logger, request) {
4601
4601
  //#endregion
4602
4602
  //#region src/url.ts
4603
4603
  var UrlError = class extends Error {
4604
- constructor(message) {
4605
- super(message);
4604
+ constructor(message, options) {
4605
+ super(message, options);
4606
4606
  this.name = "UrlError";
4607
4607
  }
4608
4608
  };
@@ -4630,10 +4630,37 @@ async function validatePublicUrl(url) {
4630
4630
  let addresses;
4631
4631
  try {
4632
4632
  addresses = await (0, node_dns_promises.lookup)(hostname, { all: true });
4633
- } catch {
4634
- addresses = [];
4633
+ } catch (error) {
4634
+ throw new UrlError("DNS lookup failed", { cause: error });
4635
4635
  }
4636
- for (const { address, family } of addresses) validatePublicIpAddress(address, family);
4636
+ validateLookupAddresses(addresses);
4637
+ }
4638
+ /**
4639
+ * Validates the IP addresses returned by `node:dns.lookup()`.
4640
+ *
4641
+ * Cloudflare Workers' `node:dns` implementation currently maps every record
4642
+ * in a DNS-over-HTTPS `Answer` array—including CNAME records—to a
4643
+ * `LookupAddress`, even though Node.js specifies that the `address` field must
4644
+ * contain an IPv4 or IPv6 literal. See:
4645
+ * https://github.com/cloudflare/workerd/issues/6886
4646
+ *
4647
+ * Work around that bug by ignoring non-IP entries only when the lookup also
4648
+ * returns at least one actual IP address. This remains fail-closed: a result
4649
+ * containing no IP addresses is rejected, and every returned IP address is
4650
+ * still validated and must be public. This workaround can be revisited once
4651
+ * the Workerd issue is fixed in supported Cloudflare Workers runtimes.
4652
+ *
4653
+ * @internal
4654
+ */
4655
+ function validateLookupAddresses(addresses) {
4656
+ let ipAddressCount = 0;
4657
+ for (const { address } of addresses) {
4658
+ const family = (0, node_net.isIP)(address);
4659
+ if (family === 0) continue;
4660
+ ipAddressCount++;
4661
+ validatePublicIpAddress(address, family);
4662
+ }
4663
+ if (ipAddressCount === 0) throw new UrlError("DNS lookup did not return any IP address");
4637
4664
  }
4638
4665
  function validatePublicIpAddress(address, family) {
4639
4666
  if (family === 4 && isValidPublicIPv4Address(address) || family === 6 && isValidPublicIPv6Address(address)) return;
package/dist/mod.d.cts CHANGED
@@ -458,7 +458,9 @@ type PropertyPreprocessor<T = unknown> = (value: Json, context: PropertyPreproce
458
458
  //#endregion
459
459
  //#region src/url.d.ts
460
460
  declare class UrlError extends Error {
461
- constructor(message: string);
461
+ constructor(message: string, options?: {
462
+ cause?: unknown;
463
+ });
462
464
  }
463
465
  /**
464
466
  * Validates a URL to prevent SSRF attacks.
package/dist/mod.d.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  /// <reference lib="esnext.temporal" />
2
2
  import { Logger } from "@logtape/logtape";
3
3
  import { TracerProvider } from "@opentelemetry/api";
4
-
5
4
  //#region src/contexts.d.ts
6
5
  declare const preloadedContexts: Record<string, unknown>;
7
6
  //#endregion
@@ -458,7 +457,9 @@ type PropertyPreprocessor<T = unknown> = (value: Json, context: PropertyPreproce
458
457
  //#endregion
459
458
  //#region src/url.d.ts
460
459
  declare class UrlError extends Error {
461
- constructor(message: string);
460
+ constructor(message: string, options?: {
461
+ cause?: unknown;
462
+ });
462
463
  }
463
464
  /**
464
465
  * Validates a URL to prevent SSRF attacks.
package/dist/mod.js CHANGED
@@ -4342,7 +4342,7 @@ const preloadedContexts = {
4342
4342
  //#endregion
4343
4343
  //#region deno.json
4344
4344
  var name = "@fedify/vocab-runtime";
4345
- var version = "2.3.2-dev.1556+3e14f2d7";
4345
+ var version = "2.3.3";
4346
4346
  //#endregion
4347
4347
  //#region src/link.ts
4348
4348
  const parametersNeedLowerCase = ["rel", "type"];
@@ -4597,8 +4597,8 @@ function logRequest(logger, request) {
4597
4597
  //#endregion
4598
4598
  //#region src/url.ts
4599
4599
  var UrlError = class extends Error {
4600
- constructor(message) {
4601
- super(message);
4600
+ constructor(message, options) {
4601
+ super(message, options);
4602
4602
  this.name = "UrlError";
4603
4603
  }
4604
4604
  };
@@ -4626,10 +4626,37 @@ async function validatePublicUrl(url) {
4626
4626
  let addresses;
4627
4627
  try {
4628
4628
  addresses = await lookup(hostname, { all: true });
4629
- } catch {
4630
- addresses = [];
4629
+ } catch (error) {
4630
+ throw new UrlError("DNS lookup failed", { cause: error });
4631
4631
  }
4632
- for (const { address, family } of addresses) validatePublicIpAddress(address, family);
4632
+ validateLookupAddresses(addresses);
4633
+ }
4634
+ /**
4635
+ * Validates the IP addresses returned by `node:dns.lookup()`.
4636
+ *
4637
+ * Cloudflare Workers' `node:dns` implementation currently maps every record
4638
+ * in a DNS-over-HTTPS `Answer` array—including CNAME records—to a
4639
+ * `LookupAddress`, even though Node.js specifies that the `address` field must
4640
+ * contain an IPv4 or IPv6 literal. See:
4641
+ * https://github.com/cloudflare/workerd/issues/6886
4642
+ *
4643
+ * Work around that bug by ignoring non-IP entries only when the lookup also
4644
+ * returns at least one actual IP address. This remains fail-closed: a result
4645
+ * containing no IP addresses is rejected, and every returned IP address is
4646
+ * still validated and must be public. This workaround can be revisited once
4647
+ * the Workerd issue is fixed in supported Cloudflare Workers runtimes.
4648
+ *
4649
+ * @internal
4650
+ */
4651
+ function validateLookupAddresses(addresses) {
4652
+ let ipAddressCount = 0;
4653
+ for (const { address } of addresses) {
4654
+ const family = isIP(address);
4655
+ if (family === 0) continue;
4656
+ ipAddressCount++;
4657
+ validatePublicIpAddress(address, family);
4658
+ }
4659
+ if (ipAddressCount === 0) throw new UrlError("DNS lookup did not return any IP address");
4633
4660
  }
4634
4661
  function validatePublicIpAddress(address, family) {
4635
4662
  if (family === 4 && isValidPublicIPv4Address(address) || family === 6 && isValidPublicIPv6Address(address)) return;
@@ -1,6 +1,6 @@
1
1
  require("./chunk-C2EiDwsr.cjs");
2
- require("./docloader-dOzCqf3N.cjs");
3
- require("./url-C20FhC7p.cjs");
2
+ require("./docloader-DWIisNvg.cjs");
3
+ require("./url-BF_Nb_Cu.cjs");
4
4
  require("./key-pMmqUKuo.cjs");
5
5
  require("./multibase-Bz_UUDtL.cjs");
6
6
  require("./langstr-CbAxaeEZ.cjs");
@@ -1,5 +1,5 @@
1
- import "./docloader-CXChKvdE.mjs";
2
- import "./url-m9Qzxy-Y.mjs";
1
+ import "./docloader-DtqV7PF2.mjs";
2
+ import "./url-o0e-aXQY.mjs";
3
3
  import "./key-CrrK9mYh.mjs";
4
4
  import "./multibase-B4bvakyA.mjs";
5
5
  import "./langstr-Di5AvKpB.mjs";
@@ -1,7 +1,7 @@
1
1
  require("./chunk-C2EiDwsr.cjs");
2
- const require_request = require("./request-CiXkZkTU.cjs");
2
+ const require_request = require("./request-Bvqy-9Kl.cjs");
3
3
  const require_link = require("./link-FguCydMA.cjs");
4
- const require_url = require("./url-C20FhC7p.cjs");
4
+ const require_url = require("./url-BF_Nb_Cu.cjs");
5
5
  let _logtape_logtape = require("@logtape/logtape");
6
6
  let _opentelemetry_api = require("@opentelemetry/api");
7
7
  //#endregion
@@ -1,6 +1,6 @@
1
- import { a as name, i as logRequest, n as createActivityPubRequest, o as version, t as FetchError } from "./request-B_w4OTut.mjs";
1
+ import { a as name, i as logRequest, n as createActivityPubRequest, o as version, t as FetchError } from "./request-DIjLcXKG.mjs";
2
2
  import { t as HttpHeaderLink } from "./link-NUUWCdnK.mjs";
3
- import { a as validatePublicUrl, t as UrlError } from "./url-m9Qzxy-Y.mjs";
3
+ import { o as validatePublicUrl, t as UrlError } from "./url-o0e-aXQY.mjs";
4
4
  import { getLogger } from "@logtape/logtape";
5
5
  import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
6
6
  //#endregion
@@ -1,7 +1,7 @@
1
1
  const require_chunk = require("./chunk-C2EiDwsr.cjs");
2
- const require_docloader = require("./docloader-dOzCqf3N.cjs");
3
- const require_request = require("./request-CiXkZkTU.cjs");
4
- const require_url = require("./url-C20FhC7p.cjs");
2
+ const require_docloader = require("./docloader-DWIisNvg.cjs");
3
+ const require_request = require("./request-Bvqy-9Kl.cjs");
4
+ const require_url = require("./url-BF_Nb_Cu.cjs");
5
5
  let node_assert = require("node:assert");
6
6
  let node_test = require("node:test");
7
7
  //#region ../../node_modules/.pnpm/glob-to-regexp@0.4.1/node_modules/glob-to-regexp/index.js
@@ -1,6 +1,6 @@
1
- import { n as preloadedContexts, t as getDocumentLoader } from "./docloader-CXChKvdE.mjs";
2
- import { t as FetchError } from "./request-B_w4OTut.mjs";
3
- import { t as UrlError } from "./url-m9Qzxy-Y.mjs";
1
+ import { n as preloadedContexts, t as getDocumentLoader } from "./docloader-DtqV7PF2.mjs";
2
+ import { t as FetchError } from "./request-DIjLcXKG.mjs";
3
+ import { t as UrlError } from "./url-o0e-aXQY.mjs";
4
4
  import { deepStrictEqual, ok, rejects } from "node:assert";
5
5
  import { test } from "node:test";
6
6
  //#region \0rolldown/runtime.js
@@ -3,7 +3,7 @@ let node_process = require("node:process");
3
3
  node_process = require_chunk.__toESM(node_process, 1);
4
4
  //#region deno.json
5
5
  var name = "@fedify/vocab-runtime";
6
- var version = "2.3.2-dev.1556+3e14f2d7";
6
+ var version = "2.3.3";
7
7
  //#endregion
8
8
  //#region src/request.ts
9
9
  /**
@@ -1,7 +1,7 @@
1
1
  import process from "node:process";
2
2
  //#region deno.json
3
3
  var name = "@fedify/vocab-runtime";
4
- var version = "2.3.2-dev.1556+3e14f2d7";
4
+ var version = "2.3.3";
5
5
  //#endregion
6
6
  //#region src/request.ts
7
7
  /**
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("./chunk-C2EiDwsr.cjs");
2
- const require_request = require("./request-CiXkZkTU.cjs");
2
+ const require_request = require("./request-Bvqy-9Kl.cjs");
3
3
  let node_assert = require("node:assert");
4
4
  let node_test = require("node:test");
5
5
  let node_process = require("node:process");
@@ -1,4 +1,4 @@
1
- import { o as version, r as getUserAgent } from "./request-B_w4OTut.mjs";
1
+ import { o as version, r as getUserAgent } from "./request-DIjLcXKG.mjs";
2
2
  import { deepStrictEqual } from "node:assert";
3
3
  import { test } from "node:test";
4
4
  import process from "node:process";
@@ -3,8 +3,8 @@ let node_dns_promises = require("node:dns/promises");
3
3
  let node_net = require("node:net");
4
4
  //#region src/url.ts
5
5
  var UrlError = class extends Error {
6
- constructor(message) {
7
- super(message);
6
+ constructor(message, options) {
7
+ super(message, options);
8
8
  this.name = "UrlError";
9
9
  }
10
10
  };
@@ -32,10 +32,37 @@ async function validatePublicUrl(url) {
32
32
  let addresses;
33
33
  try {
34
34
  addresses = await (0, node_dns_promises.lookup)(hostname, { all: true });
35
- } catch {
36
- addresses = [];
35
+ } catch (error) {
36
+ throw new UrlError("DNS lookup failed", { cause: error });
37
37
  }
38
- for (const { address, family } of addresses) validatePublicIpAddress(address, family);
38
+ validateLookupAddresses(addresses);
39
+ }
40
+ /**
41
+ * Validates the IP addresses returned by `node:dns.lookup()`.
42
+ *
43
+ * Cloudflare Workers' `node:dns` implementation currently maps every record
44
+ * in a DNS-over-HTTPS `Answer` array—including CNAME records—to a
45
+ * `LookupAddress`, even though Node.js specifies that the `address` field must
46
+ * contain an IPv4 or IPv6 literal. See:
47
+ * https://github.com/cloudflare/workerd/issues/6886
48
+ *
49
+ * Work around that bug by ignoring non-IP entries only when the lookup also
50
+ * returns at least one actual IP address. This remains fail-closed: a result
51
+ * containing no IP addresses is rejected, and every returned IP address is
52
+ * still validated and must be public. This workaround can be revisited once
53
+ * the Workerd issue is fixed in supported Cloudflare Workers runtimes.
54
+ *
55
+ * @internal
56
+ */
57
+ function validateLookupAddresses(addresses) {
58
+ let ipAddressCount = 0;
59
+ for (const { address } of addresses) {
60
+ const family = (0, node_net.isIP)(address);
61
+ if (family === 0) continue;
62
+ ipAddressCount++;
63
+ validatePublicIpAddress(address, family);
64
+ }
65
+ if (ipAddressCount === 0) throw new UrlError("DNS lookup did not return any IP address");
39
66
  }
40
67
  function validatePublicIpAddress(address, family) {
41
68
  if (family === 4 && isValidPublicIPv4Address(address) || family === 6 && isValidPublicIPv6Address(address)) return;
@@ -198,6 +225,12 @@ Object.defineProperty(exports, "isValidPublicIPv6Address", {
198
225
  return isValidPublicIPv6Address;
199
226
  }
200
227
  });
228
+ Object.defineProperty(exports, "validateLookupAddresses", {
229
+ enumerable: true,
230
+ get: function() {
231
+ return validateLookupAddresses;
232
+ }
233
+ });
201
234
  Object.defineProperty(exports, "validatePublicUrl", {
202
235
  enumerable: true,
203
236
  get: function() {
@@ -2,8 +2,8 @@ import { lookup } from "node:dns/promises";
2
2
  import { isIP } from "node:net";
3
3
  //#region src/url.ts
4
4
  var UrlError = class extends Error {
5
- constructor(message) {
6
- super(message);
5
+ constructor(message, options) {
6
+ super(message, options);
7
7
  this.name = "UrlError";
8
8
  }
9
9
  };
@@ -31,10 +31,37 @@ async function validatePublicUrl(url) {
31
31
  let addresses;
32
32
  try {
33
33
  addresses = await lookup(hostname, { all: true });
34
- } catch {
35
- addresses = [];
34
+ } catch (error) {
35
+ throw new UrlError("DNS lookup failed", { cause: error });
36
36
  }
37
- for (const { address, family } of addresses) validatePublicIpAddress(address, family);
37
+ validateLookupAddresses(addresses);
38
+ }
39
+ /**
40
+ * Validates the IP addresses returned by `node:dns.lookup()`.
41
+ *
42
+ * Cloudflare Workers' `node:dns` implementation currently maps every record
43
+ * in a DNS-over-HTTPS `Answer` array—including CNAME records—to a
44
+ * `LookupAddress`, even though Node.js specifies that the `address` field must
45
+ * contain an IPv4 or IPv6 literal. See:
46
+ * https://github.com/cloudflare/workerd/issues/6886
47
+ *
48
+ * Work around that bug by ignoring non-IP entries only when the lookup also
49
+ * returns at least one actual IP address. This remains fail-closed: a result
50
+ * containing no IP addresses is rejected, and every returned IP address is
51
+ * still validated and must be public. This workaround can be revisited once
52
+ * the Workerd issue is fixed in supported Cloudflare Workers runtimes.
53
+ *
54
+ * @internal
55
+ */
56
+ function validateLookupAddresses(addresses) {
57
+ let ipAddressCount = 0;
58
+ for (const { address } of addresses) {
59
+ const family = isIP(address);
60
+ if (family === 0) continue;
61
+ ipAddressCount++;
62
+ validatePublicIpAddress(address, family);
63
+ }
64
+ if (ipAddressCount === 0) throw new UrlError("DNS lookup did not return any IP address");
38
65
  }
39
66
  function validatePublicIpAddress(address, family) {
40
67
  if (family === 4 && isValidPublicIPv4Address(address) || family === 6 && isValidPublicIPv6Address(address)) return;
@@ -173,4 +200,4 @@ function matchesIPv6Prefix(address, prefixWords, prefixLength) {
173
200
  return true;
174
201
  }
175
202
  //#endregion
176
- export { validatePublicUrl as a, isValidPublicIPv6Address as i, expandIPv6Address as n, isValidPublicIPv4Address as r, UrlError as t };
203
+ export { validateLookupAddresses as a, isValidPublicIPv6Address as i, expandIPv6Address as n, validatePublicUrl as o, isValidPublicIPv4Address as r, UrlError as t };
@@ -1,5 +1,5 @@
1
1
  require("./chunk-C2EiDwsr.cjs");
2
- const require_url = require("./url-C20FhC7p.cjs");
2
+ const require_url = require("./url-BF_Nb_Cu.cjs");
3
3
  let node_assert = require("node:assert");
4
4
  let node_test = require("node:test");
5
5
  //#region src/url.test.ts
@@ -29,6 +29,35 @@ let node_test = require("node:test");
29
29
  await require_url.validatePublicUrl("https://[2001:db8::1]");
30
30
  await require_url.validatePublicUrl("https://[64:ff9b::8.8.8.8]");
31
31
  });
32
+ (0, node_test.test)("validateLookupAddresses() tolerates Cloudflare Workers CNAME entries", () => {
33
+ require_url.validateLookupAddresses([
34
+ {
35
+ address: "app-host.example.net.",
36
+ family: 4
37
+ },
38
+ {
39
+ address: "93.184.216.34",
40
+ family: 4
41
+ },
42
+ {
43
+ address: "2606:2800:220:1:248:1893:25c8:1946",
44
+ family: 6
45
+ }
46
+ ]);
47
+ });
48
+ (0, node_test.test)("validateLookupAddresses() rejects unsafe or CNAME-only Cloudflare Workers results", () => {
49
+ (0, node_assert.throws)(() => require_url.validateLookupAddresses([{
50
+ address: "private-host.example.net.",
51
+ family: 4
52
+ }, {
53
+ address: "127.0.0.1",
54
+ family: 4
55
+ }]), require_url.UrlError);
56
+ (0, node_assert.throws)(() => require_url.validateLookupAddresses([{
57
+ address: "app-host.example.net.",
58
+ family: 4
59
+ }]), require_url.UrlError);
60
+ });
32
61
  (0, node_test.test)("isValidPublicIPv4Address()", () => {
33
62
  (0, node_assert.ok)(require_url.isValidPublicIPv4Address("8.8.8.8"));
34
63
  (0, node_assert.ok)(!require_url.isValidPublicIPv4Address("192.168.1.1"));
@@ -1,5 +1,5 @@
1
- import { a as validatePublicUrl, i as isValidPublicIPv6Address, n as expandIPv6Address, r as isValidPublicIPv4Address, t as UrlError } from "./url-m9Qzxy-Y.mjs";
2
- import { deepStrictEqual, ok, rejects } from "node:assert";
1
+ import { a as validateLookupAddresses, i as isValidPublicIPv6Address, n as expandIPv6Address, o as validatePublicUrl, r as isValidPublicIPv4Address, t as UrlError } from "./url-o0e-aXQY.mjs";
2
+ import { deepStrictEqual, ok, rejects, throws } from "node:assert";
3
3
  import { test } from "node:test";
4
4
  //#region src/url.test.ts
5
5
  test("validatePublicUrl()", async () => {
@@ -28,6 +28,35 @@ test("validatePublicUrl()", async () => {
28
28
  await validatePublicUrl("https://[2001:db8::1]");
29
29
  await validatePublicUrl("https://[64:ff9b::8.8.8.8]");
30
30
  });
31
+ test("validateLookupAddresses() tolerates Cloudflare Workers CNAME entries", () => {
32
+ validateLookupAddresses([
33
+ {
34
+ address: "app-host.example.net.",
35
+ family: 4
36
+ },
37
+ {
38
+ address: "93.184.216.34",
39
+ family: 4
40
+ },
41
+ {
42
+ address: "2606:2800:220:1:248:1893:25c8:1946",
43
+ family: 6
44
+ }
45
+ ]);
46
+ });
47
+ test("validateLookupAddresses() rejects unsafe or CNAME-only Cloudflare Workers results", () => {
48
+ throws(() => validateLookupAddresses([{
49
+ address: "private-host.example.net.",
50
+ family: 4
51
+ }, {
52
+ address: "127.0.0.1",
53
+ family: 4
54
+ }]), UrlError);
55
+ throws(() => validateLookupAddresses([{
56
+ address: "app-host.example.net.",
57
+ family: 4
58
+ }]), UrlError);
59
+ });
31
60
  test("isValidPublicIPv4Address()", () => {
32
61
  ok(isValidPublicIPv4Address("8.8.8.8"));
33
62
  ok(!isValidPublicIPv4Address("192.168.1.1"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab-runtime",
3
- "version": "2.3.2-dev.1556+3e14f2d7",
3
+ "version": "2.3.3",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
package/src/url.test.ts CHANGED
@@ -1,10 +1,11 @@
1
- import { deepStrictEqual, ok, rejects } from "node:assert";
1
+ import { deepStrictEqual, ok, rejects, throws } from "node:assert";
2
2
  import { test } from "node:test";
3
3
  import {
4
4
  expandIPv6Address,
5
5
  isValidPublicIPv4Address,
6
6
  isValidPublicIPv6Address,
7
7
  UrlError,
8
+ validateLookupAddresses,
8
9
  validatePublicUrl,
9
10
  } from "./url.ts";
10
11
 
@@ -65,6 +66,32 @@ test("validatePublicUrl()", async () => {
65
66
  await validatePublicUrl("https://[64:ff9b::8.8.8.8]");
66
67
  });
67
68
 
69
+ test("validateLookupAddresses() tolerates Cloudflare Workers CNAME entries", () => {
70
+ validateLookupAddresses([
71
+ { address: "app-host.example.net.", family: 4 },
72
+ { address: "93.184.216.34", family: 4 },
73
+ { address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 },
74
+ ]);
75
+ });
76
+
77
+ test("validateLookupAddresses() rejects unsafe or CNAME-only Cloudflare Workers results", () => {
78
+ throws(
79
+ () =>
80
+ validateLookupAddresses([
81
+ { address: "private-host.example.net.", family: 4 },
82
+ { address: "127.0.0.1", family: 4 },
83
+ ]),
84
+ UrlError,
85
+ );
86
+ throws(
87
+ () =>
88
+ validateLookupAddresses([
89
+ { address: "app-host.example.net.", family: 4 },
90
+ ]),
91
+ UrlError,
92
+ );
93
+ });
94
+
68
95
  test("isValidPublicIPv4Address()", () => {
69
96
  ok(isValidPublicIPv4Address("8.8.8.8")); // Google DNS
70
97
  ok(!isValidPublicIPv4Address("192.168.1.1")); // private
package/src/url.ts CHANGED
@@ -3,8 +3,8 @@ import { lookup } from "node:dns/promises";
3
3
  import { isIP } from "node:net";
4
4
 
5
5
  export class UrlError extends Error {
6
- constructor(message: string) {
7
- super(message);
6
+ constructor(message: string, options?: { cause?: unknown }) {
7
+ super(message, options);
8
8
  this.name = "UrlError";
9
9
  }
10
10
  }
@@ -51,12 +51,42 @@ export async function validatePublicUrl(url: string): Promise<void> {
51
51
  let addresses: LookupAddress[];
52
52
  try {
53
53
  addresses = await lookup(hostname, { all: true });
54
- } catch {
55
- addresses = [];
54
+ } catch (error) {
55
+ throw new UrlError("DNS lookup failed", { cause: error });
56
56
  }
57
- for (const { address, family } of addresses) {
57
+ validateLookupAddresses(addresses);
58
+ }
59
+
60
+ /**
61
+ * Validates the IP addresses returned by `node:dns.lookup()`.
62
+ *
63
+ * Cloudflare Workers' `node:dns` implementation currently maps every record
64
+ * in a DNS-over-HTTPS `Answer` array—including CNAME records—to a
65
+ * `LookupAddress`, even though Node.js specifies that the `address` field must
66
+ * contain an IPv4 or IPv6 literal. See:
67
+ * https://github.com/cloudflare/workerd/issues/6886
68
+ *
69
+ * Work around that bug by ignoring non-IP entries only when the lookup also
70
+ * returns at least one actual IP address. This remains fail-closed: a result
71
+ * containing no IP addresses is rejected, and every returned IP address is
72
+ * still validated and must be public. This workaround can be revisited once
73
+ * the Workerd issue is fixed in supported Cloudflare Workers runtimes.
74
+ *
75
+ * @internal
76
+ */
77
+ export function validateLookupAddresses(
78
+ addresses: readonly LookupAddress[],
79
+ ): void {
80
+ let ipAddressCount = 0;
81
+ for (const { address } of addresses) {
82
+ const family = isIP(address);
83
+ if (family === 0) continue;
84
+ ipAddressCount++;
58
85
  validatePublicIpAddress(address, family);
59
86
  }
87
+ if (ipAddressCount === 0) {
88
+ throw new UrlError("DNS lookup did not return any IP address");
89
+ }
60
90
  }
61
91
 
62
92
  function validatePublicIpAddress(address: string, family: number): void {