From 79ea737f58105ff2b3292886c17849849ddf01b9 Mon Sep 17 00:00:00 2001 From: SJang1 Date: Sun, 19 Jul 2026 14:50:32 +0900 Subject: [PATCH 1/3] Handle non-IP DNS lookup results Ignore non-IP aliases emitted by Node-compatible DNS implementations when a lookup also returns actual IP addresses. Fail closed for alias-only results and continue checking every returned IP. Assisted-by: Codex:gpt-5 --- packages/vocab-runtime/src/url.test.ts | 29 +++++++++++++++++++++++++- packages/vocab-runtime/src/url.ts | 25 +++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/vocab-runtime/src/url.test.ts b/packages/vocab-runtime/src/url.test.ts index 35aad4658..78580df4a 100644 --- a/packages/vocab-runtime/src/url.test.ts +++ b/packages/vocab-runtime/src/url.test.ts @@ -1,10 +1,11 @@ -import { deepStrictEqual, ok, rejects } from "node:assert"; +import { deepStrictEqual, ok, rejects, throws } from "node:assert"; import { test } from "node:test"; import { expandIPv6Address, isValidPublicIPv4Address, isValidPublicIPv6Address, UrlError, + validateLookupAddresses, validatePublicUrl, } from "./url.ts"; @@ -65,6 +66,32 @@ test("validatePublicUrl()", async () => { await validatePublicUrl("https://[64:ff9b::8.8.8.8]"); }); +test("validateLookupAddresses() ignores CNAME results", () => { + validateLookupAddresses([ + { address: "app-host.example.net.", family: 4 }, + { address: "93.184.216.34", family: 4 }, + { address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 }, + ]); +}); + +test("validateLookupAddresses() rejects unsafe or alias-only results", () => { + throws( + () => + validateLookupAddresses([ + { address: "private-host.example.net.", family: 4 }, + { address: "127.0.0.1", family: 4 }, + ]), + UrlError, + ); + throws( + () => + validateLookupAddresses([ + { address: "app-host.example.net.", family: 4 }, + ]), + UrlError, + ); +}); + test("isValidPublicIPv4Address()", () => { ok(isValidPublicIPv4Address("8.8.8.8")); // Google DNS ok(!isValidPublicIPv4Address("192.168.1.1")); // private diff --git a/packages/vocab-runtime/src/url.ts b/packages/vocab-runtime/src/url.ts index 0274e669f..b74272205 100644 --- a/packages/vocab-runtime/src/url.ts +++ b/packages/vocab-runtime/src/url.ts @@ -54,9 +54,32 @@ export async function validatePublicUrl(url: string): Promise { } catch { addresses = []; } - for (const { address, family } of addresses) { + validateLookupAddresses(addresses); +} + +/** + * Validates the IP addresses returned by `node:dns.lookup()`. + * + * Some Node.js-compatible runtimes include CNAME records in lookup results, + * even though Node.js specifies that the `address` field contains an IPv4 or + * IPv6 address. Ignore those aliases as long as the lookup also returned an + * actual IP address, while continuing to reject every non-public IP address. + * + * @internal + */ +export function validateLookupAddresses( + addresses: readonly LookupAddress[], +): void { + let ipAddressCount = 0; + for (const { address } of addresses) { + const family = isIP(address); + if (family === 0) continue; + ipAddressCount++; validatePublicIpAddress(address, family); } + if (addresses.length > 0 && ipAddressCount === 0) { + throw new UrlError("DNS lookup did not return an IP address"); + } } function validatePublicIpAddress(address: string, family: number): void { From be6b35d273a9d989c523d2dd876ea15b1220c7d8 Mon Sep 17 00:00:00 2001 From: SJang1 Date: Sun, 19 Jul 2026 15:03:19 +0900 Subject: [PATCH 2/3] Apply comment: Fail on DNS failture --- CHANGES.md | 11 +++++++++++ packages/vocab-runtime/src/url.ts | 12 ++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 79e48d8ef..e38435161 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,17 @@ Version 2.0.23 To be released. +### @fedify/vocab-runtime + + - Fixed document loaders rejecting public URLs backed by CNAMEs on + Cloudflare Workers. `validatePublicUrl()` now ignores non-IP aliases + returned alongside DNS lookup results while continuing to validate every + resolved IP address, and rejects lookups that return no IP addresses. + [[#956], [#957] by SJang1] + +[#956]: https://github.com/fedify-dev/fedify/issues/956 +[#957]: https://github.com/fedify-dev/fedify/pull/957 + Version 2.0.22 -------------- diff --git a/packages/vocab-runtime/src/url.ts b/packages/vocab-runtime/src/url.ts index b74272205..7f7f68c7b 100644 --- a/packages/vocab-runtime/src/url.ts +++ b/packages/vocab-runtime/src/url.ts @@ -3,8 +3,8 @@ import { lookup } from "node:dns/promises"; import { isIP } from "node:net"; export class UrlError extends Error { - constructor(message: string) { - super(message); + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); this.name = "UrlError"; } } @@ -51,8 +51,8 @@ export async function validatePublicUrl(url: string): Promise { let addresses: LookupAddress[]; try { addresses = await lookup(hostname, { all: true }); - } catch { - addresses = []; + } catch (error) { + throw new UrlError("DNS lookup failed", { cause: error }); } validateLookupAddresses(addresses); } @@ -77,8 +77,8 @@ export function validateLookupAddresses( ipAddressCount++; validatePublicIpAddress(address, family); } - if (addresses.length > 0 && ipAddressCount === 0) { - throw new UrlError("DNS lookup did not return an IP address"); + if (ipAddressCount === 0) { + throw new UrlError("DNS lookup did not return any IP address"); } } From c54dedbd563440424209327dc19a5bb7b3aca494 Mon Sep 17 00:00:00 2001 From: SJang1 Date: Sun, 19 Jul 2026 16:58:50 +0900 Subject: [PATCH 3/3] Document Cloudflare Workers DNS workaround Clarify that non-IP lookup entries are tolerated only for Workerd issue #6886 while retaining fail-closed validation, and identify the regression source in test names. Assisted-by: Codex:gpt-5 --- packages/vocab-runtime/src/url.test.ts | 4 ++-- packages/vocab-runtime/src/url.ts | 15 +++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/vocab-runtime/src/url.test.ts b/packages/vocab-runtime/src/url.test.ts index 78580df4a..d101c02b5 100644 --- a/packages/vocab-runtime/src/url.test.ts +++ b/packages/vocab-runtime/src/url.test.ts @@ -66,7 +66,7 @@ test("validatePublicUrl()", async () => { await validatePublicUrl("https://[64:ff9b::8.8.8.8]"); }); -test("validateLookupAddresses() ignores CNAME results", () => { +test("validateLookupAddresses() tolerates Cloudflare Workers CNAME entries", () => { validateLookupAddresses([ { address: "app-host.example.net.", family: 4 }, { address: "93.184.216.34", family: 4 }, @@ -74,7 +74,7 @@ test("validateLookupAddresses() ignores CNAME results", () => { ]); }); -test("validateLookupAddresses() rejects unsafe or alias-only results", () => { +test("validateLookupAddresses() rejects unsafe or CNAME-only Cloudflare Workers results", () => { throws( () => validateLookupAddresses([ diff --git a/packages/vocab-runtime/src/url.ts b/packages/vocab-runtime/src/url.ts index 7f7f68c7b..9b2c718ff 100644 --- a/packages/vocab-runtime/src/url.ts +++ b/packages/vocab-runtime/src/url.ts @@ -60,10 +60,17 @@ export async function validatePublicUrl(url: string): Promise { /** * Validates the IP addresses returned by `node:dns.lookup()`. * - * Some Node.js-compatible runtimes include CNAME records in lookup results, - * even though Node.js specifies that the `address` field contains an IPv4 or - * IPv6 address. Ignore those aliases as long as the lookup also returned an - * actual IP address, while continuing to reject every non-public IP address. + * Cloudflare Workers' `node:dns` implementation currently maps every record + * in a DNS-over-HTTPS `Answer` array—including CNAME records—to a + * `LookupAddress`, even though Node.js specifies that the `address` field must + * contain an IPv4 or IPv6 literal. See: + * https://github.com/cloudflare/workerd/issues/6886 + * + * Work around that bug by ignoring non-IP entries only when the lookup also + * returns at least one actual IP address. This remains fail-closed: a result + * containing no IP addresses is rejected, and every returned IP address is + * still validated and must be public. This workaround can be revisited once + * the Workerd issue is fixed in supported Cloudflare Workers runtimes. * * @internal */