Harden websocket server against abuse

This commit is contained in:
2026-05-08 13:13:13 -04:00
parent 88688c8f84
commit c26a833eda
5 changed files with 451 additions and 57 deletions

View File

@@ -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

View File

@@ -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"]

View File

@@ -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`.

View File

@@ -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}"

453
server.js
View File

@@ -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) {
if (client.buffer.length + buffer.length > maxMessageBytes) {
closeClient(client, 1009, "Message too large");
return;
}
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;
}
const message = decodeFrame(buffer);
if (!message) {
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]);
}
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;
}
const header = Buffer.alloc(10);
header[0] = 0x81;
header[1] = 127;
header.writeBigUInt64BE(BigInt(length), 2);
return Buffer.concat([header, payload]);
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}`);
});