Skip to content

fix(console): stop a malformed cookie killing the console gateway - #96

Merged
ralyodio merged 1 commit into
moshcoder:mainfrom
clawedassistant26:fix/console-cookie-decode-crash
Jul 30, 2026
Merged

fix(console): stop a malformed cookie killing the console gateway#96
ralyodio merged 1 commit into
moshcoder:mainfrom
clawedassistant26:fix/console-cookie-decode-crash

Conversation

@clawedassistant26

Copy link
Copy Markdown
Contributor

moshcode console puts an authenticating gateway in front of a real shell. An unauthenticated client can stop that gateway answering with a single header.

The bug

parseCookies decodes each cookie value with decodeURIComponent, which throws URIError: URI malformed on a stray %:

if (k) out[k] = decodeURIComponent(part.slice(eq + 1).trim());

A cookie value is whatever the client chose to send, and this runs before any auth check, from two places where a throw is fatal:

  • the async request handler, so the throw becomes an unhandled rejection, which Node treats as fatal by default
  • the upgrade listener, where it is an uncaught exception

No token, no session, no valid cookie name needed. Cookie: sid=% is enough.

Reproducing it

Against unmodified main (1b8af96), gateway in a child process so the exit is visible:

gateway up on 44911, sending an ordinary unauthenticated request first...
  -> 401 (expected 401, gateway healthy)
now sending Cookie: sid=% (one stray percent sign, no token, no auth)...
  -> socket error: ECONNRESET

*** GATEWAY PROCESS EXITED *** code=1 signal=null
stderr first line: URIError: URI malformed

The process is gone, so every other user of that gateway loses their terminal too, and moshcode console serve stays down until someone restarts it by hand.

There is a milder version that bites without an attacker: a browser sends every cookie set for the host, so one unrelated cookie holding a literal % costs you the gateway on an ordinary page load.

The fix

Fall back to the raw value when a cookie will not decode. This is what the cookie package does, so it matches what express would have given you if the upgrade path could have used it.

try { out[k] = decodeURIComponent(raw); } catch { out[k] = raw; }

The gate itself is untouched: a value that fails to decode still has to survive readCookie's HMAC check to authenticate anything, and an undecodable value will not.

test/console.test.mjs already asserts "malformed cookies are rejected rather than throwing" for readCookie. This extends the same rule to the layer that runs first.

Tests

New test/console-cookie-malformed.test.mjs, 10 tests.

Five are the bug. They fail before and pass after:

  • a stray %, a literal 100%, and a truncated escape all parse instead of throwing
  • an unauthenticated request carrying sid=% gets a 401 and raises no process-level error
  • the gateway still answers the next request after one arrives
  • the websocket upgrade path refuses it rather than dying
  • a valid session beside a malformed cookie still authenticates

Five are controls that pass both ways, so the fix cannot buy crash safety by weakening the gate: well-formed values are still percent-decoded, no cookie is still 401 on both paths, a forged cookie is still 401, an expired cookie is still 401, and a valid session still reaches the proxy.

The tests record unhandledRejection and uncaughtException rather than letting them fly, because otherwise the unpatched run takes the whole file down and you cannot tell which test failed.

Suite: 365 -> 375, 0 failing.

Scoped out deliberately

Two things I noticed in the same file and did not put in here, since they are judgement calls rather than defects:

  1. proxyUpgrade does not check Origin. Cross-site WebSocket hijacking is the classic risk for a cookie-authenticated websocket, and SameSite=Lax has historically not been applied to websocket handshakes in every browser. A fix means deciding which origins are allowed, which is your call, not mine.
  2. The session cookie is not marked Secure. That is right for the documented loopback and tailnet setups and wrong behind a TLS reverse proxy, so it probably wants to follow a flag rather than be hardcoded.

Happy to send either as its own PR if you want them.

parseCookies ran decodeURIComponent on the raw cookie value, which throws a
URIError on a stray "%". It is called before any auth check, from two places
whose throw is fatal: the async request handler (unhandled rejection) and the
`upgrade` listener (uncaught exception). So an unauthenticated client could end
the gateway process with one header.

Fall back to the raw value when a cookie will not decode, the way the `cookie`
package does. The gate itself is untouched.
@ralyodio
ralyodio merged commit 285d16c into moshcoder:main Jul 30, 2026
3 checks passed
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.

2 participants