JSON-RPC: bound the per-connection read buffer so unterminated input cannot exhaust memory - #3861
Conversation
An unauthenticated client that sends no newline grows the QTcpSocket read buffer without bound (setReadBufferSize is never called), before auth, until std::bad_alloc aborts the process and drops all clients. Bound the buffer to 64 KiB per connection and drop a connection whose buffer fills with no complete line. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@mcfnord, pass your LLM this PR and get it to work out why it failed in the build: and work out what to put in the appropriate guidance notes to stop it happening again. |
NO need to prompt the LLM. Very likely just an issue on GitHubs side |
|
MY LLM WROTE: The blocking check was the "Verify C-like coding style" job, conclusion cancelled. The job record shows zero steps executed, no stored log, and inverted timestamps (started_at 2026-08-07 06:38 UTC, completed_at 2026-08-06 16:24 UTC) — it never ran. Re-running it directly needs repo write access, so the PR was closed and reopened to fire a fresh |
|
TIL: Closing and reopening a pull request can retrigger CI. Good to know. There was a GitHub Actions outage. Details: https://www.githubstatus.com/incidents/qcvjkzcs7j74 Seems like the LLM agent hasn't yet been trained to look at the CI log, and then deduce that they should reach for |
|
🤖 AI: The 64K was not derived from anything, so here is the number to derive it from. Two inputs compete for "largest legitimate request", and both land on Welcome message: every path goes through Chat: Measured against a server built from this branch, request line as sent on the wire:
So the largest legitimate request is 9,708 bytes and 64 KiB is 6.8x that. Worth flagging while looking: of the string-taking methods, only Proposed for the declaration, saying what it is plus where the number comes from: // Largest JSON-RPC request accepted on one line: 6.8x the largest legitimate
// request, which is a 1600-character welcome or chat message (MAX_LEN_CHAT_TEXT),
// 9708 bytes once JSON-escaped.
static constexpr int MAX_JSON_RPC_REQUEST_BYTES = 64 * 1024;@ann0see, on stating the limit in the error: the reply becomes If 6.8x reads as too much slack, 16 KiB still leaves 1.6x and is a one-line change. |
|
Yes. Then bound it to a lower number. |
|
🤖 AI: Lowered to 16 KiB, and the error now names the limit. Lowering it surfaced a second thing, which the 64 KiB version had too. Sizing first: the largest legitimate single request is a 1600-character welcome or chat message with every character JSON-escaped, 9698 bytes as a compact line. 16384 bytes leaves 1.7x that, or a batch of 221 ordinary calls. The second thing:
So an oversized request is now answered and then discarded up to its terminating newline, instead of closing the connection. A long-standing script that posts a large welcome message gets an error on that one call and keeps working, rather than silently losing its session. The bound still holds: 200 MiB of unterminated input is absorbed with under 1 MB of RSS growth. The cost is that a flooding connection is no longer severed, so it can keep streaming, at flat memory. |
|
I think I'd like a second limit check. Once the (potentially JSON-escaped as \uXXXX) message has been read and converted into a QString, check the length does not exceed Then, in a separate PR -- assuming these aren't in place, which reading the above it sounds like they aren't:
|
The 16 KiB request bound cannot see this case: 1700 characters sent as \uXXXX escapes is 10222 bytes, well under the limit, and was stored and then silently truncated by CServer::SetWelcomeMessage. Check the decoded QString instead, against the same MAX_LEN_CHAT_TEXT that setter truncates to, so an over-length message is reported rather than half-accepted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 AI: The second check is in, on the decoded One thing to settle first, because it decides the constant. A bound is already there, one layer down:
Rows three to five are what the new check now rejects outright. So a check at On the separate PR: the string form of |
Yes, that makes sense. |
The declaration only needs to say what the constant is and where its number comes from. The mechanism it also described -- that a line is only consumed once complete, so unterminated input would otherwise accumulate -- is already stated at the two places that enforce it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Please fix the comment length - otherwise I'd say it's ok |
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YPrJx6FCv2UrgoeTxMoEwm
MY LLM WROTE:
The JSON-RPC server consumes input only on a complete line —
while ( pSocket->canReadLine() )inCRpcServer::OnNewConnection(src/rpcserver.cpp). Until a newline arrives the received bytes stay in theQTcpSocketread buffer, which is unbounded becausesetReadBufferSize()is never called. A client that connects and sends bytes with no\ngrows that buffer 1:1 with bytes sent, before authentication, until the process is killed by the allocator (uncaughtstd::bad_alloc→ SIGABRT, dropping every connected client).Measured on a non-ASan release build of
main, one connection sending 200 MiB with no newline:mainWell-formed traffic is unaffected on the patched build:
jamulus/apiAuthreturns"result":"ok"andjamulus/getVersionreturns the version, connection stays open.Scope: reachable only when the RPC server is enabled (
--jsonrpcport) and bound off-loopback (--jsonrpcbindip); the default bind is127.0.0.1. This is the denial-of-service classSECURITY.mddocuments as a non-guarantee, so the change hardens a documented limitation rather than closing a promised guarantee — the buffer bounds in a few lines.The change, all in
OnNewConnection:setReadBufferSize ( MAX_JSON_RPC_REQUEST_BYTES )on each new connection, so buffering stops at the bound.MAX_JSON_RPC_REQUEST_BYTES= 64 KiB — far above any real request (secret, method name, or a large batch), and adjustable.A regression test fits the fork's JSON-RPC test surface directly: send an unterminated payload and assert the server's RSS stays flat and the connection is dropped, versus a terminated payload parsed normally.
@dtinth — flagging you as the JSON-RPC author and since this sits in the surface the fork is bringing under test.