Skip to content

Commit 71bf584

Browse files
Alix-007QiuYuang
authored andcommitted
fix(ollama): bound model-discovery JSON response reads (openclaw#96027)
* fix(ollama): bound model-discovery JSON response reads The /api/tags and /api/show discovery reads in extensions/ollama/src/provider-models.ts parsed their HTTP responses with an unbounded await response.json(). Ollama base URLs are user-supplied and can point at remote/cloud endpoints, so a hostile or buggy server (or one reachable via SSRF) could stream an unbounded or never-ending JSON body and drive model discovery into OOM. Route both reads through the shared @openclaw/media-core byte-bounded reader (readResponseWithLimit, re-exported via openclaw/plugin-sdk/response-limit-runtime) under a single 16 MiB cap before JSON.parse, cancelling the stream on overflow. Overflow throws a bounded error that the existing fail-soft handlers swallow, so a capped endpoint degrades gracefully: /api/tags returns { reachable: false, models: [] } and /api/show returns {}. Symmetric counterpart to the openclaw#95103/openclaw#95108 response-limit campaign. AI-assisted. * fix(ollama): reuse shared bounded JSON reader for model discovery Replace the local readOllamaDiscoveryJson helper with the shared readProviderJsonResponse (from openclaw/plugin-sdk/provider-http), which already enforces the 16 MiB cap, cancels the stream on overflow, and wraps malformed JSON with the caller label. The /api/tags and /api/show discovery reads now go through it directly while keeping the existing fail-soft handlers ({ reachable: false, models: [] } and {}). Add a focused regression test: when a discovery stream exceeds the JSON byte cap, fetchOllamaModels returns { reachable: false, models: [] }, queryOllamaModelShowInfo returns {}, and the bounded reader cancels the body mid-flight so less than the full advertised stream is read.
1 parent d38d2d4 commit 71bf584

2 files changed

Lines changed: 62 additions & 3 deletions

File tree

extensions/ollama/src/provider-models.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import {
55
buildOllamaProvider,
66
buildOllamaModelDefinition,
77
enrichOllamaModelsWithContext,
8+
fetchOllamaModels,
89
parseOllamaNumCtxParameter,
10+
queryOllamaModelShowInfo,
911
resetOllamaModelShowInfoCacheForTest,
1012
resolveOllamaApiBase,
1113
type OllamaTagModel,
@@ -380,4 +382,57 @@ describe("ollama provider models", () => {
380382
expect(parseOllamaNumCtxParameter('stop "<|eot_id|>"')).toBeUndefined();
381383
expect(parseOllamaNumCtxParameter({ num_ctx: 8192 })).toBeUndefined();
382384
});
385+
386+
it("fails soft and stops reading when discovery streams exceed the JSON byte cap", async () => {
387+
// Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels
388+
// the stream mid-flight; if the cap were removed the reader would buffer the whole payload.
389+
const ONE_MIB = 1024 * 1024;
390+
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
391+
const chunk = new Uint8Array(ONE_MIB);
392+
393+
let bytesPulled = 0;
394+
let canceled = false;
395+
const makeOversizedJsonResponse = (): Response => {
396+
bytesPulled = 0;
397+
canceled = false;
398+
let pulled = 0;
399+
const body = new ReadableStream<Uint8Array>({
400+
pull(controller) {
401+
if (pulled >= TOTAL_CHUNKS) {
402+
controller.close();
403+
return;
404+
}
405+
pulled += 1;
406+
bytesPulled += chunk.length;
407+
controller.enqueue(chunk);
408+
},
409+
cancel() {
410+
canceled = true;
411+
},
412+
});
413+
return new Response(body, {
414+
status: 200,
415+
headers: { "Content-Type": "application/json" },
416+
});
417+
};
418+
419+
vi.stubGlobal(
420+
"fetch",
421+
vi.fn(async () => makeOversizedJsonResponse()),
422+
);
423+
const tags = await fetchOllamaModels("http://127.0.0.1:11434");
424+
expect(tags).toEqual({ reachable: false, models: [] });
425+
expect(canceled).toBe(true);
426+
// Only the bounded prefix is pulled, never the full advertised 32 MiB stream.
427+
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
428+
429+
vi.stubGlobal(
430+
"fetch",
431+
vi.fn(async () => makeOversizedJsonResponse()),
432+
);
433+
const showInfo = await queryOllamaModelShowInfo("http://127.0.0.1:11434", "evil-model:latest");
434+
expect(showInfo).toEqual({});
435+
expect(canceled).toBe(true);
436+
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
437+
});
383438
});

extensions/ollama/src/provider-models.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { createHash } from "node:crypto";
33
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
44
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard";
5+
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
56
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
67
import {
78
OLLAMA_DEFAULT_BASE_URL,
@@ -146,11 +147,11 @@ export async function queryOllamaModelShowInfo(
146147
if (!response.ok) {
147148
return {};
148149
}
149-
const data = (await response.json()) as {
150+
const data = await readProviderJsonResponse<{
150151
model_info?: Record<string, unknown>;
151152
capabilities?: unknown;
152153
parameters?: unknown;
153-
};
154+
}>(response, "ollama-provider-models.show");
154155

155156
let contextWindow: number | undefined;
156157
if (data.model_info) {
@@ -314,7 +315,10 @@ export async function fetchOllamaModels(
314315
if (!response.ok) {
315316
return { reachable: true, models: [] };
316317
}
317-
const data = (await response.json()) as OllamaTagsResponse;
318+
const data = await readProviderJsonResponse<OllamaTagsResponse>(
319+
response,
320+
"ollama-provider-models.tags",
321+
);
318322
const models = (data.models ?? []).filter((m) => m.name);
319323
return { reachable: true, models };
320324
} finally {

0 commit comments

Comments
 (0)