diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index ac4d4e6b6e08..7d304f31abf7 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index b4f42914a6d0..193a10d45390 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -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 | +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. + | Subpath | Key exports | | --- | --- | diff --git a/extensions/codex/src/app-server/config-security.ts b/extensions/codex/src/app-server/config-security.ts index f2574379d0f5..00274d5988f4 100644 --- a/extensions/codex/src/app-server/config-security.ts +++ b/extensions/codex/src/app-server/config-security.ts @@ -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: { diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index 8945c1246744..8c18981fb90f 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -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({ diff --git a/extensions/diffs/src/http.ts b/extensions/diffs/src/http.ts index a64917018551..fed0df9f9519 100644 --- a/extensions/diffs/src/http.ts +++ b/extensions/diffs/src/http.ts @@ -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 { diff --git a/extensions/diffs/src/store.test.ts b/extensions/diffs/src/store.test.ts index 140e07907f5c..4a651fb28b06 100644 --- a/extensions/diffs/src/store.test.ts +++ b/extensions/diffs/src/store.test.ts @@ -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; + 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; } diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index 2f15ca6112e0..46c86e178972 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -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" }, diff --git a/extensions/nostr/src/nostr-profile-http.ts b/extensions/nostr/src/nostr-profile-http.ts index af43f6397144..36fd65b4bda1 100644 --- a/extensions/nostr/src/nostr-profile-http.ts +++ b/extensions/nostr/src/nostr-profile-http.ts @@ -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; } diff --git a/extensions/ollama/src/stream.test.ts b/extensions/ollama/src/stream.test.ts index 4459ab5ef36a..9a11837037d3 100644 --- a/extensions/ollama/src/stream.test.ts +++ b/extensions/ollama/src/stream.test.ts @@ -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()), 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({ diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index 16d345b115b4..08a5f44acb25 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -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; } diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 6fc7682eaa90..99cbe9160ec9 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -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( diff --git a/src/plugin-sdk/ssrf-runtime.test.ts b/src/plugin-sdk/ssrf-runtime.test.ts new file mode 100644 index 000000000000..f6aea33f59e3 --- /dev/null +++ b/src/plugin-sdk/ssrf-runtime.test.ts @@ -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); + }); +}); diff --git a/src/plugin-sdk/ssrf-runtime.ts b/src/plugin-sdk/ssrf-runtime.ts index 9d9ad7617461..c73213fe661e 100644 --- a/src/plugin-sdk/ssrf-runtime.ts +++ b/src/plugin-sdk/ssrf-runtime.ts @@ -29,4 +29,4 @@ export { ssrfPolicyFromPrivateNetworkOptIn, ssrfPolicyFromAllowPrivateNetwork, } from "./ssrf-policy.js"; -export { isPrivateOrLoopbackHost } from "../gateway/net.js"; +export { isLoopbackHost, isPrivateOrLoopbackHost } from "../gateway/net.js";