Skip to content

Commit 89b3fac

Browse files
committed
fix: prefer IPv4 for pinned SSRF lookups
1 parent 09132ef commit 89b3fac

5 files changed

Lines changed: 64 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
2222

2323
- Gateway: reread config from disk after the first in-process restart loop startup, preventing SIGUSR1 restarts from reusing a stale startup snapshot and dropping config written after boot. Fixes #79947. Thanks @TheLevti.
2424
- Codex app-server: deliver native image-generation outputs from Codex `savedPath` events as reply media, so blank-text image generation turns still attach the generated file. Thanks @keshavbotagent.
25+
- Network/SSRF: keep pinned automatic DNS lookups on IPv4 when dual-stack hosts also publish AAAA records, and treat `EADDRNOTAVAIL` as a transient gateway network failure instead of a fatal crash. Fixes #80078. Thanks @takamasa-aiso.
2526
- Media/host-read: allow buffer-verified gzip, tar, and 7z archives in the shared host-local media validator alongside ZIP and document attachments.
2627
- Plugins/doctor: invalidate persisted plugin registry snapshots when plugin diagnostics point at deleted source paths, so `openclaw doctor` stops repeating stale warnings after a local extension is replaced by a managed npm plugin. Fixes #80087. (#80134) Thanks @hclsys.
2728
- Cron: let isolated self-cleanup runs inspect their own job run history while keeping other cron jobs and mutation actions blocked. Fixes #80019. Thanks @hclsys.

src/infra/net/ssrf.pinning.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,52 @@ describe("ssrf pinning", () => {
4949
);
5050
});
5151

