diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 275abb0..cebddb1 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -3,4 +3,10 @@ # (wizwar..sslip.io) for zero DNS setup. {$WIZWAR_HOST} +header { + Strict-Transport-Security "max-age=31536000" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "no-referrer" +} reverse_proxy localhost:8787 diff --git a/deploy/setup-droplet.sh b/deploy/setup-droplet.sh index 2ebbe3c..a18a8a0 100755 --- a/deploy/setup-droplet.sh +++ b/deploy/setup-droplet.sh @@ -24,8 +24,15 @@ id -u wizwar &>/dev/null || useradd -r -m -d /opt/wizwar-home wizwar mkdir -p /opt/wizwar /var/lib/wizwar/rooms chown -R wizwar:wizwar /opt/wizwar /var/lib/wizwar -# Caddy vhost -printf '%s\n\nreverse_proxy localhost:8787\n' "$HOST" > /etc/caddy/Caddyfile +# Caddy vhost (security headers included; see deploy/Caddyfile for the template) +printf '%s\n\nheader {\n\tStrict-Transport-Security "max-age=31536000"\n\tX-Content-Type-Options "nosniff"\n\tX-Frame-Options "DENY"\n\tReferrer-Policy "no-referrer"\n}\nreverse_proxy localhost:8787\n' "$HOST" > /etc/caddy/Caddyfile systemctl reload caddy +# Firewall: ssh + web only. The game server binds loopback and is reached +# through Caddy; nothing else should answer the internet. +ufw allow OpenSSH +ufw allow 80/tcp +ufw allow 443/tcp +ufw --force enable + echo "droplet ready — now run deploy.sh from your machine" diff --git a/deploy/wizwar.service b/deploy/wizwar.service index e6d2153..47c4792 100644 --- a/deploy/wizwar.service +++ b/deploy/wizwar.service @@ -7,11 +7,27 @@ Type=simple User=wizwar WorkingDirectory=/opt/wizwar/packages/server Environment=PORT=8787 +# Caddy terminates TLS; the plaintext port must not face the internet. +Environment=HOST=127.0.0.1 Environment=WIZWAR_DATA_DIR=/var/lib/wizwar/rooms Environment=WIZWAR_STATIC_DIR=/opt/wizwar/packages/web/dist -ExecStart=/usr/bin/npx tsx src/index.ts +ExecStart=/opt/wizwar/node_modules/.bin/tsx src/index.ts Restart=always RestartSec=3 +# Sandbox: the process reads /opt/wizwar and writes only its data dir. +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/var/lib/wizwar +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +# A runaway process gets killed and restarted before it can take the box down. +MemoryMax=700M +LimitNOFILE=4096 + [Install] WantedBy=multi-user.target diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1af2a91..a147cce 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -26,14 +26,34 @@ import { loadPersistedRooms, makeTransferCode, redactFor, + roomCount, runCommand, + seatTokenValid, startGame, summarize, viewForPlayer, type Room, } from "./rooms"; +// --- Abuse limits: this is a public server on a small box. ----------------- +const MAX_SOCKETS = 300; // concurrent connections +const MAX_ROOMS = 5000; // total rooms on the server +const MAX_ROOMS_PER_CONN = 10; // rooms one connection may create +const MAX_COMMAND_BYTES = 16384; // serialized game command +const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request +const CATCHUP_COOLDOWN_MS = 3000; // full-game replays are CPU-heavy +const NAME_MAX = 24; + +/** Player/room names: printable, trimmed, bounded. */ +function cleanName(raw: unknown): string { + // eslint-disable-next-line no-control-regex + return String(raw ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, NAME_MAX); +} + const port = Number(process.env.PORT ?? 8787); +// In production a TLS proxy fronts us: bind loopback so the plaintext port +// is not reachable from the internet. Dev default stays LAN-friendly. +const host = process.env.HOST ?? "0.0.0.0"; loadPersistedRooms(); // One process serves both the built client and the websocket, so production @@ -49,6 +69,11 @@ const MIME: Record = { const staticRoot = existsSync(STATIC_DIR) ? realpathSync(normalize(STATIC_DIR)) : null; const httpServer = createServer((req, res) => { try { + if (req.method !== "GET" && req.method !== "HEAD") { + res.writeHead(405).end(); + return; + } + res.setHeader("x-content-type-options", "nosniff"); if (!staticRoot) { res.writeHead(404).end("client not built"); return; @@ -83,8 +108,8 @@ const httpServer = createServer((req, res) => { res.writeHead(500).end(); } }); -const wss = new WebSocketServer({ server: httpServer, path: undefined }); -httpServer.listen(port); +const wss = new WebSocketServer({ server: httpServer, path: undefined, maxPayload: 64 * 1024 }); +httpServer.listen(port, host); interface Session { socket: WebSocket; @@ -93,6 +118,21 @@ interface Session { /** The raw seat token this connection authenticated with (memory only). */ token: string | null; claimFails: number; + // Token bucket: refills at 15 msg/s up to a burst of 30. + bucket: number; + lastRefill: number; + overLimitStrikes: number; + roomsCreated: number; + lastCatchUpAt: number; +} + +function underRateLimit(s: Session): boolean { + const now = Date.now(); + s.bucket = Math.min(30, s.bucket + ((now - s.lastRefill) / 1000) * 15); + s.lastRefill = now; + if (s.bucket < 1) return false; + s.bucket -= 1; + return true; } const sessions = new Set(); @@ -128,13 +168,26 @@ function broadcastRoomState(room: Room): void { } wss.on("connection", (socket) => { - const session: Session = { socket, playerId: null, roomId: null, token: null, claimFails: 0 }; + if (sessions.size >= MAX_SOCKETS) { + send(socket, { type: "error", message: "the tavern is packed — try again shortly" }); + socket.close(); + return; + } + const session: Session = { + socket, playerId: null, roomId: null, token: null, claimFails: 0, + bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0, + roomsCreated: 0, lastCatchUpAt: 0, + }; sessions.add(session); send(socket, { type: "welcome", game: "wizwar" }); socket.on("close", () => sessions.delete(session)); socket.on("message", (data) => { + if (!underRateLimit(session)) { + if (++session.overLimitStrikes > 100) socket.close(); + return send(socket, { type: "error", message: "slow down" }); + } let msg: Record; try { msg = JSON.parse(data.toString()); @@ -145,8 +198,12 @@ wss.on("connection", (socket) => { try { switch (msg.type) { case "create": { - const name = String(msg.name ?? "").trim(); + const name = cleanName(msg.name); if (!name) return send(socket, { type: "error", message: "name required" }); + if (session.roomsCreated >= MAX_ROOMS_PER_CONN || roomCount() >= MAX_ROOMS) { + return send(socket, { type: "error", message: "no new rooms right now — try again later" }); + } + session.roomsCreated++; const { room, token } = createRoom(name); session.playerId = name; session.roomId = room.id; @@ -156,8 +213,8 @@ wss.on("connection", (socket) => { break; } case "join": { - const name = String(msg.name ?? "").trim(); - const roomId = String(msg.roomId ?? "").trim(); + const name = cleanName(msg.name); + const roomId = String(msg.roomId ?? "").trim().slice(0, 8); if (!name || !roomId) return send(socket, { type: "error", message: "name and roomId required" }); const room = getRoom(roomId); if (!room) return send(socket, { type: "error", message: "no such room" }); @@ -187,6 +244,9 @@ wss.on("connection", (socket) => { case "command": { const room = session.roomId ? getRoom(session.roomId) : undefined; if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); + if (JSON.stringify(msg.command ?? null).length > MAX_COMMAND_BYTES) { + return send(socket, { type: "error", message: "command too large" }); + } const result = runCommand(room, session.playerId, msg.command as Command); if ("error" in result) return send(socket, { type: "error", message: result.error }); broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) })); @@ -217,6 +277,12 @@ wss.on("connection", (socket) => { case "catchUp": { const room = session.roomId ? getRoom(session.roomId) : undefined; if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); + // A catch-up replays the whole game server-side; one at a time, please. + const now = Date.now(); + if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) { + return send(socket, { type: "error", message: "catching up already — one moment" }); + } + session.lastCatchUpAt = now; const steps = catchUpSteps(room, session.playerId, Number(msg.sinceSeq ?? 0)); if ("error" in steps) return send(socket, { type: "error", message: steps.error }); send(socket, { type: "catchUp", steps }); @@ -232,13 +298,14 @@ wss.on("connection", (socket) => { } case "myGames": { // {seats: [{roomId, name, token}]} -> summaries for valid seats. - const seats = Array.isArray(msg.seats) ? msg.seats : []; + const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : []; const games = []; for (const seat of seats) { + if (typeof seat !== "object" || seat === null) continue; const room = getRoom(String(seat.roomId ?? "")); if (!room) continue; const name = String(seat.name ?? ""); - if (room.tokens.get(name) !== seat.token) continue; + if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) continue; games.push(summarize(room, name)); } send(socket, { type: "games", games }); @@ -248,7 +315,10 @@ wss.on("connection", (socket) => { send(socket, { type: "error", message: `unknown message type: ${String(msg.type)}` }); } } catch (e) { - send(socket, { type: "error", message: e instanceof Error ? e.message : "internal error" }); + // Expected failures travel as result.error values; anything thrown is a + // bug, and its message (paths, internals) is not for strangers' eyes. + console.error("unhandled protocol error:", e); + send(socket, { type: "error", message: "internal error" }); } }); }); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 109278e..1138d7d 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -48,6 +48,11 @@ function hashToken(raw: string): string { return createHash("sha256").update(raw).digest("hex"); } +/** Public seat check for protocol handlers (timing-safe under the hood). */ +export function seatTokenValid(room: Room, playerId: PlayerId, raw: string | null): boolean { + return tokenMatches(room, playerId, raw); +} + function tokenMatches(room: Room, playerId: PlayerId, raw: string | null): boolean { if (!raw) return false; const stored = room.tokens.get(playerId); @@ -65,6 +70,10 @@ function makeRoomCode(): string { return rooms.has(code) ? makeRoomCode() : code; } +export function roomCount(): number { + return rooms.size; +} + export function createRoom(hostId: PlayerId): { room: Room; token: string } { const token = randomBytes(16).toString("hex"); const room: Room = { @@ -312,6 +321,7 @@ export function makeTransferCode( for (const [code, t] of transfers) { if (t.expiresAt < now) transfers.delete(code); } + if (transfers.size >= 200) return { error: "too many transfers pending — try again in a few minutes" }; let code: string; do { code = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomInt(TRANSFER_WORDS.length)]).join("-");