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}`); }