Skip to content

fix(signal): bound container REST reads so a hostile signal-cli-rest-api host cannot exhaust memory#97539

Merged
vincentkoc merged 1 commit into
openclaw:mainfrom
Alix-007:fix/signal-bound-container-reads
Jun 29, 2026
Merged

fix(signal): bound container REST reads so a hostile signal-cli-rest-api host cannot exhaust memory#97539
vincentkoc merged 1 commit into
openclaw:mainfrom
Alix-007:fix/signal-bound-container-reads

Conversation

@Alix-007

@Alix-007 Alix-007 commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

AI-assisted: implemented with Claude Code (Anthropic). Degree: Claude Code wrote the source change, the tests, and a standalone real-HTTP proof harness under human direction; a human reviewed the diff, ran the focused test suite, the type-check, and the real-behavior proof (including a load-bearing mutation/negative-control), and can explain each line. Session prompt: bound the central Signal container RPC reads against OOM by reusing existing repo helpers, then prove it against a real HTTP server with a negative control, without touching any shared test mocks or shared infra.

Body follows the live .github/pull_request_template.md (What Problem / Why / User Impact / Evidence). Security & Privacy and Compatibility notes are folded into the relevant sections.

What Problem This Solves

Fixes an issue where an operator running OpenClaw's Signal channel in container mode (the bbernhard signal-cli-rest-api container) could have the OpenClaw runtime's memory exhausted by a single oversized or runaway HTTP response from that container.

The Signal container is an external, user-configured HTTP dependency that OpenClaw does not control. The central RPC helper containerRestRequest read both the error body and the success body with res.text(), which buffers the entire response into memory before parsing — with no byte ceiling, even when the response carries no Content-Length. A hostile, compromised, or merely buggy container that streams an unbounded body could push the runtime into an out-of-memory condition. Because send, sendTyping, sendReceipt, sendReaction/removeReaction, and version all funnel through containerRestRequest, every container-mode Signal operation was exposed — not just attachment downloads, which were already bounded.

Why This Change Was Made

Both reads in containerRestRequest are now bounded with the same readers already used elsewhere in this file and across the provider stack, so the fix adds no new size-limit machinery:

  • Success body: readProviderTextResponse(res, "Signal REST") — reads under the shared 16 MiB provider cap (PROVIDER_TEXT_RESPONSE_MAX_BYTES) and fails closed (rejects, cancelling the stream) on overflow. The existing empty-body → undefined and JSON.parse semantics are preserved exactly.
  • Error body: readResponseTextLimited(res) — reads only a bounded diagnostic prefix for the thrown error message instead of the full body. This matches how the shared provider error path and other extensions (openrouter/chutes/minimax/qqbot) already read error bodies.

This mirrors the attachment path in the same file (readCappedResponseBufferreadResponseWithLimit) and the JSON readers in the recent response-limit campaign (#95103/#96495/#96873). Non-goal: this PR does not change protocol behavior, timeouts, or the 16 MiB cap value; it only changes how the body bytes are read.

Compatibility (please confirm the tradeoff): No public API, config, or behavior change for well-behaved containers — same return values, same error strings, same undefined-on-empty and JSON.parse-on-malformed behavior, and legitimate large responses (verified up to ~4 MiB and proven against the live cap) still read in full. The only observable difference is that a pathologically large body (> 16 MiB) now fails the single affected operation with a bounded error instead of buffering unbounded. This is the deliberate fail-closed tradeoff for the untrusted container boundary; maintainers are asked to accept it. No schema, CLI, or migration impact (the test-only .test.ts change is not a data-model/serialized-state change).

User Impact

Operators running the Signal channel in container mode are no longer exposed to an OOM/denial-of-service vector from their signal-cli-rest-api endpoint. A malformed or oversized response now fails the single affected operation with a clear bounded error instead of risking the whole runtime. No action required; existing setups behave identically for normal responses.

Security & Privacy: defense-in-depth hardening of an untrusted external boundary (the container response); reduces a memory-exhaustion DoS surface. No new data is logged or transmitted — the error path now logs less (a bounded prefix rather than the full body). No secrets or PII handling changes.

Blast radius: low. Single source file + its own co-located test file; no shared SDK, no shared HTTP test mocks, no shared infra touched. Companion to the merged #96495/#96873 response-limit work, reusing the exact same readers.

Evidence

1) Real-behavior proof (live HTTP, end-to-end, no mocks)

