fix(security): the console channel enforced less than it claimed - #69
Merged
Conversation
The console JWT is what lets a browser talk straight to a daemon that runs as root and drives Docker. It had no tests at all — 274 lines of issuing and 422 of verifying, resting on manual discipline. Writing the attacks found that several of its guarantees were not there. **Live console output was ungated.** The listeners were attached the moment a session authenticated; only the *replay* of the buffer checked `control.console`. A subuser holding nothing but the implicit `websocket.connect` read the console live — including whatever an operator typed into it, which is where RCON passwords, `op` and plugin keys go. The gate is now on emission rather than on subscription, so a renewal that narrows a token stops the stream mid-session; state, stats and install lifecycle still flow, because *that* something happened is not the same as *what was said*. **A read-scoped API key could open a console.** The endpoint is a GET, scope is decided from the verb, and the permission resolver has never heard of API keys — so an owner's key came back with every permission and they were frozen into the token. `docs/api.md` already said this was impossible. It is now. **Expiry closed the socket without removing authority.** A close is a handshake, not an instant, and nothing obliges a peer to answer: commands arriving in that window were executed with a token the daemon had already declared dead. Authority is dropped first, the frame second. **The quotas were arithmetic rather than limits.** The command counter lived on the session, so the ceiling was sixty times however many sockets one token cared to open. Quotas now hang off the identity the panel signed — the user, on one server — which cannot be multiplied by reconnecting. Power actions had no quota at all, though each is container work on a root-privileged daemon; they have their own, tighter one. Console replays have theirs, charged on the connect path too, since leaving it free would have priced the allowance at nothing. The window is genuinely sliding now, where the comment had claimed sliding and the code reset wholesale — sixty commands either side of a boundary went through in a fifth of a second. Two documented capabilities did not exist. Signed download URLs are described as single-use and are not — and in fact nothing in the product issues one, so the docs now say that instead. The `jti` claim was documented as allowing a targeted revocation that is implemented nowhere. A sentence an incident responder would act on, describing a mechanism that is not there, is worse than no sentence. What did hold, and is now pinned: `alg: none` and algorithm confusion, a token for another node by audience and by key independently, a foreign issuer, every direction of cross-family replay, payload tampering of every field, and a node operator forging a panel administrator token with the node secret they own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QL3QL3ReEa9Sk68mxJW6Fu
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.
This started as a test-writing task —
token.service.ts(274 lines) andconsole-gateway.ts(422 lines) had zero tests, which an audit had flagged as the largest untested security surface in the codebase. The console JWT is what lets a browser talk straight to the daemon, which is the architectural decision the whole project rests on. Writing the attacks found that several of its guarantees were not there.Every finding below was reproduced independently by a second pass before being fixed.
Live console output was ungated
attachToServerregistered the console and install listeners the moment a session authenticated. OnlysendConsoleSnapshot— the replay of the last 500 lines — checkedcontrol.console.So a subuser holding nothing but
websocket.connect, which every subuser has implicitly, read the console live. That includes whatever an operator types into it: RCON passwords,op, plugin keys, anything pasted.The gate is now on emission, not subscription, because renewal re-authenticates over the same socket — reading the permission when each line arrives is the only way a narrowed token bites mid-session, and a widened one resumes.
state,statsand the install lifecycle still flow: that something happened is not the same as what was said.A read-scoped API key could open a console
GET /api/servers/:id/consoleis a GET,scopeAllows()decides scope from the HTTP verb, andServerPermissionResolverhas never heard of API keys — so an owner's read-only key resolved to every permission, frozen into the token. The daemon then honouredcontrol.console(arbitrary command execution on a Minecraft server) andcontrol.stop.docs/api.md:38already stated the console cannot be opened with an API key. It is now true.Expiry closed the socket without removing authority
scheduleTokenTimerssenttoken_expiredand calledsocket.close(1008), leavingauthenticated,permissionsandserverset. A close is a handshake, not an instant, and nothing obliges a peer to answer — commands arriving in that window ran with a token the daemon had already declared dead. Authority is dropped first now, the frame second. The refused-renewal path had the same hole and is the worse of the two, since someone is actively probing.The quotas were arithmetic, not limits
The counter lived on the session object, so the real ceiling was 60 × however many sockets one token opened — 122 commands over two sockets, verified. Quotas now hang off the identity the panel signed, which cannot be multiplied by reconnecting. Not the token (renewal mints a fresh
jti), not the server alone (one subuser could spend the owner's ability to typestop).Power actions are container work on a root-privileged daemon and had no limit at all: 200 alternating
start/killall landed.The window is genuinely sliding now. It claimed sliding and reset wholesale, so 60 commands either side of a boundary went through in a fifth of a second.
Also: an 8 MiB send-buffer ceiling, because the replay quota bounds how often a 30-byte frame asks for 500 messages, not what becomes of them when the client never reads.
Two documented capabilities that did not exist
jti. More to the point,signResourceUrlhas no production caller at all, so the docs now say the mechanism is unused rather than describing a protection nobody has.jtiis documented in the contract as "allowing a targeted revocation". Grepping the repo finds only the schema declarations.A sentence an incident responder would act on, describing a mechanism that is not there, is worse than no sentence.
What held, and is now pinned
Worth stating, because a clean result from a genuine attempt is the point of the exercise:
alg: none; HS/RS confusion; an algorithm outside the pin; a token for another node refused by audience and by key, independently; a foreign issuer; expiry to the second; payload tampering of every field; every direction of cross-family replay; and a node operator forging a panel administrator token with the node secret they legitimately hold.Verification
consoleTokenPayloadSchemare-parse, the permission check, expiry — is load-bearing in at least one suite.serverUuidcomparison rather than the schema it claimed to test.lint,typecheck,test,format:checkgreen — 1451 tests, up from 1307.Left open, deliberately
A console token still carries no session identifier, so signing out or changing a password does not reach an open console before the token's two-minute lifetime ends. The daemon cannot consult the panel's session table, and a revocation channel is a larger change than this.
docs/security.mdnow states the window precisely, names re-keying the node as the way to cut consoles immediately, and no longer implies revocation is complete.🤖 Generated with Claude Code
https://claude.ai/code/session_01QL3QL3ReEa9Sk68mxJW6Fu