diff --git a/CHANGELOG.md b/CHANGELOG.md index e234bcdc434..0fed75428d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Harden hostname normalization for repeated trailing dots [AI]. (#87305) Thanks @pgondhi987. - fix: block side-effecting command wrappers [AI]. (#87292) Thanks @pgondhi987. - Block unsafe Node runtime env overrides [AI]. (#87308) Thanks @pgondhi987. - Telegram: route `sendMessage` action replies through durable outbound delivery so completed agent responses remain retryable when the gateway send path times out. (#87261) Thanks @mbelinky. diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 21d170d9a31..72ceff50a2b 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -2003,6 +2003,31 @@ describe("fetchWithSsrFGuard hardening", () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it.each([ + "http://localhost.../resource", + "http://metadata.google.internal.../computeMetadata/v1/", + "http://api.localhost.../resource", + "http://svc.local.../resource", + "http://db.internal.../resource", + ])("blocks reserved repeated-dot hostname in trusted env proxy mode %s", async (url) => { + clearProxyEnv(); + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + const lookupFn = createPublicLookup(); + const fetchImpl = vi.fn(async () => okResponse()); + + await expect( + fetchWithSsrFGuard({ + url, + fetchImpl, + lookupFn, + mode: GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY, + }), + ).rejects.toThrow(/blocked/i); + + expect(lookupFn).not.toHaveBeenCalled(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it("keeps DNS pinning in trusted proxy mode when only ALL_PROXY is configured after allowlist checks", async () => { clearProxyEnv(); vi.stubEnv("ALL_PROXY", "http://127.0.0.1:7890"); diff --git a/src/infra/net/hostname.test.ts b/src/infra/net/hostname.test.ts index e906a8aa192..c7ae95fc437 100644 --- a/src/infra/net/hostname.test.ts +++ b/src/infra/net/hostname.test.ts @@ -4,8 +4,11 @@ import { normalizeHostname } from "./hostname.js"; describe("normalizeHostname", () => { it.each([ { input: " Example.COM. ", expected: "example.com" }, + { input: " Example.COM... ", expected: "example.com" }, + { input: "metadata.google.internal...", expected: "metadata.google.internal" }, { input: " ", expected: "" }, { input: " [FD7A:115C:A1E0::1] ", expected: "fd7a:115c:a1e0::1" }, + { input: " [FD7A:115C:A1E0::1]... ", expected: "fd7a:115c:a1e0::1" }, { input: " [FD7A:115C:A1E0::1]. ", expected: "fd7a:115c:a1e0::1" }, { input: "[fd7a:115c:a1e0::1", expected: "[fd7a:115c:a1e0::1" }, { input: "fd7a:115c:a1e0::1]", expected: "fd7a:115c:a1e0::1]" }, diff --git a/src/infra/net/hostname.ts b/src/infra/net/hostname.ts index b28b41f1a0c..febe5bcd5fe 100644 --- a/src/infra/net/hostname.ts +++ b/src/infra/net/hostname.ts @@ -1,7 +1,7 @@ import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; export function normalizeHostname(hostname: string): string { - const normalized = normalizeLowercaseStringOrEmpty(hostname).replace(/\.$/, ""); + const normalized = normalizeLowercaseStringOrEmpty(hostname).replace(/\.+$/, ""); if (normalized.startsWith("[") && normalized.endsWith("]")) { return normalized.slice(1, -1); } diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 7767ab5c3ee..2a25f1cdac6 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { blockedIpv6MulticastLiterals } from "../../shared/net/ip-test-fixtures.js"; import { + assertHostnameAllowedWithPolicy, isBlockedHostnameOrIp, isPrivateIpAddress, isSameSsrFPolicy, @@ -267,6 +268,18 @@ describe("isBlockedHostnameOrIp", () => { expect(isBlockedHostnameOrIp(hostname)).toBe(true); }); + it.each([ + "localhost...", + "localhost.localdomain...", + "metadata.google.internal...", + "api.localhost...", + "svc.local...", + "db.internal...", + ])("blocks reserved hostname with repeated trailing dots %s", (hostname) => { + expect(isBlockedHostnameOrIp(hostname)).toBe(true); + expect(() => assertHostnameAllowedWithPolicy(hostname)).toThrow(/blocked/i); + }); + it.each([ ["2001:db8:1234::5efe:127.0.0.1", true], ["100::1", true], diff --git a/src/test-helpers/ssrf.ts b/src/test-helpers/ssrf.ts index 9e6d85f5ee0..5a11e54036a 100644 --- a/src/test-helpers/ssrf.ts +++ b/src/test-helpers/ssrf.ts @@ -1,13 +1,13 @@ import { vi } from "vitest"; +import { normalizeHostname } from "../infra/net/hostname.js"; import * as ssrf from "../infra/net/ssrf.js"; import type { LookupFn } from "../infra/net/ssrf.js"; -import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; export function mockPinnedHostnameResolution(addresses: string[] = ["93.184.216.34"]) { const resolvePinnedHostname = ssrf.resolvePinnedHostname; const resolvePinnedHostnameWithPolicy = ssrf.resolvePinnedHostnameWithPolicy; const lookupFn = (async (hostname: string, options?: { all?: boolean }) => { - const normalized = normalizeLowercaseStringOrEmpty(hostname).replace(/\.$/, ""); + const normalized = normalizeHostname(hostname); const resolved = addresses.map((address) => ({ address, family: address.includes(":") ? 6 : 4,