From 19187a34176a1d12e4b2b74b6411bb39de0b857e Mon Sep 17 00:00:00 2001 From: Mohamed Elsherif Date: Sat, 24 Oct 2026 19:51:10 -0700 Subject: [PATCH] Harden static files, credentials, and WebSocket boundaries --- docs/adr/0005-session-boundaries.md | 7 ++ docs/adr/0007-same-origin-security.md | 6 + docs/adr/0012-shared-app-install-surface.md | 7 +- docs/adr/0017-websocket-resource-limits.md | 6 + docs/security-review-2026-10-24.md | 75 ++++++++++++ server/accounts.js | 7 +- server/battle-tanks-rooms.js | 6 +- server/http-security.js | 5 +- server/index.js | 8 +- server/rooms.js | 6 +- server/saves.js | 5 +- server/static-assets.js | 37 +++++- server/tictactoe-rooms.js | 5 +- server/websocket-messages.js | 16 ++- service-worker.js | 8 +- tests/accounts.test.js | 24 ++++ tests/http-security.test.js | 2 +- tests/install-app.test.js | 2 +- tests/online-rooms.test.js | 2 +- tests/server-security.test.js | 122 ++++++++++++++++++++ tests/service-worker.test.js | 54 +++++++++ tests/static-assets.test.js | 81 +++++++++++++ tests/websocket-messages.test.js | 38 ++++++ 23 files changed, 501 insertions(+), 28 deletions(-) create mode 100644 docs/security-review-2026-10-24.md create mode 100644 tests/server-security.test.js create mode 100644 tests/service-worker.test.js diff --git a/docs/adr/0005-session-boundaries.md b/docs/adr/0005-session-boundaries.md index 60bb140..cf4ba1d 100644 --- a/docs/adr/0005-session-boundaries.md +++ b/docs/adr/0005-session-boundaries.md @@ -17,3 +17,10 @@ flowchart TB account credentials; room tokens remain script-visible and are intentionally short-lived with the room. +Login rechecks the stored credential after asynchronous passcode verification, +and profile writes compare the verified credential inside their transaction. +A concurrent password change therefore cannot be undone by an older profile +request or admit a login using the replaced passcode. Profile verification has +an account-level budget shared across rotated sessions and also consumes the +same address-level credential budget as login. + diff --git a/docs/adr/0007-same-origin-security.md b/docs/adr/0007-same-origin-security.md index 38ed586..683fe2d 100644 --- a/docs/adr/0007-same-origin-security.md +++ b/docs/adr/0007-same-origin-security.md @@ -22,3 +22,9 @@ foreign form targets remain blocked. Production session and deletion cookies always carry the `Secure` attribute; development deployments may opt into it but cannot opt production out. +Static serving validates normalized and real filesystem paths, including +directory links, before reading or caching bytes. Case and Windows path aliases +cannot bypass private-path checks. Only GET and HEAD serve assets. Cached +buffers use an 8 MiB least-recently-used budget with a 512 KiB per-file ceiling; +larger files stream with backpressure instead of being retained in memory. + diff --git a/docs/adr/0012-shared-app-install-surface.md b/docs/adr/0012-shared-app-install-surface.md index b2847e2..d9ac4a3 100644 --- a/docs/adr/0012-shared-app-install-surface.md +++ b/docs/adr/0012-shared-app-install-surface.md @@ -23,8 +23,11 @@ manifest so every shared install action installs JavaScript Playground rather th a game-specific application. The shell registers a root service worker that uses network-first navigation and stale-while-revalidate static assets in a versioned cache. Cache writes run in the background so a fetched response is -not delayed by storage. API requests are explicitly excluded from interception -and caching. Generated raster icons are +not delayed by storage. API requests, including encoded path aliases, are +explicitly excluded from interception and caching. Responses declaring +`Cache-Control: private` or `no-store` are never stored. Changes to this privacy +boundary advance the cache version so activation removes older cached responses. +Generated raster icons are not part of the atomic app-shell precache, and the Node server creates any missing icons before it begins listening so clean direct-server checkouts remain installable. diff --git a/docs/adr/0017-websocket-resource-limits.md b/docs/adr/0017-websocket-resource-limits.md index cbe8c97..a547ee8 100644 --- a/docs/adr/0017-websocket-resource-limits.md +++ b/docs/adr/0017-websocket-resource-limits.md @@ -24,6 +24,12 @@ per minute, 200 rooms across the arcade, 100 rooms per game, 5 rooms per address, and 50 entries per public listing. Deployments may lower or raise these limits through documented environment variables. +Every outbound room message also passes a shared 1 MiB per-socket queued-byte +ceiling, including the message being sent. Slow consumers are terminated and +can use the existing room resume flow; unsent result receipts remain pending. +Protocol errors terminate their offending socket without escaping as unhandled +process errors. Replaced sockets cannot issue commands on any game endpoint. + **Consequences:** A remote peer cannot allocate unbounded sockets, rooms, game state, broadcast work, or lobby responses. Limits are intentionally process-local because rooms are process-local. Multi-instance deployments need diff --git a/docs/security-review-2026-10-24.md b/docs/security-review-2026-10-24.md new file mode 100644 index 0000000..6bec310 --- /dev/null +++ b/docs/security-review-2026-10-24.md @@ -0,0 +1,75 @@ +# Security and performance review — 2026-10-24 + +Reviewed from master `4bf2d39`. The review focuses on the network-facing Node +service, authentication and persistence, static-file isolation, shared browser +runtime and service worker, multiplayer lifecycle, and deployment/dependency +configuration. Game mechanics are covered at their trust boundaries and by the +regression suite; classic games and independent coding-challenge scripts were +not comprehensively audited. + +## Fixed findings + +| Priority | Finding and impact | Resolution and evidence | +| --- | --- | --- | +| High | Static isolation checked request text before filesystem normalization. Encoded separators could route a nominal game path into private server files or the default database directory. Windows aliases and filesystem links also bypassed the original checks. | Normalize private-path checks, reject ambiguous path syntax, and validate the real destination inside the public root before reads. Unit tests cover aliases and directory links; live HTTP tests deny encoded server, data, and package paths. | +| High | Accepted WebSockets had no `error` listener. One invalid protocol frame emitted an unhandled error and terminated the process. | Install the error handler before routing any game socket. A live test reproduces the original process failure and verifies the fixed server remains healthy after malformed frames on all three endpoints. | +| High | Authentication and profile writes trusted a passcode hash read before awaiting scrypt. Concurrent credential changes could permit an old login or let an older profile write restore the old hash. | Recheck the login credential and condition the transactional profile write on the verified hash. Tests replace credentials while verification is pending and verify both older operations are rejected. | +| Medium | Profile PATCH verified the current passcode without throttling. A valid session allowed repeated credential guesses and expensive scrypt work outside the login budget. | Limit profile verification to ten attempts per account per fifteen minutes, across session rotations, and share login's address-level budget. A live test checks successful rotation, failed attempts, and HTTP 429 with Retry-After. | +| Medium | Tic-tac-toe omitted the socket-ownership guard used by the other games. An action queued on a closing, replaced connection could still mutate the resumed match. | Check current socket ownership before dispatch. A live regression reproduces an unauthorized old-socket move when the guard is removed; the fixed test also completes the resumed match and rematch. | +| Medium | The service worker excluded only literal `/api/` URLs and ignored private/no-store response directives. Encoded API aliases could store account responses in persistent browser cache. | Exclude decoded API paths, honor response privacy directives, and advance the cache to v16 to purge previous entries. Executable worker tests cover encoded aliases, private/no-store responses, public static caching, and old-cache removal. | +| Medium | Room broadcasts queued data without an outbound byte ceiling. Slow receivers could accumulate state snapshots even while responding to heartbeat pings. | Route every room send through a 1 MiB queue ceiling and terminate slow consumers. Tests cover all room managers, UTF-8 byte accounting, arena delivery, and pending result receipts across reconnect. | + +## Performance changes + +- Static cached buffers now have an 8 MiB LRU budget and a 512 KiB per-file + ceiling. Larger files stream instead of being read into the cache. Existing + conditional responses, HEAD requests, and resolved-asset CSP remain intact. + Tests verify eviction and streaming without `readFile` allocations. +- Save listings select only slot metadata; screenshot retrieval selects only + image columns. Previously, listing five slots could copy up to roughly + 2.5 MiB of unused state and image payloads into JavaScript. +- An illustrative Node 26.12.1 in-memory SQLite benchmark used five slots with + 240 KiB of state data and 256 KiB screenshots. Across 300 listings, the old + SELECT-all query plus minimal metadata mapping took 169.4 ms; the revised + `GameSaves.list` took 16.3 ms. This measures one local workload, not a production + latency guarantee. Existing save isolation, revision, and storage-budget tests + still pass. + +## Remaining follow-ups + +- Profile and leaderboard reads aggregate persisted history. The leaderboard's + correlated best-result query and expression sort deserve a production-sized + benchmark before choosing a ranking index, window query, or materialized + summary. Any new index should ship as the next ordered migration. +- The browser service worker still uses an open-ended runtime cache of public + URLs. Consider a bounded entry policy and normalization of asset query keys if + offline storage usage becomes significant. +- In the phone smoke check at 390 × 844, Tic-tac-toe's board extended below the + initial viewport and New game started around y=1232. Natural scrolling works, + but a separate visual pass should bring the board and primary controls closer + together without reducing touch targets. +- Preserve the deployment assumptions: TLS at the reverse proxy, a correctly + configured TRUST_PROXY boundary, and persistent-data backups. Source review + does not establish the configuration of an existing deployment. Solo/local + result facts remain client-authored within server bounds, as accepted by + ADR 0003; they do not provide replay-proof anti-cheat. + +## Verification + +- Baseline: 341 passing tests. Final: 359 passing tests on Node 26.12.1 with the + locked ws 8.22.0 dependency. +- Focused regressions, full `npm test`, `npm run check`, and `git diff --check`. +- `npm audit --omit=dev`: zero reported vulnerabilities in the production + dependency graph. Locked dependencies installed with `npm ci --ignore-scripts`. +- Live HTTP/WebSocket tests run with temporary databases and ephemeral ports; + local mechanics and room disconnect/resume/rematch tests remain covered. +- Browser checks loaded the shared shell and Tic-tac-toe, exercised a local + move, checked phone and desktop navigation, and reported no console warnings + or errors. Screenshot capture repeatedly timed out, so visual screenshot QA + is incomplete; phone geometry was inspected through the DOM. +- Docker is unavailable in this environment. The container vulnerability scan + was not run locally; the existing CI build job performs the pinned scan. + +The changes implement the existing security and resource boundaries in ADRs +0005, 0007, 0012, and 0017 without adding a framework, build step, dependency, +schema change, or new subsystem. diff --git a/server/accounts.js b/server/accounts.js index 9cdbf00..b4276c8 100644 --- a/server/accounts.js +++ b/server/accounts.js @@ -48,7 +48,9 @@ class Accounts { async authenticate(gamertagValue, passcodeValue) { const row = this.database.prepare('SELECT * FROM users WHERE gamertag = ? COLLATE NOCASE').get(normalizeGamertag(gamertagValue)); if (!row || !await verifyPasscode(String(passcodeValue || ''), row.passcode_hash)) throw new Error('Gamertag or passcode is incorrect.'); - return publicUser(row); + const current = this.database.prepare('SELECT * FROM users WHERE id = ? AND passcode_hash = ?').get(row.id, row.passcode_hash); + if (!current) throw new Error('Gamertag or passcode is incorrect.'); + return publicUser(current); } createSession(userId) { @@ -76,7 +78,8 @@ class Accounts { try { const passcodeHash = passcode ? await hashPasscode(passcode) : current.passcode_hash; this.database.exec('BEGIN IMMEDIATE'); - this.database.prepare('UPDATE users SET gamertag = ?, passcode_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(gamertag, passcodeHash, userId); + const updated = this.database.prepare('UPDATE users SET gamertag = ?, passcode_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND passcode_hash = ?').run(gamertag, passcodeHash, userId, current.passcode_hash); + if (!updated.changes) throw new Error('Current passcode is incorrect.'); this.database.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId); this.database.exec('COMMIT'); } catch (error) { diff --git a/server/battle-tanks-rooms.js b/server/battle-tanks-rooms.js index fce13c1..88963b0 100644 --- a/server/battle-tanks-rooms.js +++ b/server/battle-tanks-rooms.js @@ -1,5 +1,7 @@ 'use strict'; +const { send, sendSerialized } = require('./websocket-messages'); + const game = require('../battle-tanks/scripts/game'); const { createRoomCode, createRoomToken, normalizePasscode } = require('./room-identity'); @@ -155,8 +157,8 @@ class BattleTanksRooms { } } disconnect(room, player, socket = player.socket) { if (player.socket !== socket) return false; Object.assign(player, { connected: false, socket: null, disconnectedAt: Date.now() }); room.paused = room.game.phase !== 'setup' && room.game.phase !== 'game-over'; room.touchedAt = Date.now(); return true; } - broadcast(room, message) { const body = JSON.stringify(message); room.players.forEach(item => { if (item?.socket?.readyState === 1) item.socket.send(body); }); } - broadcastState(room, { forceArena = false } = {}) { const arenaVersion = `${room.matchId}:${room.game.impactSerial || 0}`; room.players.forEach((item, side) => { if (item?.socket?.readyState !== 1) return; const includeArena = forceArena || item.arenaVersion !== arenaVersion; item.socket.send(JSON.stringify({ type: 'state', state: this.stateFor(room, side, { includeArena }) })); item.arenaVersion = arenaVersion; }); } + broadcast(room, message) { const body = JSON.stringify(message); room.players.forEach(item => sendSerialized(item?.socket, body)); } + broadcastState(room, { forceArena = false } = {}) { const arenaVersion = `${room.matchId}:${room.game.impactSerial || 0}`; room.players.forEach((item, side) => { if (item?.socket?.readyState !== 1) return; const includeArena = forceArena || item.arenaVersion !== arenaVersion; if (send(item.socket, { type: 'state', state: this.stateFor(room, side, { includeArena }) })) item.arenaVersion = arenaVersion; }); } broadcastStates(now = Date.now()) { for (const room of this.rooms.values()) if (room.game.phase === 'projectile-flight' && now - room.lastBroadcast >= 40) { room.lastBroadcast = now; this.broadcastState(room); } } } diff --git a/server/http-security.js b/server/http-security.js index c0c280a..9e83d4b 100644 --- a/server/http-security.js +++ b/server/http-security.js @@ -1,6 +1,7 @@ 'use strict'; const net = require('node:net'); +const path = require('node:path'); class HttpError extends Error { constructor(message, status = 400, closeConnection = false) { super(message); this.status = status; this.closeConnection = closeConnection; } @@ -58,7 +59,7 @@ async function readJson(request, maximum = 10000, { guard = null, ip = 'unknown' const PRIVATE_TOP_LEVEL = new Set([ 'compose.yaml', 'compose.nas.yaml', - 'Dockerfile', + 'dockerfile', 'package.json', 'package-lock.json', 'releases.json', @@ -133,7 +134,7 @@ function useSecureCookies(environment = process.env) { } function isPrivatePath(pathname) { - const segments = pathname.split('/').filter(Boolean); + const segments = path.posix.normalize('/' + pathname.replace(/\\/g, '/')).split('/').filter(Boolean).map(segment => segment.toLowerCase().replace(/[. ]+$/g, '')); if (segments.some(segment => segment.startsWith('.'))) return true; if (['server', 'tests', 'node_modules', 'data'].includes(segments[0])) return true; return segments.length === 1 && PRIVATE_TOP_LEVEL.has(segments[0]); diff --git a/server/index.js b/server/index.js index 03fcdd7..b268851 100644 --- a/server/index.js +++ b/server/index.js @@ -18,7 +18,7 @@ const { createBuildInformation } = require('./build-info'); const root = path.resolve(__dirname, '..'); generateIcons(root, { onlyMissing: true }); -const port = Number(process.env.PORT) || 8080; +const port = process.env.PORT === '0' ? 0 : Number(process.env.PORT) || 8080; const buildInformation = createBuildInformation(); const database = openDatabase(); const achievements = new Achievements(database); @@ -79,6 +79,7 @@ function sessionCookie(token, expiresAt) { return `arcade_session=${encodeURICom const loginLimiter = new RateLimiter(10, 15 * 60 * 1000); const loginIpLimiter = new RateLimiter(50, 15 * 60 * 1000); const registrationLimiter = new RateLimiter(5, 60 * 60 * 1000); +const profileLimiter = new RateLimiter(10, 15 * 60 * 1000); const resultLimiter = new RateLimiter(60, 60 * 60 * 1000); const achievementCheckpointLimiter = new RateLimiter(120, 60 * 60 * 1000); const saveLimiter = new RateLimiter(120, 60 * 60 * 1000); @@ -162,6 +163,7 @@ const server = http.createServer(async (request, response) => { return json(response, 200, accounts.profile(user.id, parameters.get('page'), parameters.get('pageSize'))); } if (pathname === '/api/profile' && request.method === 'PATCH') { + const retryAfter = Math.max(profileLimiter.consume(user.id), loginIpLimiter.consume(clientIp(request))); if (retryAfter) return throttle(response, retryAfter); const updated = await accounts.update(user.id, await readRequestJson(request)), session = accounts.createSession(user.id); return json(response, 200, { user: updated }, { 'set-cookie': sessionCookie(session.token, session.expiresAt) }); } @@ -233,6 +235,7 @@ function acceptMessage(socket) { } websocketServer.on('connection', (socket, request) => { + socket.on('error', () => socket.terminate()); // Every websocket shares the same heartbeat. Tic-tac-toe used to return // before these handlers were installed, so its healthy connections were // terminated on the first heartbeat interval. @@ -282,6 +285,7 @@ function handleTicSocket(socket, request) { if (!acceptMessage(socket)) return; try { const message = parseMessage(raw); + if (membership && membership.player.socket !== socket) throw new Error('This connection has been replaced by a newer session.'); if (!membership && message.type === 'create-room') membership = createRoom(socket, ticRooms, () => ticRooms.create(socket, { ...message, user })); else if (!membership && message.type === 'join-room') membership = joinRoom(socket, () => ticRooms.join(message.roomCode, socket, message.passcode, gamertag, user)); else if (!membership && message.type === 'resume') membership = ticRooms.resume(message.roomCode, message.playerToken, socket); @@ -335,7 +339,7 @@ let lastTankTick = Date.now(); const tankSimulation = setInterval(() => { const now = Date.now(); tankRooms.tick((now - lastTankTick) / 1000, now); lastTankTick = now; tankRooms.broadcastStates(now); }, 1000 / 60); const heartbeat = setInterval(() => websocketServer.clients.forEach(socket => { if (!socket.isAlive) return socket.terminate(); socket.isAlive = false; socket.ping(); }), 15000); -server.listen(port, '0.0.0.0', () => console.log(`JavaScript Playground listening on http://0.0.0.0:${port}`)); +server.listen(port, '0.0.0.0', () => console.log(`JavaScript Playground listening on http://0.0.0.0:${server.address().port}`)); function shutdown() { clearInterval(simulation); clearInterval(ticCleanup); clearInterval(tankSimulation); clearInterval(broadcast); clearInterval(heartbeat); websocketServer.clients.forEach(socket => socket.close(1001, 'Server shutting down')); server.close(() => { database.close(); process.exit(0); }); } process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); diff --git a/server/rooms.js b/server/rooms.js index e61c57b..d062efe 100644 --- a/server/rooms.js +++ b/server/rooms.js @@ -2,6 +2,7 @@ const { createGame, startGame, update, setInput, setColor, snapshot } = require('./game'); const { createRoomCode, createRoomToken, normalizePasscode } = require('./room-identity'); +const { send, sendSerialized } = require('./websocket-messages'); class RoomManager { constructor({ reconnectMs = 15000, roomTimeoutMs = 30 * 60 * 1000, random = Math.random, recordResult = null } = {}) { @@ -111,8 +112,7 @@ class RoomManager { deliverResult(player) { if (!player.pendingResult || player.socket?.readyState !== 1) return; - player.socket.send(JSON.stringify({ type: 'result-recorded', result: player.pendingResult })); - player.pendingResult = null; + if (send(player.socket, { type: 'result-recorded', result: player.pendingResult })) player.pendingResult = null; } disconnect(room, player, socket = player.socket) { @@ -143,7 +143,7 @@ class RoomManager { broadcast(room, message) { const body = JSON.stringify(message); - room.players.forEach(player => { if (player?.socket?.readyState === 1) player.socket.send(body); }); + room.players.forEach(player => sendSerialized(player?.socket, body)); } broadcastStates(serverTime = Date.now()) { diff --git a/server/saves.js b/server/saves.js index edc96a4..29d94d2 100644 --- a/server/saves.js +++ b/server/saves.js @@ -15,6 +15,7 @@ const MAX_STATE_BYTES = 256 * 1024; const MAX_SCREENSHOT_BYTES = 256 * 1024; const DEFAULT_MAX_TOTAL_BYTES = 512 * 1024 * 1024; const DEFAULT_MAX_USER_BYTES = 16 * 1024 * 1024; +const SUMMARY_COLUMNS = 'slot, game, title, mode, state_version, elapsed_seconds, score_label, generation, revision, created_at, updated_at'; class SaveError extends Error { constructor(message, status = 400, code = 'INVALID_SAVE', current = null) { @@ -95,7 +96,7 @@ class GameSaves { } list(userId, gameValue) { const game = gameId(gameValue); - return this.database.prepare('SELECT * FROM game_saves WHERE user_id = ? AND game = ? ORDER BY slot').all(userId, game).map(summary); + return this.database.prepare(`SELECT ${SUMMARY_COLUMNS} FROM game_saves WHERE user_id = ? AND game = ? ORDER BY slot`).all(userId, game).map(summary); } load(userId, gameValue, slotValue) { const game = gameId(gameValue), slot = slotNumber(slotValue), row = this.row(userId, game, slot); @@ -105,7 +106,7 @@ class GameSaves { return { ...summary(row), state }; } screenshot(userId, gameValue, slotValue) { - const game = gameId(gameValue), slot = slotNumber(slotValue), row = this.row(userId, game, slot); + const game = gameId(gameValue), slot = slotNumber(slotValue), row = this.database.prepare('SELECT screenshot, screenshot_mime FROM game_saves WHERE user_id = ? AND game = ? AND slot = ?').get(userId, game, slot); if (!row) throw new SaveError('Save screenshot not found.', 404, 'SAVE_NOT_FOUND'); return { data: Buffer.from(row.screenshot), mimeType: row.screenshot_mime }; } diff --git a/server/static-assets.js b/server/static-assets.js index 522537d..f0d4d2e 100644 --- a/server/static-assets.js +++ b/server/static-assets.js @@ -2,6 +2,8 @@ const fs = require('node:fs'); const path = require('node:path'); +const { pipeline } = require('node:stream/promises'); +const { isPrivatePath } = require('./http-security'); function assetTag(stats) { return `W/"${stats.size.toString(16)}-${Math.floor(stats.mtimeMs).toString(16)}"`; @@ -15,16 +17,24 @@ function requestIsFresh(request, etag, modifiedAt) { return Number.isFinite(since) && Math.floor(modifiedAt.getTime() / 1000) <= Math.floor(since / 1000); } -function createStaticAssetHandler({ root, mime, contentSecurityPolicy }) { +function createStaticAssetHandler({ root, mime, contentSecurityPolicy, maxCacheBytes = 8 * 1024 * 1024, maxCachedFileBytes = 512 * 1024 }) { const cache = new Map(); + let cachedBytes = 0; + root = path.resolve(root); return async function serveStaticAsset(request, response, pathname) { + if (!['GET', 'HEAD'].includes(request.method)) { response.writeHead(405, { allow: 'GET, HEAD' }).end('Method not allowed'); return; } + if (/[\\:\0]/.test(pathname) || isPrivatePath(pathname)) { response.writeHead(404).end('Not found'); return; } let filePath = path.resolve(root, `.${pathname}`); if (!filePath.startsWith(root + path.sep) && filePath !== root) { response.writeHead(403).end('Forbidden'); return; } try { let stats = await fs.promises.stat(filePath); if (stats.isDirectory()) { filePath = path.join(filePath, 'index.html'); stats = await fs.promises.stat(filePath); } if (!stats.isFile()) throw Object.assign(new Error('Not a file'), { code: 'ENOENT' }); - const assetPath = path.relative(root, filePath).split(path.sep).join('/'), etag = assetTag(stats), modifiedAt = stats.mtime; + const realRoot = await fs.promises.realpath(root); + filePath = await fs.promises.realpath(filePath); + const relativePath = path.relative(realRoot, filePath); + if (relativePath === '..' || relativePath.startsWith('..' + path.sep) || path.isAbsolute(relativePath) || isPrivatePath('/' + relativePath)) { response.writeHead(404).end('Not found'); return; } + const assetPath = relativePath.split(path.sep).join('/'), etag = assetTag(stats), modifiedAt = stats.mtime; const headers = { 'content-type': mime[path.extname(filePath)] || 'application/octet-stream', 'cache-control': 'no-cache', @@ -33,12 +43,33 @@ function createStaticAssetHandler({ root, mime, contentSecurityPolicy }) { }; response.setHeader('content-security-policy', contentSecurityPolicy(assetPath)); if (requestIsFresh(request, etag, modifiedAt)) { response.writeHead(304, headers); response.end(); return; } + headers['content-length'] = stats.size; if (request.method === 'HEAD') { response.writeHead(200, headers); response.end(); return; } let entry = cache.get(filePath); - if (!entry || entry.etag !== etag) { entry = { etag, content: await fs.promises.readFile(filePath) }; cache.set(filePath, entry); } + if (entry) { cache.delete(filePath); cachedBytes -= entry.content.length; } + if (!entry || entry.etag !== etag) { + if (stats.size > Math.min(maxCachedFileBytes, maxCacheBytes)) { + response.writeHead(200, headers); + await pipeline(fs.createReadStream(filePath), response); + return; + } + entry = { etag, content: await fs.promises.readFile(filePath) }; + } + // The file can grow between stat and read; only retain buffers within the budget. + if (entry.content.length <= Math.min(maxCachedFileBytes, maxCacheBytes)) { + while (cachedBytes + entry.content.length > maxCacheBytes && cache.size) { + const oldest = cache.keys().next().value; + cachedBytes -= cache.get(oldest).content.length; cache.delete(oldest); + } + // Another request may have populated this key while readFile was pending. + if (cache.has(filePath)) cachedBytes -= cache.get(filePath).content.length; + cache.set(filePath, entry); cachedBytes += entry.content.length; + } + headers['content-length'] = entry.content.length; response.writeHead(200, headers); response.end(entry.content); } catch (error) { + if (response.headersSent) { response.destroy(); return; } const missing = error.code === 'ENOENT' || error.code === 'ENOTDIR'; response.writeHead(missing ? 404 : 500).end(missing ? 'Not found' : 'Server error'); } diff --git a/server/tictactoe-rooms.js b/server/tictactoe-rooms.js index 6e5d959..354e45b 100644 --- a/server/tictactoe-rooms.js +++ b/server/tictactoe-rooms.js @@ -1,5 +1,6 @@ 'use strict'; const {createRoomCode,createRoomToken,normalizePasscode}=require('./room-identity'); +const {send,sendSerialized}=require('./websocket-messages'); const game=require('../tictactoe/scripts/game'); class TicTacToeRooms { constructor({reconnectMs=15000,roomTimeoutMs=1800000,random=Math.random,recordResult=null}={}){this.rooms=new Map();Object.assign(this,{reconnectMs,roomTimeoutMs,random,recordResult});} @@ -13,11 +14,11 @@ class TicTacToeRooms { ready(room,player){if(room.running&&!room.over)throw new Error('The match is already in progress.');player.ready=true;if(room.players.every(item=>item?.ready&&item.connected)){this.start(room);return true;}return false;} rematch(room){if(!room.players.every(item=>item?.connected))throw new Error('Both players must be connected to start a rematch.');if(room.running&&!room.over)return true;if(!room.over)throw new Error('The match must be finished before starting a rematch.');this.start(room);room.touchedAt=Date.now();return true;} finish(room){if(room.recorded||!room.over)return;room.recorded=true;const seconds=Math.max(1,Math.min(86400,Math.round((Date.now()-room.started)/1000))),moves=room.board.filter(Boolean).length;room.players.forEach((player,side)=>{if(!player?.userId||!this.recordResult)return;const draw=room.winner===null,won=!draw&&room.winner===side;try{player.pendingResult=this.recordResult(player.userId,{game:'tictactoe',won,details:{mode:'online',moves,seconds,outcome:draw?'draw':won?'win':'loss'}})||null;this.deliverResult(player);}catch{/* A persistence failure must not stop the room. */}});} - deliverResult(player){if(!player.pendingResult||player.socket?.readyState!==1)return;player.socket.send(JSON.stringify({type:'result-recorded',result:player.pendingResult}));player.pendingResult=null;} + deliverResult(player){if(!player.pendingResult||player.socket?.readyState!==1)return;if(send(player.socket,{type:'result-recorded',result:player.pendingResult}))player.pendingResult=null;} move(room,player,cell){if(!room.running||room.over)throw new Error('The match is not active.');if(player.side!==room.turn)throw new Error('Wait for your turn.');if(!Number.isInteger(cell)||cell<0||cell>8||room.board[cell])throw new Error('That square is unavailable.');const result=game.play(room.board,cell,player.side);room.board=result.board;if(result.over)Object.assign(room,{running:false,over:true,winner:result.winner,winningLine:result.winningLine});else room.turn=1-room.turn;room.touchedAt=Date.now();if(room.over)this.finish(room);} color(room,player,color){if(/^#[0-9a-f]{6}$/i.test(color))room.colors[player.side]=color;} state(room){return{board:room.board,turn:room.turn,running:room.running,over:room.over,winner:room.winner,winningLine:room.winningLine,colors:room.colors,started:room.started};} - broadcast(room,message){const body=JSON.stringify(message);room.players.forEach(item=>{if(item?.socket?.readyState===1)item.socket.send(body);});} + broadcast(room,message){const body=JSON.stringify(message);room.players.forEach(item=>sendSerialized(item?.socket,body));} disconnect(room,player,socket=player.socket){if(player.socket!==socket)return false;Object.assign(player,{connected:false,socket:null,disconnectedAt:Date.now()});return true;} tick(now=Date.now()){for(const[code,room]of this.rooms){const expired=room.players.some(item=>item&&!item.connected&&now-item.disconnectedAt>this.reconnectMs);if(expired||now-room.touchedAt>this.roomTimeoutMs){this.broadcast(room,{type:'room-closed',reason:expired?'An opponent did not reconnect in time.':'Room expired.'});this.rooms.delete(code);}}} } diff --git a/server/websocket-messages.js b/server/websocket-messages.js index cd2c747..2f5df98 100644 --- a/server/websocket-messages.js +++ b/server/websocket-messages.js @@ -1,7 +1,19 @@ 'use strict'; +const MAX_BUFFERED_BYTES = 1024 * 1024; + +function sendSerialized(socket, body) { + if (socket?.readyState !== 1) return false; + if ((socket.bufferedAmount || 0) + Buffer.byteLength(body) > MAX_BUFFERED_BYTES) { + socket.terminate(); + return false; + } + socket.send(body); + return true; +} + function send(socket, message) { - if (socket.readyState === 1) socket.send(JSON.stringify(message)); + return sendSerialized(socket, JSON.stringify(message)); } function parseMessage(raw) { @@ -34,4 +46,4 @@ function roomStatusMessage(room) { }; } -module.exports = { parseMessage, roomStatusMessage, send, sessionMessage }; +module.exports = { parseMessage, roomStatusMessage, send, sendSerialized, sessionMessage }; diff --git a/service-worker.js b/service-worker.js index ae1092f..bf1381f 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,6 +1,6 @@ 'use strict'; -const CACHE_NAME = 'js-playground-v15'; +const CACHE_NAME = 'js-playground-v16'; const APP_SHELL = [ './', './index.html', @@ -29,9 +29,11 @@ self.addEventListener('activate', event => { self.addEventListener('fetch', event => { const request = event.request; const url = new URL(request.url); - if (request.method !== 'GET' || url.origin !== self.location.origin || url.pathname.includes('/api/')) return; + let pathname; + try { pathname = decodeURIComponent(url.pathname).replace(/\\/g, '/'); } catch { return; } + if (request.method !== 'GET' || url.origin !== self.location.origin || pathname.includes('/api/')) return; const network = fetch(request); - event.waitUntil(network.then(response => response.ok ? caches.open(CACHE_NAME).then(cache => cache.put(request, response.clone())) : undefined).catch(() => {})); + event.waitUntil(network.then(response => response.ok && !/\b(?:no-store|private)\b/i.test(response.headers.get('cache-control') || '') ? caches.open(CACHE_NAME).then(cache => cache.put(request, response.clone())) : undefined).catch(() => {})); if (request.mode === 'navigate') { event.respondWith(network.catch(() => caches.match(request).then(response => response || caches.match('./index.html')))); return; diff --git a/tests/accounts.test.js b/tests/accounts.test.js index 1e43313..781e0e3 100644 --- a/tests/accounts.test.js +++ b/tests/accounts.test.js @@ -58,6 +58,30 @@ test('validates result structure and derives scores on the server', async t => { assert.throws(() => accounts.record(user.id, { game: 'minesweeper', won: true, details: { difficulty: 'impossible', seconds: 1 } }), /Invalid Minesweeper/); }); +test('a profile update cannot restore a passcode changed during asynchronous verification', async t => { + const accounts = fixture(t), user = await accounts.create('Original', 'old-passcode'); + await accounts.create('Replacement', 'new-passcode'); + const replacementHash = accounts.database.prepare('SELECT passcode_hash FROM users WHERE gamertag = ?').get('Replacement').passcode_hash; + const session = accounts.createSession(user.id); + const pending = accounts.update(user.id, { gamertag: 'Renamed', currentPasscode: 'old-passcode' }); + // Model another completed credential update while the old verification is awaiting scrypt. + accounts.database.prepare('UPDATE users SET passcode_hash = ? WHERE id = ?').run(replacementHash, user.id); + await assert.rejects(pending, /Current passcode is incorrect/); + assert.equal((await accounts.authenticate('Original', 'new-passcode')).id, user.id); + await assert.rejects(() => accounts.authenticate('Original', 'old-passcode'), /incorrect/); + assert.equal(accounts.userForToken(session.token).id, user.id, 'rejected updates must not revoke other sessions'); +}); + +test('an in-flight login cannot authenticate a passcode that has just been replaced', async t => { + const accounts = fixture(t), user = await accounts.create('Original', 'old-passcode'); + await accounts.create('Replacement', 'new-passcode'); + const replacementHash = accounts.database.prepare('SELECT passcode_hash FROM users WHERE gamertag = ?').get('Replacement').passcode_hash; + const pending = accounts.authenticate('Original', 'old-passcode'); + accounts.database.prepare('UPDATE users SET passcode_hash = ? WHERE id = ?').run(replacementHash, user.id); + await assert.rejects(pending, /incorrect/); + assert.equal((await accounts.authenticate('Original', 'new-passcode')).id, user.id); +}); + test('faster time breaks ties on the leaderboard', async t => { const accounts = fixture(t); const fast = await accounts.create('Fast', 'passcode'); diff --git a/tests/http-security.test.js b/tests/http-security.test.js index 4c4bb6b..efd0f45 100644 --- a/tests/http-security.test.js +++ b/tests/http-security.test.js @@ -100,7 +100,7 @@ test('trusted proxy addresses must be valid and use the proxy-adjacent value', ( }); test('private implementation and configuration paths are not public assets', () => { - for (const pathname of ['/.git/config', '/.env', '/server/index.js', '/tests/accounts.test.js', '/data/arcade.sqlite', '/compose.nas.yaml', '/releases.json']) { + for (const pathname of ['/.git/config', '/.env', '/server/index.js', '/tests/accounts.test.js', '/data/arcade.sqlite', '/compose.nas.yaml', '/releases.json', '/pong/../server/index.js', '/SERVER/index.js', '/server./index.js', '/pong/..\\server\\index.js', '/DOCKERFILE']) { assert.equal(isPrivatePath(pathname), true, pathname); } assert.equal(isPrivatePath('/pong/index.html'), false); diff --git a/tests/install-app.test.js b/tests/install-app.test.js index 4551e86..4028c53 100644 --- a/tests/install-app.test.js +++ b/tests/install-app.test.js @@ -23,7 +23,7 @@ test('shared shell owns one installable arcade manifest', () => { assert.match(runtime, /manifest\.webmanifest/); assert.match(runtime, /serviceWorker\.register\(`\$\{rootPath\}service-worker\.js`/); assert.match(worker, /addEventListener\('fetch'/); - assert.match(worker, /url\.pathname\.includes\('\/api\/'\)/); + assert.match(worker, /pathname\.includes\('\/api\/'\)/); assert.match(worker, /request\.mode === 'navigate'/); assert.match(worker, /event\.waitUntil\(network\.then/); assert.match(worker, /fonts\/Silkscreen-Regular\.ttf/); diff --git a/tests/online-rooms.test.js b/tests/online-rooms.test.js index 386497e..0170f53 100644 --- a/tests/online-rooms.test.js +++ b/tests/online-rooms.test.js @@ -63,7 +63,7 @@ test('online pages cache-bust shared contract changes',()=>{ assert.match(page,new RegExp(`scripts/online-rooms\\.js\\?v=${rooms.CONTRACT_VERSION}`),`${file} must request the current shared contract`); } const worker=fs.readFileSync('service-worker.js','utf8'); - assert.match(worker,/CACHE_NAME = 'js-playground-v15'/); + assert.match(worker,/CACHE_NAME = 'js-playground-v16'/); }); test('Battle Tanks ignores obsolete sockets and owns one reconnect timer',()=>{ const app=fs.readFileSync('battle-tanks/scripts/app.js','utf8'); diff --git a/tests/server-security.test.js b/tests/server-security.test.js new file mode 100644 index 0000000..14495a7 --- /dev/null +++ b/tests/server-security.test.js @@ -0,0 +1,122 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const fs = require('node:fs'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { WebSocket } = require('ws'); + +async function fixture(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'arcade-server-security-')); + const child = spawn(process.execPath, ['-e', "const { server } = require('./server/index'); server.on('listening', () => process.send(server.address().port));"], { + cwd: path.resolve(__dirname, '..'), + env: { ...process.env, PORT: '0', DATABASE_PATH: path.join(directory, 'test.sqlite'), NODE_ENV: 'test', TRUST_PROXY: 'false', ALLOWED_ORIGINS: '' }, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'] + }); + let output = ''; child.stdout.on('data', chunk => { output += chunk; }); child.stderr.on('data', chunk => { output += chunk; }); + const exit = once(child, 'exit'); + t.after(async () => { child.kill(); await exit; fs.rmSync(directory, { recursive: true, force: true }); }); + const [port] = await Promise.race([ + once(child, 'message'), + exit.then(() => { throw new Error(`Server exited before startup: ${output}`); }) + ]); + const server = { port, origin: `http://127.0.0.1:${port}`, output: () => output }; + assert.equal((await request(server, '/healthz')).status, 200); + return server; +} + +function request(server, pathname, options = {}) { + return new Promise((resolve, reject) => { + const connection = http.request({ hostname: '127.0.0.1', port: server.port, path: pathname, ...options }, response => { + const chunks = []; response.on('data', chunk => chunks.push(chunk)); + response.on('end', () => resolve({ status: response.statusCode, headers: response.headers, body: Buffer.concat(chunks).toString() })); + }); + connection.on('error', reject); connection.end(options.body); + }); +} + +async function socket(t, server, endpoint) { + const client = new WebSocket(`ws://127.0.0.1:${server.port}${endpoint}`, { origin: server.origin }); + client.on('error', () => {}); + client.inbox = []; client.on('message', raw => client.inbox.push(JSON.parse(raw))); + t.after(() => client.terminate()); + await once(client, 'open'); + return client; +} + +async function message(client, predicate) { + while (true) { + const index = client.inbox.findIndex(predicate); + if (index >= 0) return client.inbox.splice(index, 1)[0]; + await once(client, 'message'); + } +} + +test('HTTP denies encoded private-path aliases before reading files', { timeout: 15000 }, async t => { + const server = await fixture(t); + for (const pathname of ['/pong/..%2fserver/index.js', '/pong/..%2fdata/arcade.sqlite', '/pong/..%2fpackage.json', '/SERVER/index.js', '/server.%20/index.js', '/pong%5c..%5cserver%5cindex.js']) { + const result = await request(server, pathname); + assert.equal(result.status, 404, pathname); assert.equal(result.body, 'Not found'); + } + assert.equal((await request(server, '/pong/')).status, 200); +}); + +test('malformed WebSocket frames close only the offending connection for every game', { timeout: 15000 }, async t => { + const server = await fixture(t); + for (const endpoint of ['/ws', '/ws/tictactoe', '/ws/battle-tanks']) { + const client = await socket(t, server, endpoint), closed = once(client, 'close'); + // A masked empty text frame with RSV1 set, without negotiated compression. + client._socket.write(Buffer.from([0xc1, 0x80, 0, 0, 0, 0])); + await closed; + const health = await request(server, '/healthz'); + assert.equal(health.status, 200, server.output()); + } +}); + +test('profile passcode verification is rate limited independently of session rotation', { timeout: 15000 }, async t => { + const server = await fixture(t); + const registration = await request(server, '/api/auth/register', { method: 'POST', headers: { 'content-type': 'application/json', origin: server.origin }, body: JSON.stringify({ gamertag: 'ProfileGuard', passcode: 'passcode' }) }); + assert.equal(registration.status, 201); + let cookie = registration.headers['set-cookie'][0].split(';')[0]; + const update = currentPasscode => request(server, '/api/profile', { method: 'PATCH', headers: { 'content-type': 'application/json', origin: server.origin, cookie }, body: JSON.stringify({ currentPasscode }) }); + const rotated = await update('passcode'); assert.equal(rotated.status, 200); + cookie = rotated.headers['set-cookie'][0].split(';')[0]; + for (let attempt = 0; attempt < 9; attempt += 1) assert.equal((await update('wrong')).status, 400); + const limited = await update('wrong'); + assert.equal(limited.status, 429); assert.ok(Number(limited.headers['retry-after']) > 0); + assert.equal((await request(server, '/api/me', { headers: { cookie } })).status, 200); +}); + +test('tic-tac-toe rejects replaced sockets while preserving resume, turns, and rematches', { timeout: 15000 }, async t => { + const server = await fixture(t), host = await socket(t, server, '/ws/tictactoe'); + host.send(JSON.stringify({ type: 'create-room', visibility: 'public' })); + const session = await message(host, item => item.type === 'session'); + const guest = await socket(t, server, '/ws/tictactoe'); + guest.send(JSON.stringify({ type: 'join-room', roomCode: session.roomCode })); + await message(guest, item => item.type === 'session'); + host.send(JSON.stringify({ type: 'ready' })); guest.send(JSON.stringify({ type: 'ready' })); + await message(guest, item => item.type === 'state' && item.state.running); + // Keep the old transport alive until an already queued action reaches the server. + host._socket.pause(); + const replacement = await socket(t, server, '/ws/tictactoe'); + replacement.send(JSON.stringify({ type: 'resume', roomCode: session.roomCode, playerToken: session.playerToken })); + await message(replacement, item => item.type === 'session'); + await message(replacement, item => item.type === 'state' && item.state.running); + const closed = once(host, 'close'); + await new Promise((resolve, reject) => host.send(JSON.stringify({ type: 'move', cell: 8 }), error => error ? reject(error) : resolve())); + host._socket.resume(); await closed; + replacement.send(JSON.stringify({ type: 'color', color: '#123456' })); + const resumed = await message(replacement, item => item.type === 'state' && item.state.colors[0] === '#123456'); + assert.deepEqual(resumed.state.board, Array(9).fill(null)); + for (const [client, cell] of [[replacement, 0], [guest, 3], [replacement, 1], [guest, 4], [replacement, 2]]) { + client.send(JSON.stringify({ type: 'move', cell })); + await message(replacement, item => item.type === 'state' && item.state.board[cell] !== null); + } + replacement.send(JSON.stringify({ type: 'rematch' })); + const rematch = await message(replacement, item => item.type === 'state' && item.state.running && item.state.board.every(cell => cell === null)); + assert.equal(rematch.state.turn, 1); +}); diff --git a/tests/service-worker.test.js b/tests/service-worker.test.js new file mode 100644 index 0000000..5360fbd --- /dev/null +++ b/tests/service-worker.test.js @@ -0,0 +1,54 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const test = require('node:test'); +const vm = require('node:vm'); + +function worker(cacheControl = 'no-cache') { + const handlers = {}, writes = [], waits = [], deleted = []; + const response = { ok: true, headers: new Headers({ 'cache-control': cacheControl }), clone() { return this; } }; + const context = { + URL, + self: { location: { origin: 'https://arcade.test' }, addEventListener(type, handler) { handlers[type] = handler; }, skipWaiting() {}, clients: { claim: async () => {} } }, + fetch: async () => response, + caches: { match: async () => undefined, open: async () => ({ put: async request => writes.push(request.url) }), keys: async () => ['js-playground-v15', 'js-playground-v16'], delete: async name => deleted.push(name) } + }; + vm.runInNewContext(fs.readFileSync('service-worker.js', 'utf8'), context); + return { + writes, response, deleted, + async activate() { handlers.activate({ waitUntil(promise) { waits.push(promise); } }); await Promise.all(waits); }, + async fetch(pathname, mode = 'cors') { + let result; + handlers.fetch({ request: { method: 'GET', url: `https://arcade.test${pathname}`, mode }, waitUntil(promise) { waits.push(promise); }, respondWith(promise) { result = promise; } }); + const returned = await result; await Promise.all(waits); return returned; + } + }; +} + +test('service worker excludes API paths including encoded separators and traversal aliases', async () => { + const app = worker(); + for (const pathname of ['/api/profile', '/%61pi/profile', '/api%2fprofile', '/pong/..%2fapi/profile', '/api%5cprofile', '/bad%zz']) { + assert.equal(await app.fetch(pathname), undefined, pathname); + } + assert.deepEqual(app.writes, []); +}); + +test('service worker never stores private or no-store responses', async () => { + for (const directive of ['no-store', 'private, max-age=0', 'public, NO-STORE', 'private="set-cookie"']) { + const app = worker(directive); + assert.equal(await app.fetch('/healthz', 'navigate'), app.response); + assert.deepEqual(app.writes, [], directive); + } +}); + +test('service worker still caches public static assets that require revalidation', async () => { + const app = worker(); + assert.equal(await app.fetch('/arcade.js'), app.response); + assert.deepEqual(app.writes, ['https://arcade.test/arcade.js']); +}); + +test('service worker activation removes the previous cache that may contain private responses', async () => { + const app = worker(); await app.activate(); + assert.deepEqual(app.deleted, ['js-playground-v15']); +}); diff --git a/tests/static-assets.test.js b/tests/static-assets.test.js index 1806192..b928343 100644 --- a/tests/static-assets.test.js +++ b/tests/static-assets.test.js @@ -5,6 +5,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const test = require('node:test'); +const { PassThrough } = require('node:stream'); const { createStaticAssetHandler } = require('../server/static-assets'); function response() { @@ -45,3 +46,83 @@ test('static asset handler keeps resolved paths inside its public root', async ( fs.rmSync(root, { recursive: true, force: true }); } }); + +test('static assets cannot reach private paths through normalized filesystem aliases', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'arcade-private-')); + try { + fs.mkdirSync(path.join(root, 'server')); + fs.mkdirSync(path.join(root, 'pong')); + fs.writeFileSync(path.join(root, 'server', 'index.js'), 'private implementation'); + fs.writeFileSync(path.join(root, 'package.json'), 'private configuration'); + const serve = createStaticAssetHandler({ root, mime: {}, contentSecurityPolicy: () => '' }); + for (const pathname of ['/pong/../server/index.js', '/pong/../package.json', '/SERVER/index.js', '/server./index.js', '/pong/..\\server\\index.js']) { + const blocked = response(); await serve({ method: 'GET', headers: {} }, blocked, pathname); + assert.equal(blocked.status, 404, pathname); + assert.equal(blocked.body, 'Not found'); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('static assets reject directory links into private directories and outside the root', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'arcade-links-')); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'arcade-outside-')); + try { + fs.mkdirSync(path.join(root, 'server')); + fs.writeFileSync(path.join(root, 'server', 'index.html'), 'private implementation'); + fs.writeFileSync(path.join(outside, 'index.html'), 'outside file'); + fs.symlinkSync(path.join(root, 'server'), path.join(root, 'alias'), 'junction'); + fs.symlinkSync(outside, path.join(root, 'escape'), 'junction'); + const serve = createStaticAssetHandler({ root, mime: {}, contentSecurityPolicy: () => '' }); + for (const pathname of ['/alias/', '/escape/']) { + const blocked = response(); await serve({ method: 'GET', headers: {} }, blocked, pathname); + assert.equal(blocked.status, 404, pathname); + } + } finally { + fs.unlinkSync(path.join(root, 'alias')); fs.unlinkSync(path.join(root, 'escape')); + fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(outside, { recursive: true, force: true }); + } +}); + +test('static cache evicts least recently used buffers within its byte budget', async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'arcade-static-budget-')); + try { + for (const name of ['a', 'b', 'c']) fs.writeFileSync(path.join(root, `${name}.js`), name.repeat(3)); + const reads = t.mock.method(fs.promises, 'readFile'); + const serve = createStaticAssetHandler({ root, mime: {}, contentSecurityPolicy: () => '', maxCacheBytes: 6 }); + const get = async name => { const result = response(); await serve({ method: 'GET', headers: {} }, result, `/${name}.js`); assert.equal(result.status, 200); }; + await get('a'); await get('b'); await get('a'); await get('c'); await get('a'); + assert.equal(reads.mock.callCount(), 3, 'recently used a stays cached'); + await get('b'); assert.equal(reads.mock.callCount(), 4, 'b was evicted'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('large static files stream without entering the readFile cache', async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'arcade-static-stream-')); + try { + const content = Buffer.alloc(128 * 1024, 'x'); fs.writeFileSync(path.join(root, 'large.js'), content); + const reads = t.mock.method(fs.promises, 'readFile'); + const serve = createStaticAssetHandler({ root, mime: {}, contentSecurityPolicy: () => '', maxCachedFileBytes: 1024 }); + for (let attempt = 0; attempt < 2; attempt += 1) { + const stream = new PassThrough(), helpers = response(), chunks = []; + Object.assign(stream, { headers: helpers.headers, setHeader: helpers.setHeader, writeHead: helpers.writeHead }); + stream.on('data', chunk => chunks.push(chunk)); + await serve({ method: 'GET', headers: {} }, stream, '/large.js'); + assert.equal(stream.status, 200); assert.deepEqual(Buffer.concat(chunks), content); + assert.equal(stream.headers['content-length'], content.length); + } + assert.equal(reads.mock.callCount(), 0); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('static assets reject mutation methods before reading files', async t => { + const reads = t.mock.method(fs.promises, 'readFile'); + const serve = createStaticAssetHandler({ root: path.resolve(__dirname, '..'), mime: {}, contentSecurityPolicy: () => '' }); + const result = response(); await serve({ method: 'POST', headers: {} }, result, '/index.html'); + assert.equal(result.status, 405); assert.equal(result.headers.allow, 'GET, HEAD'); assert.equal(reads.mock.callCount(), 0); +}); diff --git a/tests/websocket-messages.test.js b/tests/websocket-messages.test.js index 5c4ed90..5876ecd 100644 --- a/tests/websocket-messages.test.js +++ b/tests/websocket-messages.test.js @@ -3,6 +3,9 @@ const assert = require('node:assert/strict'); const test = require('node:test'); const { parseMessage, roomStatusMessage, send, sessionMessage } = require('../server/websocket-messages'); +const { RoomManager } = require('../server/rooms'); +const { TicTacToeRooms } = require('../server/tictactoe-rooms'); +const { BattleTanksRooms } = require('../server/battle-tanks-rooms'); test('WebSocket messages require JSON objects', () => { assert.deepEqual(parseMessage(Buffer.from('{"type":"ready"}')), { type: 'ready' }); @@ -28,3 +31,38 @@ test('WebSocket sends only to open sockets', () => { send(open, { type: 'state' }); send(closed, { type: 'state' }); assert.deepEqual(open.messages, [{ type: 'state' }]); }); + +test('WebSocket send budgets count UTF-8 bytes and terminate slow consumers', () => { + const socket = { readyState: 1, bufferedAmount: 1024 * 1024 - 16, terminated: false, send() { throw new Error('must not queue more data'); }, terminate() { this.terminated = true; } }; + assert.equal(send(socket, { text: '✓✓✓' }), false); + assert.equal(socket.terminated, true); +}); + +test('all room broadcasts enforce the send budget without interrupting the other player', () => { + for (const Manager of [RoomManager, TicTacToeRooms, BattleTanksRooms]) { + const slow = { readyState: 1, bufferedAmount: 1024 * 1024, terminate() { this.readyState = 3; }, send() { throw new Error('must not queue more data'); } }; + const healthy = { readyState: 1, messages: [], send(body) { this.messages.push(JSON.parse(body)); } }; + const rooms = new Manager(), { room } = rooms.create(slow, { visibility: 'public' }); + rooms.join(room.code, healthy); + rooms.broadcast(room, { type: 'room-status' }); + assert.equal(slow.readyState, 3, Manager.name); + assert.deepEqual(healthy.messages, [{ type: 'room-status' }], Manager.name); + if (Manager === BattleTanksRooms) { + slow.readyState = 1; + rooms.broadcastState(room); + assert.equal(slow.readyState, 3); + assert.equal(room.players[0].arenaVersion, null, 'unsent geometry must be retried on resume'); + assert.equal(healthy.messages.at(-1).type, 'state'); + } + } +}); + +test('Pong and tic-tac-toe retain unsent result receipts for reconnect', () => { + for (const Manager of [RoomManager, TicTacToeRooms]) { + const rooms = new Manager(), receipt = { id: 12, score: 100 }; + const player = { pendingResult: receipt, socket: { readyState: 1, bufferedAmount: 1024 * 1024, terminate() { this.readyState = 3; }, send() { throw new Error('must not queue more data'); } } }; + rooms.deliverResult(player); assert.deepEqual(player.pendingResult, receipt); + player.socket = { readyState: 1, send(body) { assert.deepEqual(JSON.parse(body), { type: 'result-recorded', result: receipt }); } }; + rooms.deliverResult(player); assert.equal(player.pendingResult, null); + } +});