From 3d03b60da99da2d638109821fbaa707828d038e8 Mon Sep 17 00:00:00 2001 From: lsr911 Date: Sun, 19 Jul 2026 07:51:50 +0800 Subject: [PATCH] fix(litellm): guard loopback hostname auto-allow with isIP to prevent DNS SSRF bypass (#110693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(litellm): guard loopback hostname auto-allow with isIP to prevent DNS bypass The isAutoAllowedLitellmHostname helper auto-enables private-network access for loopback-style hosts. Before this fix, lowered.startsWith("127.") matched DNS hostnames like 127.evil.com, letting remote endpoints bypass the explicit allowPrivateNetwork opt-in — a SSRF risk. Add isIP(host)===4 guard so only literal IPv4 loopback addresses qualify. Same canonical pattern as extensions/slack/src/monitor/relay-source.ts:271 and the codex loopback fix. Co-Authored-By: Claude * test(litellm): cover loopback endpoint policy --------- Co-authored-by: Claude Co-authored-by: Peter Steinberger --- .../litellm/image-generation-provider.test.ts | 50 +++++++++++-------- .../litellm/image-generation-provider.ts | 4 +- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/extensions/litellm/image-generation-provider.test.ts b/extensions/litellm/image-generation-provider.test.ts index 9674c763e7f4..ac1067086904 100644 --- a/extensions/litellm/image-generation-provider.test.ts +++ b/extensions/litellm/image-generation-provider.test.ts @@ -254,7 +254,9 @@ describe("litellm image generation provider", () => { const cases = [ "http://localhost:4000", "http://127.0.0.1:4000", + "http://127.255.255.254:4000", "http://[::1]:4000", + "http://[0:0:0:0:0:0:0:1]:4000", "http://host.docker.internal:4000", "https://localhost:4000", ] as const; @@ -283,6 +285,7 @@ describe("litellm image generation provider", () => { "https://192.168.5.10:4000", "http://printer.local:4000", "http://proxy.internal:4000", + "http://127.evil.com:4000", "https://metadata.google.internal", ] as const; for (const baseUrl of cases) { @@ -303,33 +306,36 @@ describe("litellm image generation provider", () => { } }); - it("honors explicit private-network opt-in for a LAN LiteLLM proxy", async () => { - mockGeneratedPngResponse(); + it.each(["http://192.168.5.10:4000", "http://127.evil.com:4000"])( + "honors explicit private-network opt-in for %s", + async (baseUrl) => { + mockGeneratedPngResponse(); - const provider = buildLitellmImageGenerationProvider(); - await provider.generateImage({ - provider: "litellm", - model: "gpt-image-2", - prompt: "x", - cfg: { - models: { - providers: { - litellm: { - baseUrl: "http://192.168.5.10:4000", - request: { allowPrivateNetwork: true }, - models: [], + const provider = buildLitellmImageGenerationProvider(); + await provider.generateImage({ + provider: "litellm", + model: "gpt-image-2", + prompt: "x", + cfg: { + models: { + providers: { + litellm: { + baseUrl, + request: { allowPrivateNetwork: true }, + models: [], + }, }, }, }, - }, - }); + }); - expectFields(mockObjectArg(resolveProviderHttpRequestConfigMock), { - allowPrivateNetwork: undefined, - request: { allowPrivateNetwork: true }, - }); - expect(mockObjectArg(postJsonRequestMock).allowPrivateNetwork).toBe(true); - }); + expectFields(mockObjectArg(resolveProviderHttpRequestConfigMock), { + allowPrivateNetwork: undefined, + request: { allowPrivateNetwork: true }, + }); + expect(mockObjectArg(postJsonRequestMock).allowPrivateNetwork).toBe(true); + }, + ); it("does not allow private network for public hosts that embed private strings in the URL", async () => { // Must not be fooled by an attacker-controlled URL that mentions diff --git a/extensions/litellm/image-generation-provider.ts b/extensions/litellm/image-generation-provider.ts index 76050744887d..00b5d32bab46 100644 --- a/extensions/litellm/image-generation-provider.ts +++ b/extensions/litellm/image-generation-provider.ts @@ -1,3 +1,4 @@ +import { isIP } from "node:net"; // Litellm provider module implements model/runtime integration. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { @@ -62,7 +63,8 @@ function isAutoAllowedLitellmHostname(hostname: string): boolean { ) { return true; } - if (lowered === "127.0.0.1" || lowered.startsWith("127.")) { + // Only IPv4 literals may use the 127/8 loopback exemption. + if (isIP(lowered) === 4 && lowered.startsWith("127.")) { return true; } if (lowered === "::1" || lowered === "0:0:0:0:0:0:0:1") {