A real node:http server (createServer) bound to 127.0.0.1 streams a body with no Content-Length in 64 KiB chunks, driving the real exported containerRestRequest end-to-end over a real socket on Node v22.22.0 (only fetch/undici is real; nothing is mocked). It includes a negative control running the OLD await res.text() path against the same body:

[setup] real http server, cap=16777216 bytes, hostile body target=25165824 bytes

[case 1] oversized success body, NO content-length
  ok: real containerRestRequest rejected on oversized success body (Signal REST: text response exceeds 16777216 bytes)
  ok: bounded error message names the 16 MiB cap
  ok: server was cut off early (cancelled), NOT drained to the full hostile body (server pushed 18874368 bytes before cancel; full body would be 25165824)

[negative control] OLD `await res.text()` buffers the whole body
  ok: unbounded read pulled the FULL hostile body (old path buffered 25165824 bytes — >23x the bounded read), proving the cap is load-bearing
  ok: bounded path read far fewer bytes than the unbounded path (bounded 18874368 vs unbounded 25165824)

[case 2] oversized 500 error body, NO content-length
  ok: real containerRestRequest rejected on 500
  ok: error message starts with `Signal REST 500:`
  ok: error message is bounded well below the 1 MiB+ payload (error length 16401 bytes)

[case 3] normal small success body
  ok: small valid JSON parses unchanged (no truncation) ({"version":"1.2.3","ok":true})

ALL PROOF ASSERTIONS PASSED
  • Fail-closed proof: the oversized success body rejects with Signal REST: text response exceeds 16777216 bytes; the server is cancelled after ~18 MiB instead of draining the full 24 MiB body.
  • Load-bearing negative control: the OLD unbounded res.text() buffers the full 25,165,824 bytes (>23× the bounded read), proving the cap is what stops the OOM.
  • No regression: the small valid body parses unchanged.

2) Mutation test (proves the tests/fix are load-bearing)

Reverting the success path to const text = await res.text() (fail-open) makes the real-HTTP proof report FAILURES: 3 and the focused Vitest suite go red (20 failed, including fails closed when the success body exceeds the response size cap and parses a large but under-cap success body without truncation). Restoring the fix returns to all-green. The guards are not vacuous.

3) Focused test suite

The tests drive the real containerRestRequest (the readers run real; only fetch is mocked via this file's existing local mock plus a local bodyStream helper — the shared provider HTTP test mocks were intentionally not modified). Beyond the oversized success/error cases, this adds a large-but-under-cap round-trip guard (~4 MiB valid JSON, exact count preserved) and an empty-body → undefined contract guard:

$ node scripts/run-vitest.mjs run extensions/signal/src/client-container.test.ts
 Test Files  1 passed (1)
      Tests  53 passed (53)

4) Type-check

$ node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.extensions.test.json
(exit 0)

AI-assisted.

@openclaw-barnacle openclaw-barnacle Bot added channel: signal Channel integration: signal size: S labels Jun 28, 2026
@clawsweeper

clawsweeper Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed June 28, 2026, 10:25 PM ET / 02:25 UTC.

Summary
The PR replaces Signal container REST success and error res.text() reads with shared bounded readers and adds focused stream-backed tests.

PR surface: Source +9, Tests +107. Total +116 across 2 files.

Reproducibility: yes. Source inspection shows current main and v2026.6.10 still call res.text() for Signal container REST success and error bodies, and the PR body includes live Node HTTP proof with an oversized no-Content-Length response plus an old-path negative control.

Review metrics: 1 noteworthy metric.

  • Bounded REST body reads: 1 success read capped; 1 error read prefix-capped. These are the behavior-changing reads maintainers need to notice before accepting the fail-closed compatibility tradeoff.

Stored data model
Persistent data-model change detected: serialized state: extensions/signal/src/client-container.test.ts. Confirm migration or upgrade compatibility proof before merge.

Merge readiness
Overall: 🐚 platinum hermit
Proof: 🦀 challenger crab
Patch quality: 🐚 platinum hermit
Result: ready for maintainer review.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • Get maintainer acceptance that Signal container REST responses over the shared 16 MiB provider text cap should fail the operation rather than buffer unbounded.

