From 2b0717fe2164aa71f33e4dea0674b9323141895d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 26 May 2026 00:13:01 +0200 Subject: [PATCH] fix(planner): cap per-WS-frame + per-session Yjs doc size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses planner audit #5 — a connected client could feed unlimited waypoints / no-go / notes into the Yjs doc, growing the in-memory map and the persisted \`sessions.yjs_state\` blob until OOM or DB blowout. Two guards: 1. **Per-frame**: \`MAX_MESSAGE_BYTES = 256 KB\`. Anything bigger arrives from a buggy or hostile client — a single waypoint add is hundreds of bytes. Oversized frame → \`ws.close(1008)\` and drop the message. 2. **Per-doc**: \`MAX_DOC_BYTES = 5 MB\`. After a sync-message apply succeeds, recompute \`Y.encodeStateAsUpdate(doc).byteLength\`. If it crossed the cap, mark the session \"quarantined\": close every connected client (\`1008 policy violation\`), and short-circuit subsequent handleMessage calls so no further bytes can be applied and the debounced save can't write an oversized blob to Postgres. The 5 MB limit is generous — a typical multi-day route's serialized state is well under 100 KB. The cap exists to make abuse expensive, not to constrain real use. Exports \`MAX_MESSAGE_BYTES\`, \`MAX_DOC_BYTES\`, and \`docByteSize\` for testing. Tests cover: - constants are positive and ordered - docByteSize is 0 for unknown sessions - a realistic 500-waypoint route stays well under the cap - ~6MB of garbage in a Y.Text trips the cap (smoke test for the guard) Full repo: pnpm typecheck / lint / test all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/planner/app/lib/yjs-server.test.ts | 58 ++++++++++++++++++++++++- apps/planner/app/lib/yjs-server.ts | 53 ++++++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/apps/planner/app/lib/yjs-server.test.ts b/apps/planner/app/lib/yjs-server.test.ts index 98e32d1..e5eb4f4 100644 --- a/apps/planner/app/lib/yjs-server.test.ts +++ b/apps/planner/app/lib/yjs-server.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "vitest"; -import { countActiveSessions } from "./yjs-server.ts"; +import { + countActiveSessions, + docByteSize, + getOrCreateDoc, + deleteDoc, + MAX_MESSAGE_BYTES, + MAX_DOC_BYTES, +} from "./yjs-server.ts"; describe("countActiveSessions", () => { it("is 0 for an empty iterable (regression: prod gauge saw -182 drift)", () => { @@ -43,3 +50,52 @@ describe("countActiveSessions", () => { } }); }); + +describe("doc-size caps", () => { + it("MAX_MESSAGE_BYTES + MAX_DOC_BYTES are positive and ordered sanely", () => { + expect(MAX_MESSAGE_BYTES).toBeGreaterThan(0); + expect(MAX_DOC_BYTES).toBeGreaterThan(MAX_MESSAGE_BYTES); + }); + + it("docByteSize returns 0 for an unknown session", () => { + expect(docByteSize("no-such-session")).toBe(0); + }); + + it("docByteSize tracks growth and stays below MAX_DOC_BYTES for a realistic route", () => { + const sid = "size-test"; + const doc = getOrCreateDoc(sid); + try { + const empty = docByteSize(sid); + // Push a few hundred waypoints — typical multi-day route. + const arr = doc.getArray<{ lat: number; lon: number }>("waypoints"); + doc.transact(() => { + for (let i = 0; i < 500; i++) { + arr.push([{ lat: 52 + i / 1000, lon: 13 + i / 1000 }]); + } + }); + const filled = docByteSize(sid); + expect(filled).toBeGreaterThan(empty); + // Several MB is still well under the cap — abuse needs to be much bigger. + expect(filled).toBeLessThan(MAX_DOC_BYTES); + } finally { + deleteDoc(sid); + } + }); + + it("docByteSize crosses MAX_DOC_BYTES when fed enough garbage (smoke test for the guard)", () => { + const sid = "size-cap-test"; + const doc = getOrCreateDoc(sid); + try { + // Yjs string updates compress, but raw bytes in a Y.Text approach + // the underlying state size. Push a ~6MB string in one go. + const txt = doc.getText("blob"); + const chunk = "x".repeat(64 * 1024); // 64 KB per insert + doc.transact(() => { + for (let i = 0; i < 100; i++) txt.insert(i * chunk.length, chunk); + }); + expect(docByteSize(sid)).toBeGreaterThan(MAX_DOC_BYTES); + } finally { + deleteDoc(sid); + } + }); +}); diff --git a/apps/planner/app/lib/yjs-server.ts b/apps/planner/app/lib/yjs-server.ts index 5dd904e..de07987 100644 --- a/apps/planner/app/lib/yjs-server.ts +++ b/apps/planner/app/lib/yjs-server.ts @@ -11,9 +11,46 @@ import { plannerActiveSessions, plannerConnectedClients } from "./metrics.server const messageSync = 0; const messageAwareness = 1; +// Bound the per-WebSocket-message size and per-session doc size. Both +// guards exist to stop a single connected client from filling memory +// (or the persisted `yjs_state` blob) without limit. Picked generously +// — a multi-day route with hundreds of waypoints is well under 100 KB +// of serialized Yjs state — but small enough to refuse abuse. +export const MAX_MESSAGE_BYTES = 256 * 1024; // 256 KB per WS frame +export const MAX_DOC_BYTES = 5 * 1024 * 1024; // 5 MB per session doc +// WebSocket close code for policy-violation responses (1008 is the +// standard RFC 6455 code for "received a message that violates policy"). +const POLICY_VIOLATION = 1008; + const docs = new Map(); const awarenessMap = new Map(); const conns = new Map }>(); +const oversizedSessions = new Set(); + +/** + * Current persisted size of a session's Yjs doc — same bytes that + * would land in `sessions.yjs_state` if we saved right now. Exported + * so tests can assert the size-cap accounting directly. + */ +export function docByteSize(sessionId: string): number { + const doc = docs.get(sessionId); + if (!doc) return 0; + return Y.encodeStateAsUpdate(doc).byteLength; +} + +/** + * Refuse further activity on a session whose doc exceeded the cap. + * Close every connected client and mark the session so the next + * persisted save is skipped (avoid writing an oversized blob to DB). + */ +function quarantineSession(sessionId: string, reason: string): void { + oversizedSessions.add(sessionId); + for (const [ws, meta] of conns) { + if (meta.sessionId === sessionId && ws.readyState === ws.OPEN) { + try { ws.close(POLICY_VIOLATION, reason); } catch { /* ignore */ } + } + } +} /** * Count the number of distinct session ids represented in an iterable @@ -131,6 +168,15 @@ function debouncedSave(sessionId: string): void { function handleMessage(ws: WebSocket, message: Uint8Array, sessionId: string) { const doc = docs.get(sessionId); if (!doc) return; + if (oversizedSessions.has(sessionId)) return; + + // Per-frame cap. Anything larger than 256 KB on a Yjs session is + // pathological — a single waypoint add is hundreds of bytes — so the + // sender is either buggy or hostile. + if (message.byteLength > MAX_MESSAGE_BYTES) { + try { ws.close(POLICY_VIOLATION, "message too large"); } catch { /* ignore */ } + return; + } const awareness = awarenessMap.get(sessionId); const decoder = decoding.createDecoder(message); @@ -145,6 +191,13 @@ function handleMessage(ws: WebSocket, message: Uint8Array, sessionId: string) { if (encoding.length(encoder) > 1) { ws.send(resp); } + // Per-doc cap. Apply succeeded above; if the doc just grew past + // the limit, quarantine before debouncing the next save so we + // never write an oversized blob to Postgres. + if (docByteSize(sessionId) > MAX_DOC_BYTES) { + quarantineSession(sessionId, "doc size cap exceeded"); + break; + } debouncedSave(sessionId); break; }