From 7a0e8ba6ef054cc638787ce4c44a4557fce56cf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Fri, 16 Oct 2026 06:42:56 +0000 Subject: [PATCH] Close an edge socket that never authenticates The edge protocol carries its token in the first message rather than the handshake, which is the right call for a WebSocket - a browser cannot set headers, and a query parameter puts a bearer credential into every access and proxy log. But the cost of that choice is a window in which an open socket belongs to nobody, and nothing was closing it. Every other message type already refuses to act without a `farmId`, so such a socket could do nothing. It did sit in `connections` forever though, and that map is keyed by whoever can reach the port. Ten seconds, which is generous for a round trip a real gateway makes immediately on open; a tighter bound would only drop gateways on slow links. The timer is cleared after the token verifies and the farm matches, so a client retrying a bad token on one socket still gets dropped rather than being handed an unbounded number of attempts. 85 tests, `bunx tsc --noEmit` clean. --- src/routes/ws/edge.ts | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/routes/ws/edge.ts b/src/routes/ws/edge.ts index f27f38c..d335d4a 100644 --- a/src/routes/ws/edge.ts +++ b/src/routes/ws/edge.ts @@ -48,6 +48,19 @@ interface EdgeConn { * after the gateway disconnected or moved. Undefined means none advertised. */ streamBaseUrl?: string; + /** + * Closes this socket if it never authenticates. Cleared by a successful + * `hello` and on close. + * + * The cost of carrying the token in the first message rather than the + * handshake is exactly this: there is a window in which an open socket + * belongs to nobody. Every other message type already refuses to act + * without a `farmId`, so such a socket can do nothing - but it does sit in + * `connections` forever, and that map is keyed by whoever can reach the + * port. A silent connection is not a slow one: a real gateway sends + * `hello` as its first frame. + */ + handshakeTimer?: ReturnType; send: (message: EdgeServerMessage) => void; /** * Gateway printer id -> our `printers.id`. Filled on the first telemetry @@ -136,6 +149,14 @@ export function listUnclaimedPrinters(farmId: string): UnclaimedPrinter[] { /** How long to wait for a gateway to answer a command before giving up. */ const COMMAND_TIMEOUT_MS = 10_000; +/** + * How long a socket may stay open without authenticating. Generous compared + * with the round trip it covers - the gateway sends `hello` immediately after + * the socket opens - because the only thing a tight bound would buy is + * dropping gateways on slow links. + */ +const HANDSHAKE_TIMEOUT_MS = 10_000; + /** * Maps a printer's own state string onto the narrowed `printer_status` enum. * @@ -318,7 +339,7 @@ export const edgeWsRoute = new Elysia() open(ws) { // No farmId yet - set by `hello`, and every other message type refuses // to do anything until it is. - connections.set(ws.id, { + const conn: EdgeConn = { farmId: "", gatewayId: "", sessionId: crypto.randomUUID(), @@ -326,7 +347,15 @@ export const edgeWsRoute = new Elysia() send: (message) => ws.send(message), printerIds: new Map(), pending: new Map(), - }); + }; + + conn.handshakeTimer = setTimeout(() => { + if (conn.farmId) return; + console.warn(`[edge] closing socket ${ws.id}: no hello within ${HANDSHAKE_TIMEOUT_MS}ms`); + ws.close(); + }, HANDSHAKE_TIMEOUT_MS); + + connections.set(ws.id, conn); }, async message(ws, message) { @@ -354,6 +383,13 @@ export const edgeWsRoute = new Elysia() return; } + // Authenticated, so it is no longer a socket belonging to nobody. + // Cleared only here, after the token verified and the farm matched + // - a rejected hello leaves the timer running, so a client that + // retries a bad token on one socket still gets dropped. + clearTimeout(conn.handshakeTimer); + conn.handshakeTimer = undefined; + conn.farmId = farmId; conn.gatewayId = message.gateway_id; // `?? undefined`: absent and explicit null both mean "no streams". @@ -447,6 +483,7 @@ export const edgeWsRoute = new Elysia() close(ws) { const conn = connections.get(ws.id); if (conn) { + clearTimeout(conn.handshakeTimer); // Anything still waiting on this gateway will never be answered now. // Failing them immediately beats making every caller wait out the // full timeout for a socket that is already gone.