Skip to content

fix(security): the console channel enforced less than it claimed - #69

Merged
aaldersondev merged 1 commit into
mainfrom
test/console-jwt
Aug 8, 2026
Merged

fix(security): the console channel enforced less than it claimed#69
aaldersondev merged 1 commit into
mainfrom
test/console-jwt

Conversation

@aaldersondev

Copy link
Copy Markdown
Contributor

This is a security fix. It closes a permission bypass, a privilege escalation, and three ways to spend a root-privileged daemon's resources without limit.

This started as a test-writing task — token.service.ts (274 lines) and console-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

attachToServer registered the console and install listeners the moment a session authenticated. Only sendConsoleSnapshot — the replay of the last 500 lines — checked control.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, stats and 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/console is a GET, scopeAllows() decides scope from the HTTP verb, and ServerPermissionResolver has never heard of API keys — so an owner's read-only key resolved to every permission, frozen into the token. The daemon then honoured control.console (arbitrary command execution on a Minecraft server) and control.stop.

docs/api.md:38 already stated the console cannot be opened with an API key. It is now true.

Expiry closed the socket without removing authority

scheduleTokenTimers sent token_expired and called socket.close(1008), leaving authenticated, permissions and server set. 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

before after
commands 60 per socket 60 per user, per server
power actions none 10, its own tighter bucket
console replays none 6, charged on the connect path too

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 type stop).

Power actions are container work on a root-privileged daemon and had no limit at all: 200 alternating start/kill all 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

  • Signed download URLs are documented as single-use. They are not — nothing consumes the jti. More to the point, signResourceUrl has no production caller at all, so the docs now say the mechanism is unused rather than describing a protection nobody has.
  • jti is 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

  • 29 defence-removing mutations applied across both files and the shared contract; the suites catch them. Every defence named — the algorithm pin, audience, issuer, the consoleTokenPayloadSchema re-parse, the permission check, expiry — is load-bearing in at least one suite.
  • Three tests that passed for the wrong reason were found by mutation and rewritten: one asserted on a helper reimplementing the check, one was refused by a serverUuid comparison rather than the schema it claimed to test.
  • Every attack from the first round re-run against the fixed code and now failing; legitimate use traced for an owner, a permitted subuser, a fast-printing server and a reconnecting browser.
  • lint, typecheck, test, format:check green — 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.md now 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

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
@aaldersondev
aaldersondev merged commit 1ad4c28 into main Aug 8, 2026
3 checks passed
@aaldersondev
aaldersondev deleted the test/console-jwt branch August 8, 2026 12:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant