fix(litellm): guard loopback hostname auto-allow with isIP to prevent DNS SSRF bypass (#110693)

* 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 <noreply@anthropic.com>

* test(litellm): cover loopback endpoint policy

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
lsr911
2026-07-19 07:51:50 +08:00
committed by GitHub
parent 8899940d5a
commit 3d03b60da9
2 changed files with 31 additions and 23 deletions

View File

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

View File

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