From dd9ba974d0353adb5d35722d936a35724f0bb5a5 Mon Sep 17 00:00:00 2001 From: Glucksberg Date: Mon, 23 Feb 2026 02:21:34 +0000 Subject: [PATCH] fix: sort IPv4 addresses before IPv6 in SSRF pinned DNS to fix Telegram media fetch on IPv6-broken hosts On hosts where IPv6 is configured but not routed (common on cloud VMs), Telegram media downloads fail because the pinned DNS lookup may return IPv6 addresses first. Even though autoSelectFamily (Happy Eyeballs) is enabled, the round-robin pinned lookup serves individual IPv6 addresses that fail before IPv4 is attempted. Sort resolved addresses so IPv4 comes first, ensuring both Happy Eyeballs and single-address round-robin try the working address family first. Fixes #23975 Co-Authored-By: Claude Opus 4.6 --- src/infra/net/ssrf.pinning.test.ts | 17 +++++++++++++++++ src/infra/net/ssrf.ts | 13 ++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 19d61bdaee8..73f91d9d536 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -155,6 +155,23 @@ describe("ssrf pinning", () => { expect(lookup).not.toHaveBeenCalled(); }); + it("sorts IPv4 addresses before IPv6 in pinned results", async () => { + const lookup = vi.fn(async () => [ + { address: "2001:db8::1", family: 6 }, + { address: "93.184.216.34", family: 4 }, + { address: "2001:db8::2", family: 6 }, + { address: "93.184.216.35", family: 4 }, + ]) as unknown as LookupFn; + + const pinned = await resolvePinnedHostname("example.com", lookup); + expect(pinned.addresses).toEqual([ + "93.184.216.34", + "93.184.216.35", + "2001:db8::1", + "2001:db8::2", + ]); + }); + it("allows ISATAP embedded private IPv4 when private network is explicitly enabled", async () => { const lookup = vi.fn(async () => [ { address: "2001:db8:1234::5efe:127.0.0.1", family: 6 }, diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 2e4c69210d6..0d77bfeb35d 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -290,7 +290,18 @@ export async function resolvePinnedHostnameWithPolicy( assertAllowedResolvedAddressesOrThrow(results, params.policy); } - const addresses = Array.from(new Set(results.map((entry) => entry.address))); + // Sort IPv4 addresses before IPv6 so that Happy Eyeballs (autoSelectFamily) and + // round-robin pinned lookups try IPv4 first. This avoids connection failures on + // hosts where IPv6 is configured but not routed (common on cloud VMs and WSL2). + // See: https://github.com/openclaw/openclaw/issues/23975 + const addresses = Array.from(new Set(results.map((entry) => entry.address))).toSorted((a, b) => { + const aIsV6 = a.includes(":"); + const bIsV6 = b.includes(":"); + if (aIsV6 === bIsV6) { + return 0; + } + return aIsV6 ? 1 : -1; + }); if (addresses.length === 0) { throw new Error(`Unable to resolve hostname: ${hostname}`); }