52+
it("keeps automatic pinned lookups on IPv4 when both address families are available", async () => {
53+
const lookup = createPinnedLookup({
54+
hostname: "api.anthropic.com",
55+
addresses: ["160.79.104.10", "2607:6bc0::10"],
56+
});
57+
const lookupDefault = () =>
58+
new Promise<{ address: string; family?: number }>((resolve, reject) => {
59+
lookup("api.anthropic.com", (err, address, family) => {
60+
if (err) {
61+
reject(err);
62+
} else {
63+
resolve({ address: address, family });
64+
}
65+
});
66+
});
67+
const lookupWithOptions = (options: { family?: number }) =>
68+
new Promise<{ address: string; family?: number }>((resolve, reject) => {
69+
lookup("api.anthropic.com", options, (err, address, family) => {
70+
if (err) {
71+
reject(err);
72+
} else {
73+
resolve({ address: address, family });
74+
}
75+
});
76+
});
77+
78+
await expect(lookupDefault()).resolves.toEqual({ address: "160.79.104.10", family: 4 });
79+
await expect(lookupDefault()).resolves.toEqual({ address: "160.79.104.10", family: 4 });
80+
81+
const all = await new Promise<unknown>((resolve, reject) => {
82+
lookup("api.anthropic.com", { all: true }, (err, addresses) => {
83+
if (err) {
84+
reject(err);
85+
} else {
86+
resolve(addresses);
87+
}
88+
});
89+
});
90+
expect(all).toEqual([{ address: "160.79.104.10", family: 4 }]);
91+
92+
await expect(lookupWithOptions({ family: 6 })).resolves.toEqual({
93+
address: "2607:6bc0::10",
94+
family: 6,
95+
});
96+
});
97+
5298
it.each([
5399
{ name: "RFC1918 private address", address: "10.0.0.8" },
54100
{ name: "RFC2544 benchmarking range", address: "198.18.0.1" },

src/infra/net/ssrf.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,8 @@ export function createPinnedLookup(params: {
345345
address,
346346
family: address.includes(":") ? 6 : 4,
347347
}));
348+
const ipv4Records = records.filter((entry) => entry.family === 4);
349+
const automaticRecords = ipv4Records.length > 0 ? ipv4Records : records;
348350
let index = 0;
349351

350352
return ((host: string, options?: unknown, callback?: unknown) => {
@@ -370,8 +372,8 @@ export function createPinnedLookup(params: {
370372
const candidates =
371373
requestedFamily === 4 || requestedFamily === 6
372374
? records.filter((entry) => entry.family === requestedFamily)
373-
: records;
374-
const usable = candidates.length > 0 ? candidates : records;
375+
: automaticRecords;
376+
const usable = candidates.length > 0 ? candidates : automaticRecords;
375377
if (opts.all) {
376378
cb(null, usable as LookupAddress[]);
377379
return;

src/infra/unhandled-rejections.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ describe("isTransientNetworkError", () => {
6262
"EPIPE",
6363
"EHOSTUNREACH",
6464
"ENETUNREACH",
65+
"EADDRNOTAVAIL",
6566
"EAI_AGAIN",
6667
"EPROTO",
6768
"UND_ERR_CONNECT_TIMEOUT",
@@ -368,13 +369,21 @@ describe("isTransientUnhandledRejectionError", () => {
368369
const rawHostUnreachable = new Error(
369370
"connect EHOSTUNREACH 149.154.167.220:443 - Local (10.0.10.40:50017)",
370371
);
372+
const addressUnavailable = Object.assign(new Error("connect EADDRNOTAVAIL"), {
373+
code: "EADDRNOTAVAIL",
374+
});
375+
const rawAddressUnavailable = new Error(
376+
"connect EADDRNOTAVAIL 2607:6bc0::10:443 - Local (:::0)",
377+
);
371378
const generic = new Error("boom");
372379

373380
expect(isBenignUncaughtExceptionError(epipe)).toBe(true);
374381
expect(isBenignUncaughtExceptionError(sqlite)).toBe(false);
375382
expect(isBenignUncaughtExceptionError(network)).toBe(false);
376383
expect(isBenignUncaughtExceptionError(hostUnreachable)).toBe(true);
377384
expect(isBenignUncaughtExceptionError(rawHostUnreachable)).toBe(true);
385+
expect(isBenignUncaughtExceptionError(addressUnavailable)).toBe(true);
386+
expect(isBenignUncaughtExceptionError(rawAddressUnavailable)).toBe(true);
378387
expect(isBenignUncaughtExceptionError(generic)).toBe(false);
379388
});
380389
it("returns true for transient SQLite errors", () => {

src/infra/unhandled-rejections.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const TRANSIENT_NETWORK_CODES = new Set([
5959
"EPIPE",
6060
"EHOSTUNREACH",
6161
"ENETUNREACH",
62+
"EADDRNOTAVAIL",
6263
"EAI_AGAIN",
6364
"UND_ERR_CONNECT_TIMEOUT",
6465
"UND_ERR_DNS_RESOLVE_FAILED",
@@ -93,6 +94,7 @@ const BENIGN_UNCAUGHT_EXCEPTION_NETWORK_CODES = new Set([
9394
"ECONNREFUSED",
9495
"EHOSTUNREACH",
9596
"ENETUNREACH",
97+
"EADDRNOTAVAIL",
9698
"EAI_AGAIN",
9799
"ENOTFOUND",
98100
"ETIMEDOUT",
@@ -102,9 +104,9 @@ const BENIGN_UNCAUGHT_EXCEPTION_NETWORK_CODES = new Set([
102104
]);
103105

104106
const TRANSIENT_NETWORK_MESSAGE_CODE_RE =
105-
/\b(ECONNRESET|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ESOCKETTIMEDOUT|ECONNABORTED|EPIPE|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN|EPROTO|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT|UND_ERR_SOCKET|UND_ERR_HEADERS_TIMEOUT|UND_ERR_BODY_TIMEOUT)\b/i;
107+
/\b(ECONNRESET|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ESOCKETTIMEDOUT|ECONNABORTED|EPIPE|EHOSTUNREACH|ENETUNREACH|EADDRNOTAVAIL|EAI_AGAIN|EPROTO|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT|UND_ERR_SOCKET|UND_ERR_HEADERS_TIMEOUT|UND_ERR_BODY_TIMEOUT)\b/i;
106108
const BENIGN_UNCAUGHT_EXCEPTION_NETWORK_MESSAGE_CODE_RE =
107-
/\b(ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT)\b/i;
109+
/\b(ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|EADDRNOTAVAIL|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT)\b/i;
108110

109111
const TRANSIENT_SQLITE_MESSAGE_CODE_RE =
110112
/\b(SQLITE_BUSY|SQLITE_CANTOPEN|SQLITE_IOERR|SQLITE_LOCKED)\b/i;

0 commit comments

Comments
 (0)