Conversation
Found by the adversarial QA pass on XERK-262; the code is pre-existing on main
(XERK-245), so it gets its own PR rather than riding that one.
`String(x)` throws `TypeError: Cannot convert object to primitive value` when a
value has no usable primitive conversion, and pure JSON can express one:
`{"toString":1,"valueOf":1}` supplies both hooks as own, non-callable
properties. `sanitizeLiveAgents` called `String()` straight on a field parsed
out of an `/agent/control` WebSocket frame.
That handler runs inside a `socket.on("data")` listener with no try/catch above
it, and this process installs no `uncaughtException` handler, so the throw
exited node. One frame. DockerOps runs `restart: unless-stopped`, so repeating
it is a sustained outage of the fleet's whole control plane — dashboard,
tunnels, terminal proxy, archive.
It does not need an agent token: `agentWsAuthorized` falls back to
`agentAuthorized` falls back to `userAuthorized`, so the ordinary single-user web
login opens the socket, and that login is reachable over the public tunnel.
Two vectors, both fixed:
- `sanitizeLiveAgents` now coerces through a `safeString` that cannot throw.
Fixed in the function handling untrusted input rather than by wrapping its
callers — a coercion that throws is the bug, and a caller-side guard is one the
next caller gets added without. An unconvertible `type` drops the row (a blank
type already did); an unconvertible `label` blanks the label and keeps the row,
so the "an agent is running" signal `hasLiveAgents` reads is not lost.
- The frame handler now requires the session id to be a STRING, not merely
truthy. `liveFanout` uses it as a property key, and coercing an object to a key
runs the same ToPrimitive — reachable once any viewer socket is open on that
host, since the optional chain short-circuits otherwise. Every real id is a
string, so this only refuses malformed frames.
Deliberately NOT fixed with a `process.on('uncaughtException')`: that would
convert the crash into a silent half-state rather than removing it. Worth
considering separately.
Verified: the original one-frame repro now leaves the hub answering. Each half
of the fix was reverted independently and the suite reddens for both. The third
caller (the heartbeat ingest) is covered too — it was already safe behind the
request handler's catch, and now has a test that it stays reachable-and-refused
rather than becoming a 500 or silently accepted. 1090 node tests green.
XERK-262 (#439) landed on main and touches the same two files, so both conflicts are with that work. Nothing from either side is dropped. - turma/server.js — main hoisted LIVE_AGENTS_MAX / LIVE_AGENT_FIELD_MAX to the top of the file while this branch added `safeString` next to their old position. Kept main's placement and only the new function; taking this side wholesale would have declared both constants twice. - turma/tests/server.test.js — a pure append conflict: both branches added tests at the end. Kept both, main's first. (The first splice truncated main's last test, whose closing brace sat past the conflict marker; restored.) One semantic interaction, deliberate on both sides. XERK-262's `heartbeat: a live-agent field with no primitive conversion never reaches the record` accepts EITHER a 200 or a 400 and asserts only the invariant, precisely because this fix was known to be coming: with the coercion no longer able to throw, that beat is now an ordinary 200 with the poisoned row dropped rather than a refusal. It still passes. This branch's ingest test pins the post-fix contract exactly, and now says so and cross-references the other. Verified after the merge: 1112 node tests green; both halves of the fix reverted independently still redden the suite; the original one-frame repro still leaves the hub answering.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes a remote crash of the hub process reachable with the ordinary operator web login.
Found by the adversarial QA pass on #430's follow-up (XERK-262). The code is pre-existing on
main(from XERK-245), so it is fixed here rather than in that PR. Ticket: XERK-278.The defect
String(x)throwsTypeError: Cannot convert object to primitive valuefor a value with no usable primitive conversion — and pure JSON can express one:{"toString":1,"valueOf":1}gives both hooks as own, non-callable properties.sanitizeLiveAgentscalledString()directly on a field parsed out of an/agent/controlWebSocket frame. That handler runs inside asocket.on("data")listener with notry/catchabove it, andturma/installs noprocess.on('uncaughtException'), so the throw exits node.Why it matters:
agentWsAuthorized→agentAuthorized→userAuthorized, so the single-user Basic login opens the socket. Reproduced withTURMA_USER/TURMA_PASSWORDonly. That login is reachable through the public Cloudflare tunnel.restart: unless-stopped(compose/turma.yaml:35), so repeating the frame is a sustained outage of the entire control plane — dashboard, tunnels, terminal proxy, archive.The fix
Two vectors, both closed:
sanitizeLiveAgentscoerces through a newsafeStringthat cannot throw. Fixed in the function that handles untrusted input, not by wrapping its three callers — a coercion that throws is the bug, and a caller-side guard is one the next caller gets added without. An unconvertibletypedrops the row (a blanktypealready did); an unconvertiblelabelblanks the label and keeps the row, so the "an agent is running" signalhasLiveAgentsreads is not lost.string, not merely truthy.liveFanoutuses it as a property key, and coercing an object to a key runs the same ToPrimitive. This one only bites once a viewer socket is open on that host (the optional chain short-circuits otherwise), which is whysafeStringalone does not cover it. Every real id is a string, so this only refuses malformed frames.Deliberately not fixed with a global
uncaughtExceptionhandler: that converts the crash into a silent half-state rather than removing it. Worth considering separately.Verification
STILL ALIVE, exit 0); before, the listening socket was gone.turma/tests/server.test.js: the unit coercions, a real/agent/controlsocket carrying the poisoned row, a real socket carrying non-string ids with a live viewer attached, and the heartbeat ingest (the third caller — already safe behind the request handler's catch, now pinned so it stays reachable-and-refused rather than becoming a 500 or silently accepted).node --test turma/tests/*.test.js agent/tests/*.test.js .github/scripts/tests/*.test.js→ 1090 passed, 0 failed.The control-socket tests run in the same process as the rest of the suite, so a regression does not fail one assertion — it takes the whole run down. That is the intended signal.
Not verified
Not exercised against the deployed hub — the repro is local rigs only, per rules of engagement. The
restart: unless-stoppedconsequence is read from the DockerOps compose file, not observed in production.