fix(plugin-sdk): classify loopback hosts consistently (#114832)

* fix(plugin-sdk): expose loopback host classifier

* fix(ollama): remove stale host normalizer import

* chore: keep release notes in PR context
This commit is contained in:
Peter Steinberger
2026-07-27 22:55:44 -04:00
committed by GitHub
parent b4e246be01
commit 577a0642fa
13 changed files with 139 additions and 32 deletions
@@ -126,7 +126,7 @@ cd431f6ba8327b81438b7a63b1963120f200f5abd145fb6aa7c5c561339cb0b1 module/setup-t
18e384ec43d9eaee52c8e286e127bda2048370e2337964a754d94b236724ca9e module/skill-commands-runtime
ae469f32799380e6b045abaefefee6eb3f00d714ffbf36b6eeef5025dc529472 module/speech-settings
9e521fe9073dfd1a6a6855f909fa6befe8613e18403f0a65faaba973a8b630c1 module/ssrf-policy
c564f2f5d1e6cf7a9fb1d3f1a7fa3ccb42c61306a8497524fcab5d4d785d108d module/ssrf-runtime
f85d5be0f635de6a77bbd9224a37c373998e098a8dcf2048ddc2c9ac735b47ef module/ssrf-runtime
ff35f9f74d35d37a2eb6126b57f3dc5a4d580b6222fe3a34c9368779ad32eab7 module/state-paths
c5ff317bb7957d0870cb806e7989c1d48877e3bed583c173ce2f8e9cfe57ee36 module/status-helpers
537047854c21ad20ea0572f8019503cbda3bbafece8194a28c30b0639bde2fde module/string-coerce-runtime
+3 -1
View File
@@ -202,12 +202,14 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/security-runtime` | Deprecated broad barrel for trust, DM gating, root-bounded file/path helpers including create-only writes, sync/async atomic file replacement, sibling temp writes, cross-device move fallback, private file-store helpers, symlink-parent guards, external-content, sensitive text redaction, constant-time secret comparison, and secret-collection helpers; prefer focused security/SSRF/secret subpaths |
| `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers |
| `plugin-sdk/ssrf-dispatcher` | Private-local after July 2026; Narrow pinned-dispatcher helpers without the broad infra runtime surface |
| `plugin-sdk/ssrf-runtime` | Pinned-dispatcher, SSRF-guarded fetch, SSRF error, and SSRF policy helpers |
| `plugin-sdk/ssrf-runtime` | Pinned-dispatcher, SSRF-guarded fetch, SSRF error, SSRF policy helpers, and loopback/private host classification |
| `plugin-sdk/secret-input` | Secret input parsing helpers |
| `plugin-sdk/webhook-ingress` | Webhook request/target helpers and raw websocket/body coercion |
| `plugin-sdk/webhook-request-guards` | Request body size/timeout helpers and `runDetachedWebhookWork` for tracked post-ack processing |
</Accordion>
Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It accepts `localhost`, IPv4 loopback literals across `127.0.0.0/8`, `::1`, bracketed IPv6, and IPv4-mapped IPv6 loopback literals. It parses IP literals rather than matching text prefixes, so a DNS name such as `127.0.0.1.evil.com` is not loopback. Use `isPrivateOrLoopbackHost(host)` only when private-network hosts such as RFC 1918 addresses are also valid.
<Accordion title="Runtime and storage subpaths">
| Subpath | Key exports |
| --- | --- |
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import { hostname as readHostName } from "node:os";
import { isLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime";
import type {
CodexAppServerConnectionClass,
CodexAppServerDefaultPolicy,
@@ -227,14 +228,7 @@ function isLoopbackWebSocketUrl(value: string): boolean {
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
return false;
}
const host = parsed.hostname.toLowerCase();
return (
host === "localhost" ||
host === "127.0.0.1" ||
host === "::1" ||
host === "[::1]" ||
host.startsWith("127.")
);
return isLoopbackHost(parsed.hostname);
}
function hasIdentityBearingWebSocketAuth(params: {
@@ -517,6 +517,30 @@ describe("Codex app-server config", () => {
});
});
it.each([
["ws://localhost:4242", "local-loopback"],
["ws://127.0.0.1:4242", "local-loopback"],
["ws://127.0.0.2:4242", "local-loopback"],
["ws://127.255.255.254:4242", "local-loopback"],
["ws://[::1]:4242", "local-loopback"],
["ws://[::ffff:127.0.0.2]:4242", "local-loopback"],
["wss://128.0.0.1:4242", "remote"],
["wss://10.0.0.1:4242", "remote"],
["wss://127.0.0.1.evil.com:4242", "remote"],
] as const)("classifies app-server URL %s as %s", (url, connectionClass) => {
const runtime = resolveRuntimeForTest({
pluginConfig: {
appServer: {
transport: "websocket",
url,
...(connectionClass === "remote" ? { authToken: "capability-token" } : {}),
},
},
});
expectFields(runtime, "runtime", { connectionClass });
});
it("rejects remote websocket app-servers without identity-bearing auth", () => {
expect(() =>
resolveRuntimeForTest({
+2 -1
View File
@@ -1,5 +1,6 @@
// Diffs plugin module implements http behavior.
import type { IncomingMessage, ServerResponse } from "node:http";
import { isLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { PluginLogger } from "../api.js";
import { resolveRequestClientIp } from "../runtime-api.js";
@@ -200,7 +201,7 @@ function normalizeRemoteClientKey(remoteAddress: string | undefined): string {
}
function isLoopbackClientIp(clientIp: string): boolean {
return clientIp === "127.0.0.1" || clientIp === "::1";
return isLoopbackHost(clientIp);
}
function hasProxyForwardingHints(req: IncomingMessage): boolean {
+26 -1
View File
@@ -487,6 +487,30 @@ describe("createDiffsHttpHandler", () => {
},
);
it.each([
["127.0.0.1", 200],
["127.0.0.2", 200],
["127.255.255.254", 200],
["::1", 200],
["::ffff:127.0.0.2", 200],
["128.0.0.1", 404],
] as const)("classifies viewer client address %s", async (remoteAddress, expectedStatusCode) => {
const artifact = await createViewerArtifact(store);
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: false });
const res = createMockServerResponse();
await handler(
localReq({
method: "GET",
url: artifact.viewerPath,
remoteAddress,
}),
res,
);
expect(res.statusCode).toBe(expectedStatusCode);
});
it("rate-limits repeated remote misses", async () => {
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
@@ -527,11 +551,12 @@ function localReq(input: {
method: string;
url: string;
headers?: Record<string, string>;
remoteAddress?: string;
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "127.0.0.1" },
socket: { remoteAddress: input.remoteAddress ?? "127.0.0.1" },
} as unknown as IncomingMessage;
}
@@ -372,6 +372,28 @@ describe("nostr-profile-http", () => {
expect(res["_getStatusCode"]()).toBe(403);
});
it.each([
["http://localhost:18789", 200],
["http://127.0.0.1:18789", 200],
["http://127.0.0.2:18789", 200],
["http://127.255.255.254:18789", 200],
["http://[::1]:18789", 200],
["http://[::ffff:127.0.0.2]:18789", 200],
["http://128.0.0.1:18789", 403],
["http://127.0.0.1.evil.com:18789", 403],
] as const)("classifies profile mutation origin %s", async (origin, expectedStatusCode) => {
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
body: { name: "satoshi" },
req: { headers: { origin } },
});
if (expectedStatusCode === 200) {
mockPublishSuccess();
}
await run();
expect(res["_getStatusCode"]()).toBe(expectedStatusCode);
});
it("rejects profile mutation with cross-site sec-fetch-site header", async () => {
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
body: { name: "attacker" },
+2 -2
View File
@@ -9,6 +9,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { isLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
@@ -166,8 +167,7 @@ function isLoopbackRemoteAddress(remoteAddress: string | undefined): boolean {
function isLoopbackOriginLike(value: string): boolean {
try {
const url = new URL(value);
const hostname = normalizeLowercaseStringOrEmpty(url.hostname);
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
return isLoopbackHost(url.hostname);
} catch {
return false;
}
+19 -2
View File
@@ -6,11 +6,12 @@ const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>()),
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
import { buildAssistantMessage, createOllamaStreamFn } from "./stream.js";
import { buildAssistantMessage, createOllamaStreamFn, isOllamaCompatProvider } from "./stream.js";
function makeOllamaResponse(params: {
content?: string;
@@ -38,6 +39,22 @@ function makeOllamaResponse(params: {
const MODEL_INFO = { api: "ollama", provider: "ollama", id: "qwen3.5" };
describe("isOllamaCompatProvider", () => {
it.each([
["http://localhost:11434", true],
["http://127.0.0.1:11434", true],
["http://127.0.0.2:11434", true],
["http://127.255.255.254:11434", true],
["http://[::1]:11434", true],
["http://[::ffff:127.0.0.2]:11434", true],
["http://128.0.0.1:11434", false],
["http://10.0.0.1:11434", false],
["http://127.0.0.1.evil.com:11434", false],
] as const)("classifies %s as Ollama-compatible=%s", (baseUrl, expected) => {
expect(isOllamaCompatProvider({ provider: "custom", baseUrl })).toBe(expected);
});
});
describe("buildAssistantMessage", () => {
it("includes thinking block when response has thinking field", () => {
const response = makeOllamaResponse({
+3 -13
View File
@@ -30,12 +30,8 @@ import {
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import {
isRecord,
normalizeLowercaseStringOrEmpty,
readStringValue,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { fetchWithSsrFGuard, isLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime";
import { isRecord, readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { OLLAMA_CLOUD_BASE_URL, OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
import { shouldWrapOllamaCompatMoonshotThinking } from "./model-behavior.js";
@@ -196,13 +192,7 @@ export function isOllamaCompatProvider(model: {
}
try {
const parsed = new URL(model.baseUrl);
const hostname = normalizeLowercaseStringOrEmpty(parsed.hostname);
const isLocalhost =
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "[::1]";
if (isLocalhost && parsed.port === "11434") {
if (isLoopbackHost(parsed.hostname) && parsed.port === "11434") {
return true;
}
+4 -2
View File
@@ -188,7 +188,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +6: model-picker action/capability and authoritative session-apply contracts.
// +1: logger file-transport flush for graceful shutdown drains.
// +1: process-local sessions.changed plugin notification payload.
4739,
// +1: loopback-only host classifier for plugin local-machine boundaries.
4740,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
@@ -217,7 +218,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +3: focused CLI root-option parsers.
// +1: authoritative model-picker session-apply operation.
// +1: logger file-transport flush for graceful shutdown drains.
2868,
// +1: loopback-only host classifier for plugin local-machine boundaries.
2869,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { isLoopbackHost, isPrivateOrLoopbackHost } from "./ssrf-runtime.js";
describe("isLoopbackHost", () => {
it.each([
"localhost",
"LOCALHOST.",
"127.0.0.1",
"127.0.0.2",
"127.255.255.254",
"::1",
"[::1]",
"::ffff:127.0.0.2",
"[::ffff:127.255.255.254]",
])("accepts loopback host %s", (host) => {
expect(isLoopbackHost(host)).toBe(true);
});
it.each(["127.0.0.1.evil.com", "128.0.0.1", "10.0.0.1", "192.168.1.1", "::", "example.com", ""])(
"rejects non-loopback host %s",
(host) => {
expect(isLoopbackHost(host)).toBe(false);
},
);
it("stays narrower than the private-or-loopback predicate", () => {
expect(isPrivateOrLoopbackHost("10.0.0.1")).toBe(true);
expect(isLoopbackHost("10.0.0.1")).toBe(false);
});
});
+1 -1
View File
@@ -29,4 +29,4 @@ export {
ssrfPolicyFromPrivateNetworkOptIn,
ssrfPolicyFromAllowPrivateNetwork,
} from "./ssrf-policy.js";
export { isPrivateOrLoopbackHost } from "../gateway/net.js";
export { isLoopbackHost, isPrivateOrLoopbackHost } from "../gateway/net.js";