High severity7.6NVD Advisory· Published Apr 21, 2026· Updated Apr 27, 2026
CVE-2026-41302
CVE-2026-41302
Description
OpenClaw before 2026.3.31 contains a server-side request forgery vulnerability in the marketplace plugin download functionality that allows remote attackers to make arbitrary network requests. Attackers can exploit unguarded fetch() calls to access internal resources or interact with external services on behalf of the affected system.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.31 | 2026.3.31 |
Affected products
1Patches
18deb9522f3d2Guard marketplace and Ollama network requests (#57850)
5 files changed · +235 −116
extensions/ollama/src/provider-models.ssrf.test.ts+21 −0 added@@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { buildOllamaBaseUrlSsrFPolicy } from "./provider-models.js"; + +describe("buildOllamaBaseUrlSsrFPolicy", () => { + it("pins requests to the configured Ollama hostname for HTTP(S) URLs", () => { + expect(buildOllamaBaseUrlSsrFPolicy("http://127.0.0.1:11434")).toEqual({ + allowedHostnames: ["127.0.0.1"], + hostnameAllowlist: ["127.0.0.1"], + }); + expect(buildOllamaBaseUrlSsrFPolicy("https://ollama.example.com/v1")).toEqual({ + allowedHostnames: ["ollama.example.com"], + hostnameAllowlist: ["ollama.example.com"], + }); + }); + + it("returns no allowlist for empty or invalid base URLs", () => { + expect(buildOllamaBaseUrlSsrFPolicy("")).toBeUndefined(); + expect(buildOllamaBaseUrlSsrFPolicy("ftp://ollama.example.com")).toBeUndefined(); + expect(buildOllamaBaseUrlSsrFPolicy("not-a-url")).toBeUndefined(); + }); +});
extensions/ollama/src/provider-models.ts+67 −25 modified@@ -1,4 +1,5 @@ import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard"; +import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_CONTEXT_WINDOW, @@ -28,6 +29,25 @@ export type OllamaModelWithContext = OllamaTagModel & { const OLLAMA_SHOW_CONCURRENCY = 8; +export function buildOllamaBaseUrlSsrFPolicy(baseUrl: string): SsrFPolicy | undefined { + const trimmed = baseUrl.trim(); + if (!trimmed) { + return undefined; + } + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return undefined; + } + return { + allowedHostnames: [parsed.hostname], + hostnameAllowlist: [parsed.hostname], + }; + } catch { + return undefined; + } +} + export function resolveOllamaApiBase(configuredBaseUrl?: string): string { if (!configuredBaseUrl) { return OLLAMA_DEFAULT_BASE_URL; @@ -41,28 +61,41 @@ export async function queryOllamaContextWindow( modelName: string, ): Promise<number | undefined> { try { - const response = await fetch(`${apiBase}/api/show`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: modelName }), - signal: AbortSignal.timeout(3000), + const { response, release } = await fetchWithSsrFGuard({ + url: `${apiBase}/api/show`, + init: { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: modelName }), + signal: AbortSignal.timeout(3000), + }, + policy: buildOllamaBaseUrlSsrFPolicy(apiBase), + auditContext: "ollama-provider-models.show", }); - if (!response.ok) { - return undefined; - } - const data = (await response.json()) as { model_info?: Record<string, unknown> }; - if (!data.model_info) { - return undefined; - } - for (const [key, value] of Object.entries(data.model_info)) { - if (key.endsWith(".context_length") && typeof value === "number" && Number.isFinite(value)) { - const contextWindow = Math.floor(value); - if (contextWindow > 0) { - return contextWindow; + try { + if (!response.ok) { + return undefined; + } + const data = (await response.json()) as { model_info?: Record<string, unknown> }; + if (!data.model_info) { + return undefined; + } + for (const [key, value] of Object.entries(data.model_info)) { + if ( + key.endsWith(".context_length") && + typeof value === "number" && + Number.isFinite(value) + ) { + const contextWindow = Math.floor(value); + if (contextWindow > 0) { + return contextWindow; + } } } + return undefined; + } finally { + await release(); } - return undefined; } catch { return undefined; } @@ -112,15 +145,24 @@ export async function fetchOllamaModels( ): Promise<{ reachable: boolean; models: OllamaTagModel[] }> { try { const apiBase = resolveOllamaApiBase(baseUrl); - const response = await fetch(`${apiBase}/api/tags`, { - signal: AbortSignal.timeout(5000), + const { response, release } = await fetchWithSsrFGuard({ + url: `${apiBase}/api/tags`, + init: { + signal: AbortSignal.timeout(5000), + }, + policy: buildOllamaBaseUrlSsrFPolicy(apiBase), + auditContext: "ollama-provider-models.tags", }); - if (!response.ok) { - return { reachable: true, models: [] }; + try { + if (!response.ok) { + return { reachable: true, models: [] }; + } + const data = (await response.json()) as OllamaTagsResponse; + const models = (data.models ?? []).filter((m) => m.name); + return { reachable: true, models }; + } finally { + await release(); } - const data = (await response.json()) as OllamaTagsResponse; - const models = (data.models ?? []).filter((m) => m.name); - return { reachable: true, models }; } catch { return { reachable: false, models: [] }; }
extensions/ollama/src/setup.ts+91 −71 modified@@ -3,8 +3,10 @@ import { upsertAuthProfileWithLock } from "openclaw/plugin-sdk/provider-auth"; import { applyAgentDefaultModelPrimary } from "openclaw/plugin-sdk/provider-onboard"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; import { WizardCancelledError, type WizardPrompter } from "openclaw/plugin-sdk/setup"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MODEL } from "./defaults.js"; import { + buildOllamaBaseUrlSsrFPolicy, buildOllamaModelDefinition, enrichOllamaModelsWithContext, fetchOllamaModels, @@ -64,18 +66,27 @@ function formatOllamaPullStatus(status: string): { text: string; hidePercent: bo async function checkOllamaCloudAuth(baseUrl: string): Promise<OllamaCloudAuthResult> { try { const apiBase = resolveOllamaApiBase(baseUrl); - const response = await fetch(`${apiBase}/api/me`, { - method: "POST", - signal: AbortSignal.timeout(5000), + const { response, release } = await fetchWithSsrFGuard({ + url: `${apiBase}/api/me`, + init: { + method: "POST", + signal: AbortSignal.timeout(5000), + }, + policy: buildOllamaBaseUrlSsrFPolicy(apiBase), + auditContext: "ollama-setup.me", }); - if (response.status === 401) { - const data = (await response.json()) as { signin_url?: string }; - return { signedIn: false, signinUrl: data.signin_url }; - } - if (!response.ok) { - return { signedIn: false }; + try { + if (response.status === 401) { + const data = (await response.json()) as { signin_url?: string }; + return { signedIn: false, signinUrl: data.signin_url }; + } + if (!response.ok) { + return { signedIn: false }; + } + return { signedIn: true }; + } finally { + await release(); } - return { signedIn: true }; } catch { return { signedIn: false }; } @@ -98,82 +109,91 @@ async function pullOllamaModelCore(params: { const baseUrl = resolveOllamaApiBase(params.baseUrl); const modelName = normalizeOllamaModelName(params.modelName) ?? params.modelName.trim(); try { - const response = await fetch(`${baseUrl}/api/pull`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: modelName }), + const { response, release } = await fetchWithSsrFGuard({ + url: `${baseUrl}/api/pull`, + init: { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: modelName }), + }, + policy: buildOllamaBaseUrlSsrFPolicy(baseUrl), + auditContext: "ollama-setup.pull", }); - if (!response.ok) { - return { ok: false, message: `Failed to download ${modelName} (HTTP ${response.status})` }; - } - if (!response.body) { - return { ok: false, message: `Failed to download ${modelName} (no response body)` }; - } + try { + if (!response.ok) { + return { ok: false, message: `Failed to download ${modelName} (HTTP ${response.status})` }; + } + if (!response.body) { + return { ok: false, message: `Failed to download ${modelName} (no response body)` }; + } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - const layers = new Map<string, { total: number; completed: number }>(); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const layers = new Map<string, { total: number; completed: number }>(); - const parseLine = (line: string): OllamaPullResult => { - const trimmed = line.trim(); - if (!trimmed) { - return { ok: true }; - } - try { - const chunk = JSON.parse(trimmed) as OllamaPullChunk; - if (chunk.error) { - return { ok: false, message: `Download failed: ${chunk.error}` }; - } - if (!chunk.status) { + const parseLine = (line: string): OllamaPullResult => { + const trimmed = line.trim(); + if (!trimmed) { return { ok: true }; } - if (chunk.total && chunk.completed !== undefined) { - layers.set(chunk.status, { total: chunk.total, completed: chunk.completed }); - let totalSum = 0; - let completedSum = 0; - for (const layer of layers.values()) { - totalSum += layer.total; - completedSum += layer.completed; + try { + const chunk = JSON.parse(trimmed) as OllamaPullChunk; + if (chunk.error) { + return { ok: false, message: `Download failed: ${chunk.error}` }; + } + if (!chunk.status) { + return { ok: true }; } - params.onStatus?.( - chunk.status, - totalSum > 0 ? Math.round((completedSum / totalSum) * 100) : null, - ); - } else { - params.onStatus?.(chunk.status, null); + if (chunk.total && chunk.completed !== undefined) { + layers.set(chunk.status, { total: chunk.total, completed: chunk.completed }); + let totalSum = 0; + let completedSum = 0; + for (const layer of layers.values()) { + totalSum += layer.total; + completedSum += layer.completed; + } + params.onStatus?.( + chunk.status, + totalSum > 0 ? Math.round((completedSum / totalSum) * 100) : null, + ); + } else { + params.onStatus?.(chunk.status, null); + } + } catch { + // Ignore malformed streaming lines from Ollama. } - } catch { - // Ignore malformed streaming lines from Ollama. - } - return { ok: true }; - }; + return { ok: true }; + }; - for (;;) { - const { done, value } = await reader.read(); - if (done) { - break; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + const parsed = parseLine(line); + if (!parsed.ok) { + return parsed; + } + } } - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - const parsed = parseLine(line); + + const trailing = buffer.trim(); + if (trailing) { + const parsed = parseLine(trailing); if (!parsed.ok) { return parsed; } } - } - const trailing = buffer.trim(); - if (trailing) { - const parsed = parseLine(trailing); - if (!parsed.ok) { - return parsed; - } + return { ok: true }; + } finally { + await release(); } - - return { ok: true }; } catch (err) { const reason = err instanceof Error ? err.message : String(err); return { ok: false, message: `Failed to download ${modelName}: ${reason}` };
src/plugins/marketplace.test.ts+28 −0 modified@@ -5,12 +5,35 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; const installPluginFromPathMock = vi.fn(); +const fetchWithSsrFGuardMock = vi.hoisted(() => + vi.fn(async (params: { url: string; init?: RequestInit }) => { + // Keep unit tests focused on guarded call sites, not AbortSignal timer behavior. + const { signal: _signal, ...init } = params.init ?? {}; + const response = await fetch(params.url, init); + return { + response, + finalUrl: params.url, + release: async () => { + await response.body?.cancel().catch(() => undefined); + }, + }; + }), +); const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn()); vi.mock("./install.js", () => ({ installPluginFromPath: (...args: unknown[]) => installPluginFromPathMock(...args), })); +vi.mock("../infra/net/fetch-guard.js", async (importOriginal) => { + const actual = await importOriginal<typeof import("../infra/net/fetch-guard.js")>(); + return { + ...actual, + fetchWithSsrFGuard: (params: { url: string; init?: RequestInit }) => + fetchWithSsrFGuardMock(params), + }; +}); + vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), })); @@ -144,6 +167,7 @@ function expectLocalMarketplaceInstallResult(params: { describe("marketplace plugins", () => { afterEach(() => { + fetchWithSsrFGuardMock.mockClear(); installPluginFromPathMock.mockReset(); runCommandWithTimeoutMock.mockReset(); vi.unstubAllGlobals(); @@ -293,6 +317,10 @@ describe("marketplace plugins", () => { ok: false, error: "failed to download https://example.com/frontend-design.tgz: empty response body", }); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({ + url: "https://example.com/frontend-design.tgz", + auditContext: "marketplace-plugin-download", + }); expect(installPluginFromPathMock).not.toHaveBeenCalled(); }); });
src/plugins/marketplace.ts+28 −20 modified@@ -5,6 +5,7 @@ import path from "node:path"; import { Writable } from "node:stream"; import { resolveArchiveKind } from "../infra/archive.js"; import { resolveOsHomeRelativePath } from "../infra/home-dir.js"; +import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; import { installPluginFromPath, type InstallPluginResult } from "./install.js"; @@ -589,27 +590,34 @@ async function downloadUrlToTempFile(url: string): Promise< error: string; } > { - const response = await fetch(url); - if (!response.ok) { - return { ok: false, error: `failed to download ${url}: HTTP ${response.status}` }; - } - if (!response.body) { - return { ok: false, error: `failed to download ${url}: empty response body` }; - } + const { response, release } = await fetchWithSsrFGuard({ + url, + auditContext: "marketplace-plugin-download", + }); + try { + if (!response.ok) { + return { ok: false, error: `failed to download ${url}: HTTP ${response.status}` }; + } + if (!response.body) { + return { ok: false, error: `failed to download ${url}: empty response body` }; + } - const pathname = new URL(url).pathname; - const fileName = path.basename(pathname) || "plugin.tgz"; - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-download-")); - const targetPath = path.join(tmpDir, fileName); - const fileStream = createWriteStream(targetPath); - await response.body.pipeTo(Writable.toWeb(fileStream)); - return { - ok: true, - path: targetPath, - cleanup: async () => { - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); - }, - }; + const pathname = new URL(url).pathname; + const fileName = path.basename(pathname) || "plugin.tgz"; + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-download-")); + const targetPath = path.join(tmpDir, fileName); + const fileStream = createWriteStream(targetPath); + await response.body.pipeTo(Writable.toWeb(fileStream)); + return { + ok: true, + path: targetPath, + cleanup: async () => { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); + }, + }; + } finally { + await release(); + } } function ensureInsideMarketplaceRoot(
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
6- github.com/openclaw/openclaw/commit/8deb9522f3d2680820588b190adb4a2a52f3670bnvdPatchWEB
- github.com/advisories/GHSA-9q7v-8mr7-g23pghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-9q7v-8mr7-g23pnvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-41302ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-server-side-request-forgery-via-unguarded-fetch-in-marketplace-plugin-downloadnvdThird Party AdvisoryWEB
- github.com/openclaw/openclaw/releases/tag/v2026.3.31ghsaWEB
News mentions
0No linked articles in our index yet.