VYPR
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.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.312026.3.31

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.31

Patches

1
8deb9522f3d2

Guard marketplace and Ollama network requests (#57850)

https://github.com/openclaw/openclawJacob TomlinsonMar 30, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.