Harden hostname normalization for repeated trailing dots [AI] (#87305)

* fix: canonicalize trailing hostname dots

* test: reuse shared hostname normalization

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-27 21:08:29 +05:30
committed by GitHub
parent 12dc398267
commit 0314d67d87
6 changed files with 45 additions and 3 deletions

View File

@@ -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.

View File

@@ -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");

View File

@@ -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]" },

View File

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

View File

@@ -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],

View File

@@ -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,