Risk before merge

  • [P1] Compatibility: a legitimate Signal container REST success body over the shared 16 MiB provider text cap would now fail the single operation instead of buffering, so maintainers should explicitly accept that fail-closed behavior.

Maintainer options:

  1. Accept the shared cap (recommended)
    Keep this PR shape and merge once maintainers agree that oversized Signal container REST JSON should fail the operation instead of risking process memory exhaustion.
  2. Request a Signal-specific cap
    Ask for a documented Signal-specific reader cap only if owners believe real container REST success responses can legitimately exceed the shared provider text limit.

Next step before merge

  • The only remaining blocker is maintainer acceptance of the compatibility tradeoff, not an automation-repairable code defect.

Security
Cleared: The diff reduces an untrusted Signal container response memory-exhaustion path and adds no dependencies, CI changes, secret handling, or new code-execution surface.

Review details

Best possible solution:

Merge the central bounded-read fix after maintainers accept the shared 16 MiB fail-closed cap for pathological Signal container REST responses.

Do we have a high-confidence way to reproduce the issue?

Yes. Source inspection shows current main and v2026.6.10 still call res.text() for Signal container REST success and error bodies, and the PR body includes live Node HTTP proof with an oversized no-Content-Length response plus an old-path negative control.

Is this the best way to solve the issue?

Yes. Fixing the central Signal container REST helper with existing bounded readers is narrower and more maintainable than patching each caller or inventing Signal-specific read machinery; the remaining question is maintainer acceptance of the cap.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 63fe5c74021d.

Label changes

Label justifications:

  • P2: This is focused Signal-channel security hardening for a memory-exhaustion denial-of-service path with limited blast radius.
  • merge-risk: 🚨 compatibility: The PR intentionally changes oversized Signal container REST responses from unbounded buffering to failure at the shared 16 MiB provider text cap.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦀 challenger crab and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR body includes copied live Node HTTP output showing oversized success/error responses are bounded, an old-path negative control buffers the full body, and normal small JSON still parses.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body includes copied live Node HTTP output showing oversized success/error responses are bounded, an old-path negative control buffers the full body, and normal small JSON still parses.
Evidence reviewed

PR surface:

Source +9, Tests +107. Total +116 across 2 files.

View PR surface stats
Area Files Added Removed Net
Source 1 11 2 +9
Tests 1 126 19 +107
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 2 137 21 +116

What I checked:

Likely related people:

  • Hua688: Merged PR history shows Signal: add REST API support for containerized deployments #16085 introduced the Signal container REST implementation and its tests. (role: feature introducer; confidence: high; commits: dff4a04c1f5f; files: extensions/signal/src/client-container.ts, extensions/signal/src/client-container.test.ts, extensions/signal/src/client-adapter.ts)
  • steipete: Recent same-file commits hardened Signal container timeout handling, attachment content length, and send timestamp validation. (role: recent area contributor; confidence: high; commits: f8ad20b87e43, 2cb8ac1596e3, 58e52e942475; files: extensions/signal/src/client-container.ts, extensions/signal/src/client-container.test.ts)
  • vincentkoc: Recent same-file commits handled status-only response-body cleanup and receive WebSocket pre-open behavior near this container boundary. (role: recent area contributor; confidence: medium; commits: fcec95ffd7a5, 259f071a9318; files: extensions/signal/src/client-container.ts, extensions/signal/src/client-container.test.ts)
  • Alix-007: Beyond this PR, merged fix(openrouter): bound video response reads #96873 landed adjacent response-read hardening for OpenRouter video using the same bounded-reader family. (role: adjacent response-limit contributor; confidence: medium; commits: 48f34b1d4df7; files: extensions/openrouter/video-generation-provider.ts, extensions/openrouter/video-generation-provider.test.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

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
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal backlog priority with limited blast radius. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. labels Jun 28, 2026
@Alix-007
Alix-007 force-pushed the fix/signal-bound-container-reads branch from b9e7e7e to 01197e6 Compare June 28, 2026 23:13
@Alix-007 Alix-007 changed the title fix(signal): cap container REST responses so a hostile signal-cli-rest-api host cannot exhaust memory fix(signal): bound container REST reads so a hostile signal-cli-rest-api host cannot exhaust memory Jun 28, 2026
@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jun 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: signal Channel integration: signal merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. P2 Normal backlog priority with limited blast radius. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. size: S status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants