diff --git a/.env.example b/.env.example index 029fa5e..1a550d6 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,6 @@ APP_PORT=8787 - +ALLOWED_ORIGINS= +MAX_CLIENTS=200 +MAX_CLIENTS_PER_IP=12 +MAX_MESSAGES_PER_WINDOW=40 +TRUST_PROXY=false diff --git a/Dockerfile b/Dockerfile index dfaaaa0..f0b08f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,4 +13,6 @@ EXPOSE 8787 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD node -e "fetch('http://127.0.0.1:' + (process.env.APP_PORT || 8787) + '/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" +USER node + CMD ["node", "server.js"] diff --git a/README.md b/README.md index 9a31796..11d6e2a 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,27 @@ $env:APP_PORT=9090; npm start - The server keeps games in memory only. - If an opponent closes their window, the remaining player sees a disconnect error and waits for a new player. - No build step is required. + +## Hardening Knobs + +The server is intentionally small, but it rejects common abuse cases: + +- static files are served from an allowlist only +- HTTP responses include basic browser security headers +- WebSocket upgrades are origin-checked +- clients, clients per IP, message size, and message velocity are capped +- moves are validated server-side before being relayed +- the Compose container runs as non-root with a read-only filesystem and dropped capabilities + +Optional `.env` settings: + +```text +APP_PORT=8787 +ALLOWED_ORIGINS=https://tic.sketchferret.com +MAX_CLIENTS=200 +MAX_CLIENTS_PER_IP=12 +MAX_MESSAGES_PER_WINDOW=40 +TRUST_PROXY=true +``` + +Use `TRUST_PROXY=true` only when the app is behind a reverse proxy that sets `X-Forwarded-For`. diff --git a/compose.yaml b/compose.yaml index bacf657..6bdd4a5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,7 +5,18 @@ services: image: tictactics:local container_name: tictactics restart: unless-stopped + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + pids_limit: 128 environment: APP_PORT: ${APP_PORT:-8787} + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-} + MAX_CLIENTS: ${MAX_CLIENTS:-200} + MAX_CLIENTS_PER_IP: ${MAX_CLIENTS_PER_IP:-12} + MAX_MESSAGES_PER_WINDOW: ${MAX_MESSAGES_PER_WINDOW:-40} + TRUST_PROXY: ${TRUST_PROXY:-false} ports: - "${APP_PORT:-8787}:${APP_PORT:-8787}" diff --git a/server.js b/server.js index 3657a09..6772b57 100644 --- a/server.js +++ b/server.js @@ -5,39 +5,85 @@ const path = require("path"); const appPort = Number(process.env.APP_PORT || 8787); const root = __dirname; +const boardSize = 5; +const winTarget = 4; +const empty = ""; +const maxClients = Number(process.env.MAX_CLIENTS || 200); +const maxClientsPerIp = Number(process.env.MAX_CLIENTS_PER_IP || 12); +const maxMessageBytes = Number(process.env.MAX_MESSAGE_BYTES || 1024); +const messageWindowMs = Number(process.env.MESSAGE_WINDOW_MS || 10_000); +const maxMessagesPerWindow = Number(process.env.MAX_MESSAGES_PER_WINDOW || 40); +const minActionIntervalMs = Number(process.env.MIN_ACTION_INTERVAL_MS || 180); +const idleSocketTimeoutMs = Number(process.env.IDLE_SOCKET_TIMEOUT_MS || 120_000); +const allowedOrigins = (process.env.ALLOWED_ORIGINS || "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); +const trustProxy = process.env.TRUST_PROXY === "true"; + const clients = new Map(); const rooms = new Map(); +const clientsByIp = new Map(); let waitingClientId = ""; let nextClientId = 1; let nextRoomId = 1; +const staticFiles = new Map([ + ["/", "index.html"], + ["/index.html", "index.html"], + ["/styles.css", "styles.css"], + ["/game.js", "game.js"], +]); + const mimeTypes = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", }; +const directions = [ + [-1, -1], + [-1, 0], + [-1, 1], + [0, -1], + [0, 1], + [1, -1], + [1, 0], + [1, 1], +]; + +const winAxes = [ + [0, 1], + [1, 0], + [1, 1], + [1, -1], +]; + const server = http.createServer((request, response) => { + setSecurityHeaders(response); + + if (request.method !== "GET" && request.method !== "HEAD") { + response.writeHead(405, { Allow: "GET, HEAD" }); + response.end("Method not allowed"); + return; + } + const requestUrl = new URL(request.url, `http://${request.headers.host}`); if (requestUrl.pathname === "/healthz") { response.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ ok: true })); + response.end(request.method === "HEAD" ? undefined : JSON.stringify({ ok: true })); return; } - const pathname = requestUrl.pathname === "/" ? "/index.html" : requestUrl.pathname; - const filePath = path.normalize(path.join(root, pathname)); - - if (!filePath.startsWith(root)) { - response.writeHead(403); - response.end("Forbidden"); + const staticName = staticFiles.get(requestUrl.pathname); + if (!staticName) { + response.writeHead(404); + response.end("Not found"); return; } + const filePath = path.join(root, staticName); fs.readFile(filePath, (error, content) => { if (error) { response.writeHead(404); @@ -45,21 +91,39 @@ const server = http.createServer((request, response) => { return; } - const type = mimeTypes[path.extname(filePath)] || "application/octet-stream"; - response.writeHead(200, { "Content-Type": type }); - response.end(content); + response.writeHead(200, { + "Cache-Control": "no-store", + "Content-Type": mimeTypes[path.extname(filePath)] || "application/octet-stream", + }); + response.end(request.method === "HEAD" ? undefined : content); }); }); server.on("upgrade", (request, socket) => { if (request.url !== "/ws") { - socket.destroy(); + rejectUpgrade(socket, 404, "Not found"); + return; + } + + if (!isAllowedOrigin(request)) { + rejectUpgrade(socket, 403, "Forbidden"); + return; + } + + if (clients.size >= maxClients) { + rejectUpgrade(socket, 503, "Server full"); + return; + } + + const ip = clientIp(request); + if ((clientsByIp.get(ip) || 0) >= maxClientsPerIp) { + rejectUpgrade(socket, 429, "Too many connections"); return; } const key = request.headers["sec-websocket-key"]; - if (!key) { - socket.destroy(); + if (!isValidWebSocketKey(key) || request.headers["sec-websocket-version"] !== "13") { + rejectUpgrade(socket, 400, "Bad websocket request"); return; } @@ -77,17 +141,87 @@ server.on("upgrade", (request, socket) => { "", ].join("\r\n")); - addClient(socket); + addClient(socket, ip); }); -function addClient(socket) { +function setSecurityHeaders(response) { + response.setHeader("Content-Security-Policy", "default-src 'self'; connect-src 'self'; script-src 'self'; style-src 'self'; base-uri 'none'; frame-ancestors 'none'"); + response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + response.setHeader("Referrer-Policy", "no-referrer"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("X-Frame-Options", "DENY"); +} + +function rejectUpgrade(socket, status, message) { + socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`); + socket.destroy(); +} + +function isValidWebSocketKey(key) { + if (typeof key !== "string") { + return false; + } + + try { + return Buffer.from(key, "base64").length === 16; + } catch { + return false; + } +} + +function isAllowedOrigin(request) { + const origin = request.headers.origin; + if (!origin) { + return true; + } + + if (allowedOrigins.length > 0) { + return allowedOrigins.includes(origin); + } + + try { + return new URL(origin).host === request.headers.host; + } catch { + return false; + } +} + +function clientIp(request) { + const forwardedFor = request.headers["x-forwarded-for"]; + if (trustProxy && typeof forwardedFor === "string" && forwardedFor.length > 0) { + return forwardedFor.split(",")[0].trim(); + } + + return request.socket.remoteAddress || "unknown"; +} + +function addClient(socket, ip) { const id = String(nextClientId); nextClientId += 1; - const client = { id, socket, roomId: "", player: "" }; - clients.set(id, client); + const client = { + id, + ip, + socket, + roomId: "", + player: "", + buffer: Buffer.alloc(0), + messageTimestamps: [], + lastActionAt: 0, + }; - socket.on("data", (buffer) => handleSocketData(client, buffer)); + clients.set(id, client); + clientsByIp.set(ip, (clientsByIp.get(ip) || 0) + 1); + + socket.setNoDelay(true); + socket.setTimeout(idleSocketTimeoutMs, () => closeClient(client, 1001, "Idle timeout")); + socket.on("data", (buffer) => { + try { + handleSocketData(client, buffer); + } catch { + closeClient(client, 1002, "Protocol error"); + } + }); socket.on("close", () => removeClient(client)); socket.on("error", () => removeClient(client)); @@ -110,28 +244,61 @@ function pairClients(xClient, oClient) { oClient.roomId = roomId; oClient.player = "O"; - rooms.set(roomId, { id: roomId, players: [xClient.id, oClient.id] }); + rooms.set(roomId, { + id: roomId, + players: [xClient.id, oClient.id], + board: createBoard(), + captures: { X: 0, O: 0 }, + currentPlayer: "X", + gameOver: false, + }); send(xClient, { type: "paired", player: "X", roomId }); send(oClient, { type: "paired", player: "O", roomId }); } function handleSocketData(client, buffer) { - const opcode = buffer[0] & 0x0f; - if (opcode === 0x8) { - client.socket.write(Buffer.from([0x88, 0x00])); - client.socket.end(); - removeClient(client); + if (!clients.has(client.id)) { return; } - if (opcode === 0x9) { - client.socket.write(Buffer.from([0x8a, 0x00])); + if (client.buffer.length + buffer.length > maxMessageBytes) { + closeClient(client, 1009, "Message too large"); return; } - const message = decodeFrame(buffer); - if (!message) { + client.buffer = Buffer.concat([client.buffer, buffer]); + + while (client.buffer.length > 0) { + const frame = readFrame(client.buffer); + if (!frame) { + return; + } + + client.buffer = client.buffer.subarray(frame.consumed); + + if (frame.opcode === 0x8) { + closeClient(client, 1000, "Closing"); + return; + } + + if (frame.opcode === 0x9) { + client.socket.write(Buffer.from([0x8a, 0x00])); + continue; + } + + if (frame.opcode !== 0x1) { + closeClient(client, 1003, "Unsupported frame"); + return; + } + + handleTextMessage(client, frame.payload.toString("utf8")); + } +} + +function handleTextMessage(client, message) { + if (!checkMessageRate(client)) { + closeClient(client, 1008, "Rate limit exceeded"); return; } @@ -143,14 +310,21 @@ function handleSocketData(client, buffer) { return; } - if (!client.roomId || payload.roomId !== client.roomId) { + if (!isObject(payload) || !isSafeAction(client)) { + send(client, { type: "error", message: "Slow down." }); + return; + } + + const room = rooms.get(client.roomId); + if (!room || payload.roomId !== client.roomId) { send(client, { type: "error", message: "You are not currently paired." }); return; } if (payload.type === "move") { - if (payload.player !== client.player) { - send(client, { type: "error", message: "Move rejected for wrong player." }); + const result = applyMove(room, client, payload); + if (!result.ok) { + send(client, { type: "error", message: result.message }); return; } @@ -158,14 +332,68 @@ function handleSocketData(client, buffer) { type: "move", row: payload.row, col: payload.col, - player: payload.player, + player: client.player, }); return; } if (payload.type === "reset") { + room.board = createBoard(); + room.captures = { X: 0, O: 0 }; + room.currentPlayer = "X"; + room.gameOver = false; relayToOpponent(client, { type: "reset" }); + return; } + + send(client, { type: "error", message: "Unsupported message type." }); +} + +function checkMessageRate(client) { + const now = Date.now(); + client.messageTimestamps = client.messageTimestamps.filter((timestamp) => now - timestamp < messageWindowMs); + client.messageTimestamps.push(now); + return client.messageTimestamps.length <= maxMessagesPerWindow; +} + +function isSafeAction(client) { + const now = Date.now(); + if (now - client.lastActionAt < minActionIntervalMs) { + return false; + } + + client.lastActionAt = now; + return true; +} + +function applyMove(room, client, payload) { + if (room.gameOver) { + return { ok: false, message: "Game is already over." }; + } + + if (client.player !== room.currentPlayer || payload.player !== client.player) { + return { ok: false, message: "Not your turn." }; + } + + if (!isBoardCoordinate(payload.row) || !isBoardCoordinate(payload.col)) { + return { ok: false, message: "Move is out of bounds." }; + } + + if (room.board[payload.row][payload.col] !== empty) { + return { ok: false, message: "Cell is already occupied." }; + } + + room.board[payload.row][payload.col] = client.player; + const captured = captureFrom(room.board, payload.row, payload.col, client.player); + room.captures[client.player] += captured.length; + + if (findWin(room.board, client.player).length > 0 || isDraw(room.board)) { + room.gameOver = true; + } else { + room.currentPlayer = opponentOf(client.player); + } + + return { ok: true }; } function removeClient(client) { @@ -174,6 +402,12 @@ function removeClient(client) { } clients.delete(client.id); + const ipCount = (clientsByIp.get(client.ip) || 1) - 1; + if (ipCount <= 0) { + clientsByIp.delete(client.ip); + } else { + clientsByIp.set(client.ip, ipCount); + } if (waitingClientId === client.id) { waitingClientId = ""; @@ -228,39 +462,72 @@ function send(client, payload) { client.socket.write(encodeFrame(JSON.stringify(payload))); } -function decodeFrame(buffer) { +function closeClient(client, code, reason) { + if (client.socket.destroyed) { + removeClient(client); + return; + } + + client.socket.write(encodeCloseFrame(code, reason)); + client.socket.end(); + removeClient(client); +} + +function readFrame(buffer) { + if (buffer.length < 2) { + return null; + } + const firstByte = buffer[0]; - const opcode = firstByte & 0x0f; const secondByte = buffer[1]; const masked = (secondByte & 0x80) === 0x80; let length = secondByte & 0x7f; let offset = 2; + if (!masked) { + throwAndClose(); + } + if (length === 126) { + if (buffer.length < offset + 2) { + return null; + } length = buffer.readUInt16BE(offset); offset += 2; } else if (length === 127) { + if (buffer.length < offset + 8) { + return null; + } length = Number(buffer.readBigUInt64BE(offset)); offset += 8; } - let mask; - if (masked) { - mask = buffer.subarray(offset, offset + 4); - offset += 4; + if (length > maxMessageBytes) { + throwAndClose(); } - const payload = buffer.subarray(offset, offset + length); - if (!masked) { - return payload.toString("utf8"); + const maskEnd = offset + 4; + const payloadEnd = maskEnd + length; + if (buffer.length < payloadEnd) { + return null; } + const mask = buffer.subarray(offset, maskEnd); + const payload = buffer.subarray(maskEnd, payloadEnd); const decoded = Buffer.alloc(payload.length); for (let index = 0; index < payload.length; index += 1) { decoded[index] = payload[index] ^ mask[index % 4]; } - return decoded.toString("utf8"); + return { + consumed: payloadEnd, + opcode: firstByte & 0x0f, + payload: decoded, + }; +} + +function throwAndClose() { + throw new Error("Invalid websocket frame"); } function encodeFrame(message) { @@ -271,21 +538,107 @@ function encodeFrame(message) { return Buffer.concat([Buffer.from([0x81, length]), payload]); } - if (length < 65536) { - const header = Buffer.alloc(4); - header[0] = 0x81; - header[1] = 126; - header.writeUInt16BE(length, 2); - return Buffer.concat([header, payload]); - } - - const header = Buffer.alloc(10); + const header = Buffer.alloc(4); header[0] = 0x81; - header[1] = 127; - header.writeBigUInt64BE(BigInt(length), 2); + header[1] = 126; + header.writeUInt16BE(length, 2); return Buffer.concat([header, payload]); } +function encodeCloseFrame(code, reason) { + const reasonBuffer = Buffer.from(reason.slice(0, 80)); + const payload = Buffer.alloc(2 + reasonBuffer.length); + payload.writeUInt16BE(code, 0); + reasonBuffer.copy(payload, 2); + return Buffer.concat([Buffer.from([0x88, payload.length]), payload]); +} + +function createBoard() { + return Array.from({ length: boardSize }, () => Array(boardSize).fill(empty)); +} + +function captureFrom(board, row, col, player) { + const opponent = opponentOf(player); + const captured = []; + + for (const [rowStep, colStep] of directions) { + const candidates = []; + let nextRow = row + rowStep; + let nextCol = col + colStep; + + while (isInside(nextRow, nextCol) && board[nextRow][nextCol] === opponent) { + candidates.push([nextRow, nextCol]); + nextRow += rowStep; + nextCol += colStep; + } + + if (candidates.length > 0 && isInside(nextRow, nextCol) && board[nextRow][nextCol] === player) { + captured.push(...candidates); + } + } + + for (const [captureRow, captureCol] of captured) { + board[captureRow][captureCol] = empty; + } + + return captured; +} + +function findWin(board, player) { + for (let row = 0; row < boardSize; row += 1) { + for (let col = 0; col < boardSize; col += 1) { + if (board[row][col] !== player) { + continue; + } + + for (const [rowStep, colStep] of winAxes) { + const cells = [[row, col]]; + + for (let index = 1; index < winTarget; index += 1) { + const nextRow = row + rowStep * index; + const nextCol = col + colStep * index; + + if (!isInside(nextRow, nextCol) || board[nextRow][nextCol] !== player) { + break; + } + + cells.push([nextRow, nextCol]); + } + + if (cells.length === winTarget) { + return cells; + } + } + } + } + + return []; +} + +function isDraw(board) { + return board.every((row) => row.every((cell) => cell !== empty)); +} + +function opponentOf(player) { + return player === "X" ? "O" : "X"; +} + +function isInside(row, col) { + return row >= 0 && row < boardSize && col >= 0 && col < boardSize; +} + +function isBoardCoordinate(value) { + return Number.isInteger(value) && value >= 0 && value < boardSize; +} + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +server.on("clientError", (_error, socket) => { + socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); +}); + server.listen(appPort, () => { console.log(`Tictactics multiplayer server running at http://localhost:${appPort}`); });