fix(ollama): bound model-discovery JSON response reads#96027
Conversation
|
@clawsweeper review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
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.
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.
fc1804c to
8876908
Compare
|
Codex review: needs maintainer review before merge. Reviewed June 23, 2026, 4:51 PM ET / 20:51 UTC. Summary PR surface: Source +4, Tests +55. Total +59 across 2 files. Reproducibility: yes. Current main source shows direct Review metrics: 1 noteworthy metric.
Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Risk before merge
Maintainer options:
Next step before merge
Security Review detailsBest possible solution: Land the bounded-reader change if maintainers accept the shared provider JSON cap for Ollama discovery; otherwise add a narrowly justified Ollama-specific cap override with matching overflow and happy-path tests. Do we have a high-confidence way to reproduce the issue? Yes. Current main source shows direct Is this the best way to solve the issue? Yes. Reusing AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against cc981f8a7303. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +4, Tests +55. Total +59 across 2 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
|
Merge-ready from my review. This follows the same response-body cap pattern as #96042 and the sibling Exa/Parallel/Ollama PRs: replace unbounded success JSON reads from untrusted provider discovery endpoints with the existing bounded reader, preserve normal small-response behavior, and add focused overflow/cancel coverage. I ran local autoreview in the PR worktree against Best-fix verdict: this is the right narrow fix because it reuses the existing bounded provider JSON helper instead of adding a broader provider refactor. |
The Copilot usage read in extensions/github-copilot/usage.ts parsed its HTTP response with an unbounded await res.json(). A hostile or buggy api.github.com proxy (the proxy endpoint is derived from a user-supplied token) could stream an unbounded JSON body and drive the usage snapshot into OOM. Route the read through the shared readProviderJsonResponse (from openclaw/plugin-sdk/provider-http), which enforces the 16 MiB byte cap, cancels the stream on overflow, and wraps malformed JSON with the caller label. Same no-helper-import-to-bounded-reader shape as the openclaw#96027 / openclaw#96038 response-limit work. Add a focused regression test: when the usage stream exceeds the JSON byte cap, fetchCopilotUsage rejects with a bounded-overflow error and the reader cancels the body mid-flight instead of buffering the full advertised stream. Existing parse/HTTP-error cases keep passing.
The batch status read (fetchVoyageBatchStatus) parsed its response with an
unbounded await res.json(), and the batch error-file read (readVoyageBatchError)
buffered the whole body via await res.text(). On top of that, the non-OK
(4xx/5xx) diagnostic body was still read unbounded: assertVoyageResponseOk did
await res.text() before throwing, and the non-OK output-file branch in
runVoyageEmbeddingBatches did the same. Voyage base URLs are user-supplied and
reachable via SSRF, so a misbehaving or hostile endpoint could stream an
unbounded body into memory on any of these paths before parsing.
Route the status JSON through the shared readProviderJsonResponse, the error
file through readResponseWithLimit, and now the non-OK diagnostic body through
readResponseWithLimit as well, all under a single 16 MiB cap, cancelling the
stream on overflow before decode/parse. assertVoyageResponseOk preserves its
original "${context}: ${status} ${text}" diagnostic shape for under-cap bodies
and throws a bounded "(error body exceeds <N> bytes)" on overflow; the non-OK
output-file branch now reuses it instead of a duplicate unbounded read. The
existing error-file fail-soft handling (formatUnavailableBatchError) is
preserved, so a capped endpoint degrades gracefully. The submit path already
bounds its body via postJsonWithRetry/maxResponseBytes and is left untouched.
Symmetric counterpart to the openclaw#96027/openclaw#96038 response-limit campaign.
* 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.
#96608) The batch status read (fetchVoyageBatchStatus) parsed its response with an unbounded await res.json(), and the batch error-file read (readVoyageBatchError) buffered the whole body via await res.text(). On top of that, the non-OK (4xx/5xx) diagnostic body was still read unbounded: assertVoyageResponseOk did await res.text() before throwing, and the non-OK output-file branch in runVoyageEmbeddingBatches did the same. Voyage base URLs are user-supplied and reachable via SSRF, so a misbehaving or hostile endpoint could stream an unbounded body into memory on any of these paths before parsing. Route the status JSON through the shared readProviderJsonResponse, the error file through readResponseWithLimit, and now the non-OK diagnostic body through readResponseWithLimit as well, all under a single 16 MiB cap, cancelling the stream on overflow before decode/parse. assertVoyageResponseOk preserves its original "${context}: ${status} ${text}" diagnostic shape for under-cap bodies and throws a bounded "(error body exceeds <N> bytes)" on overflow; the non-OK output-file branch now reuses it instead of a duplicate unbounded read. The existing error-file fail-soft handling (formatUnavailableBatchError) is preserved, so a capped endpoint degrades gracefully. The submit path already bounds its body via postJsonWithRetry/maxResponseBytes and is left untouched. Symmetric counterpart to the #96027/#96038 response-limit campaign.
The Copilot usage read in extensions/github-copilot/usage.ts parsed its HTTP response with an unbounded await res.json(). A hostile or buggy api.github.com proxy (the proxy endpoint is derived from a user-supplied token) could stream an unbounded JSON body and drive the usage snapshot into OOM. Route the read through the shared readProviderJsonResponse (from openclaw/plugin-sdk/provider-http), which enforces the 16 MiB byte cap, cancels the stream on overflow, and wraps malformed JSON with the caller label. Same no-helper-import-to-bounded-reader shape as the #96027 / #96038 response-limit work. Add a focused regression test: when the usage stream exceeds the JSON byte cap, fetchCopilotUsage rejects with a bounded-overflow error and the reader cancels the body mid-flight instead of buffering the full advertised stream. Existing parse/HTTP-error cases keep passing.
openclaw#96608) The batch status read (fetchVoyageBatchStatus) parsed its response with an unbounded await res.json(), and the batch error-file read (readVoyageBatchError) buffered the whole body via await res.text(). On top of that, the non-OK (4xx/5xx) diagnostic body was still read unbounded: assertVoyageResponseOk did await res.text() before throwing, and the non-OK output-file branch in runVoyageEmbeddingBatches did the same. Voyage base URLs are user-supplied and reachable via SSRF, so a misbehaving or hostile endpoint could stream an unbounded body into memory on any of these paths before parsing. Route the status JSON through the shared readProviderJsonResponse, the error file through readResponseWithLimit, and now the non-OK diagnostic body through readResponseWithLimit as well, all under a single 16 MiB cap, cancelling the stream on overflow before decode/parse. assertVoyageResponseOk preserves its original "${context}: ${status} ${text}" diagnostic shape for under-cap bodies and throws a bounded "(error body exceeds <N> bytes)" on overflow; the non-OK output-file branch now reuses it instead of a duplicate unbounded read. The existing error-file fail-soft handling (formatUnavailableBatchError) is preserved, so a capped endpoint degrades gracefully. The submit path already bounds its body via postJsonWithRetry/maxResponseBytes and is left untouched. Symmetric counterpart to the openclaw#96027/openclaw#96038 response-limit campaign.
The Copilot usage read in extensions/github-copilot/usage.ts parsed its HTTP response with an unbounded await res.json(). A hostile or buggy api.github.com proxy (the proxy endpoint is derived from a user-supplied token) could stream an unbounded JSON body and drive the usage snapshot into OOM. Route the read through the shared readProviderJsonResponse (from openclaw/plugin-sdk/provider-http), which enforces the 16 MiB byte cap, cancels the stream on overflow, and wraps malformed JSON with the caller label. Same no-helper-import-to-bounded-reader shape as the openclaw#96027 / openclaw#96038 response-limit work. Add a focused regression test: when the usage stream exceeds the JSON byte cap, fetchCopilotUsage rejects with a bounded-overflow error and the reader cancels the body mid-flight instead of buffering the full advertised stream. Existing parse/HTTP-error cases keep passing.
* 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.
openclaw#96608) The batch status read (fetchVoyageBatchStatus) parsed its response with an unbounded await res.json(), and the batch error-file read (readVoyageBatchError) buffered the whole body via await res.text(). On top of that, the non-OK (4xx/5xx) diagnostic body was still read unbounded: assertVoyageResponseOk did await res.text() before throwing, and the non-OK output-file branch in runVoyageEmbeddingBatches did the same. Voyage base URLs are user-supplied and reachable via SSRF, so a misbehaving or hostile endpoint could stream an unbounded body into memory on any of these paths before parsing. Route the status JSON through the shared readProviderJsonResponse, the error file through readResponseWithLimit, and now the non-OK diagnostic body through readResponseWithLimit as well, all under a single 16 MiB cap, cancelling the stream on overflow before decode/parse. assertVoyageResponseOk preserves its original "${context}: ${status} ${text}" diagnostic shape for under-cap bodies and throws a bounded "(error body exceeds <N> bytes)" on overflow; the non-OK output-file branch now reuses it instead of a duplicate unbounded read. The existing error-file fail-soft handling (formatUnavailableBatchError) is preserved, so a capped endpoint degrades gracefully. The submit path already bounds its body via postJsonWithRetry/maxResponseBytes and is left untouched. Symmetric counterpart to the openclaw#96027/openclaw#96038 response-limit campaign.
The Copilot usage read in extensions/github-copilot/usage.ts parsed its HTTP response with an unbounded await res.json(). A hostile or buggy api.github.com proxy (the proxy endpoint is derived from a user-supplied token) could stream an unbounded JSON body and drive the usage snapshot into OOM. Route the read through the shared readProviderJsonResponse (from openclaw/plugin-sdk/provider-http), which enforces the 16 MiB byte cap, cancels the stream on overflow, and wraps malformed JSON with the caller label. Same no-helper-import-to-bounded-reader shape as the openclaw#96027 / openclaw#96038 response-limit work. Add a focused regression test: when the usage stream exceeds the JSON byte cap, fetchCopilotUsage rejects with a bounded-overflow error and the reader cancels the body mid-flight instead of buffering the full advertised stream. Existing parse/HTTP-error cases keep passing.
The fal music generation provider in extensions/fal/music-generation-provider.ts parsed its HTTP success response with an unbounded await response.json(). A hostile or buggy fal.ai endpoint — the base URL is user-configurable via models.providers.fal.baseUrl (resolved by resolveFalHttpRequestConfig in http-config.ts, defaulting to https://fal.run) — could stream an arbitrarily large JSON body into memory before parsing, forcing the music generation handler into OOM. Route the read through the shared readProviderJsonResponse (from openclaw/plugin-sdk/provider-http), which enforces the 16 MiB byte cap (PROVIDER_JSON_RESPONSE_MAX_BYTES), cancels the stream on overflow, and wraps malformed JSON with the caller label. The change is minimal — a single .json() call replaced by readProviderJsonResponse<unknown> — and preserves the existing downstream logic unchanged. Update the test suite: replace fake { json: async () => ... } response stubs with proper Response objects carrying JSON body streams via a new jsonBodyResponse() helper, so the real readProviderJsonResponse implementation actually exercises the byte-bounded reader under test. Preserve the real readProviderJsonResponse in the provider-http mock (via importOriginal) so the bounded reader streams and cancels oversized bodies. Add a focused regression test: when the music generation stream exceeds the JSON byte cap (32 MiB body, double the 16 MiB cap), generateMusic rejects with a "fal-music-generation: JSON response exceeds" error and the reader cancels the body mid-flight (ReadableStream.cancel fires, bytesPulled < 32 MiB). Existing parse/HTTP-error cases keep passing. Symmetric counterpart to the openclaw#96027/openclaw#96038/openclaw#96042/openclaw#96606/openclaw#96607 response-limit campaign.
The fal music generation provider in extensions/fal/music-generation-provider.ts parsed its HTTP success response with an unbounded await response.json(). A hostile or buggy fal.ai endpoint — the base URL is user-configurable via models.providers.fal.baseUrl (resolved by resolveFalHttpRequestConfig in http-config.ts, defaulting to https://fal.run) — could stream an arbitrarily large JSON body into memory before parsing, forcing the music generation handler into OOM. Route the read through the shared readProviderJsonResponse (from openclaw/plugin-sdk/provider-http), which enforces the 16 MiB byte cap (PROVIDER_JSON_RESPONSE_MAX_BYTES), cancels the stream on overflow, and wraps malformed JSON with the caller label. The change is minimal — a single .json() call replaced by readProviderJsonResponse<unknown> — and preserves the existing downstream logic unchanged. Update the test suite: replace fake { json: async () => ... } response stubs with proper Response objects carrying JSON body streams via a new jsonBodyResponse() helper, so the real readProviderJsonResponse implementation actually exercises the byte-bounded reader under test. Preserve the real readProviderJsonResponse in the provider-http mock (via importOriginal) so the bounded reader streams and cancels oversized bodies. Add a focused regression test: when the music generation stream exceeds the JSON byte cap (32 MiB body, double the 16 MiB cap), generateMusic rejects with a "fal-music-generation: JSON response exceeds" error and the reader cancels the body mid-flight (ReadableStream.cancel fires, bytesPulled < 32 MiB). Existing parse/HTTP-error cases keep passing. Symmetric counterpart to the openclaw#96027/openclaw#96038/openclaw#96042/openclaw#96606/openclaw#96607 response-limit campaign.
What Problem This Solves
extensions/ollama/src/provider-models.tsdiscovers Ollama models by calling/api/tags(
fetchOllamaModels) and/api/show(queryOllamaModelShowInfo), and parsed both responses withan unbounded
await response.json(). The Ollama base URL is user-supplied and can point at aremote/cloud endpoint (
https://ollama.com, a self-hosted box, or a host reachable via SSRF), sothese discovery responses are untrusted input. A hostile or buggy server can return a
Content-Length-less JSON body that streams forever (or many MiB), andresponse.json()buffers theentire payload before parsing — driving the provider model-discovery path into memory pressure / OOM
or hanging the discovery call. This is not a cosmetic cleanup: the read sits on the provider
onboarding/refresh path that runs against an attacker-influenceable endpoint.
Changes
OLLAMA_DISCOVERY_JSON_MAX_BYTEScap (16 MiB, aligned with the merged provider-JSONbound-stream PRs fix(agents): bound provider JSON response reads #95218 / fix(agents): bound OpenRouter model-scan catalog success body #95418 / fix(agents): bound OpenRouter model catalog response reads #95420) and a small
readOllamaDiscoveryJson<T>(response, label)helper that reads the success body via
readResponseWithLimitfrom@openclaw/media-core(re-exported through
openclaw/plugin-sdk/response-limit-runtime, the same path style this modulealready uses for
ssrf-runtime), cancelling the stream on overflow, thenJSON.parses the decodedbytes. No new abstraction — it reuses the shared bounded reader.
/api/tags(fetchOllamaModels) and/api/show(queryOllamaModelShowInfo) throughthe bounded reader instead of
await response.json().handling, so a capped endpoint degrades gracefully —
/api/tagsreturns{ reachable: false, models: [] }and/api/showreturns{}. No OOM, no hang.Real behavior proof
await response.json()on the two Ollama discovery reads lets anuntrusted endpoint stream an unbounded JSON body into memory.
node:httpserver (createServer) bound to127.0.0.1, returning aContent-Length-less body that streams 64 KiB chunks until the socket istorn down (would total ~64 MiB, 4× the 16 MiB cap). The real exported
fetchOllamaModelsandqueryOllamaModelShowInfowere driven against it (through the realfetchWithSsrFGuarddispatcher,with the loopback allowed by the existing
buildOllamaBaseUrlSsrFPolicy).node --import tsx proof.mts— start server → drive the real functions →read back fail-soft results + the server's observed bytes-sent / socket-abort counters → run a
negative control (raw unbounded read of the same endpoint) → flip the server to small valid bodies
and re-drive the real functions.
readResponseWithLimitthrew... exceeds 16777216 bytes; the serverobserved the socket aborted after ~17–19 MiB (≪ the ~64 MiB it would have sent), confirming the
stream was cancelled, not drained.
fetchOllamaModelsreturned{ reachable: false, models: [] }and
queryOllamaModelShowInforeturned{}(fail-soft intact).readResponseWithLimit throws bounded error on unbounded body→ threw, msgexceeds 16777216 bytesstream cancelled: server saw socket abort→ aborted=true, bytesSent=19333120 (< 33554432)fetchOllamaModels fail-soft on overflow→{ reachable:false, models:[] }queryOllamaModelShowInfo fail-soft on overflow→{}show stream cancelled→ aborted=true, bytesSent=17825792negative control: unbounded read buffers PAST the 16 MiB cap→ buffered 16799524 bytes (> cap)— proves the cap is load-bearing (without it the body keeps accumulating past 16 MiB).
happy path: small /api/tags still parsed→reachable:true, 2 modelshappy path: small /api/show still parsed→contextWindow=65536ollama.com); theuntrusted-body behavior is fully reproduced with a local streaming server, which is the same
transport path. The proof script is not committed.
Evidence
Regression suite + checks (worktree on
upstream/main):node scripts/run-vitest.mjs extensions/ollama/src/provider-models.test.ts --run→ 16/16 passedoxlint extensions/ollama/src/provider-models.ts→ exit 0, cleantsgo -p tsconfig.extensions.json→ no errors inextensions/ollama(only pre-existing,unrelated
extensions/qa-labcrablinemodule-resolution failures present on untouched main)This is the symmetric counterpart to the #95103 / #95108 response-limit campaign, applying the same
@openclaw/media-corebounded reader to the two Ollama provider-discovery JSON reads.Label: security
AI-assisted.