Security hardening for public playtesting

The server code learns to distrust strangers: a 64KB WebSocket payload
cap (the ws default is 100MB — an easy OOM on a 1GB droplet), a
per-connection token-bucket rate limit, caps on concurrent sockets,
total rooms, rooms per connection, pending transfer codes, and seats
per myGames query. Player names are stripped of control characters
and bounded at 24 chars, room codes at 8, serialized commands at
16KB before they touch the append-only log. Catch-up replays — a
full game rebuild per request — get a 3-second cooldown. Unexpected
exceptions now log server-side and send strangers a bare "internal
error" instead of the exception text.

One real bug found by the sweep: myGames compared the client's raw
seat token against the stored hash, so the lobby ledger silently
matched nothing since tokens were hashed at rest — and the comparison
wasn't timing-safe either. It now goes through the same timingSafeEqual
path as every other seat check, via a new exported seatTokenValid.

The droplet tightens too: the game server binds loopback (HOST env)
so port 8787 no longer answers the internet — it was reachable
directly, plaintext, bypassing Caddy — and ufw now allows only ssh,
80, and 443. The systemd unit gains a sandbox (ProtectSystem=strict,
ProtectHome, NoNewPrivileges, PrivateTmp, MemoryMax=700M so a runaway
process is killed and restarted before it takes the box down) and
execs tsx directly instead of through npx. Caddy adds HSTS, nosniff,
frame-denial, and no-referrer headers; setup-droplet.sh records all
of it for future rebuilds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 10:08:23 -04:00
co-authored by Claude Fable 5
parent 789d927122
commit f0a264147f
5 changed files with 121 additions and 12 deletions
+6
View File
@@ -3,4 +3,10 @@
# (wizwar.<droplet-ip>.sslip.io) for zero DNS setup. # (wizwar.<droplet-ip>.sslip.io) for zero DNS setup.
{$WIZWAR_HOST} {$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 reverse_proxy localhost:8787
+9 -2
View File
@@ -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 mkdir -p /opt/wizwar /var/lib/wizwar/rooms
chown -R wizwar:wizwar /opt/wizwar /var/lib/wizwar chown -R wizwar:wizwar /opt/wizwar /var/lib/wizwar
# Caddy vhost # Caddy vhost (security headers included; see deploy/Caddyfile for the template)
printf '%s\n\nreverse_proxy localhost:8787\n' "$HOST" > /etc/caddy/Caddyfile 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 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" echo "droplet ready — now run deploy.sh from your machine"
+17 -1
View File
@@ -7,11 +7,27 @@ Type=simple
User=wizwar User=wizwar
WorkingDirectory=/opt/wizwar/packages/server WorkingDirectory=/opt/wizwar/packages/server
Environment=PORT=8787 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_DATA_DIR=/var/lib/wizwar/rooms
Environment=WIZWAR_STATIC_DIR=/opt/wizwar/packages/web/dist 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 Restart=always
RestartSec=3 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] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+79 -9
View File
@@ -26,14 +26,34 @@ import {
loadPersistedRooms, loadPersistedRooms,
makeTransferCode, makeTransferCode,
redactFor, redactFor,
roomCount,
runCommand, runCommand,
seatTokenValid,
startGame, startGame,
summarize, summarize,
viewForPlayer, viewForPlayer,
type Room, type Room,
} from "./rooms"; } 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); 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(); loadPersistedRooms();
// One process serves both the built client and the websocket, so production // One process serves both the built client and the websocket, so production
@@ -49,6 +69,11 @@ const MIME: Record<string, string> = {
const staticRoot = existsSync(STATIC_DIR) ? realpathSync(normalize(STATIC_DIR)) : null; const staticRoot = existsSync(STATIC_DIR) ? realpathSync(normalize(STATIC_DIR)) : null;
const httpServer = createServer((req, res) => { const httpServer = createServer((req, res) => {
try { try {
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(405).end();
return;
}
res.setHeader("x-content-type-options", "nosniff");
if (!staticRoot) { if (!staticRoot) {
res.writeHead(404).end("client not built"); res.writeHead(404).end("client not built");
return; return;
@@ -83,8 +108,8 @@ const httpServer = createServer((req, res) => {
res.writeHead(500).end(); res.writeHead(500).end();
} }
}); });
const wss = new WebSocketServer({ server: httpServer, path: undefined }); const wss = new WebSocketServer({ server: httpServer, path: undefined, maxPayload: 64 * 1024 });
httpServer.listen(port); httpServer.listen(port, host);
interface Session { interface Session {
socket: WebSocket; socket: WebSocket;
@@ -93,6 +118,21 @@ interface Session {
/** The raw seat token this connection authenticated with (memory only). */ /** The raw seat token this connection authenticated with (memory only). */
token: string | null; token: string | null;
claimFails: number; 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<Session>(); const sessions = new Set<Session>();
@@ -128,13 +168,26 @@ function broadcastRoomState(room: Room): void {
} }
wss.on("connection", (socket) => { 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); sessions.add(session);
send(socket, { type: "welcome", game: "wizwar" }); send(socket, { type: "welcome", game: "wizwar" });
socket.on("close", () => sessions.delete(session)); socket.on("close", () => sessions.delete(session));
socket.on("message", (data) => { socket.on("message", (data) => {
if (!underRateLimit(session)) {
if (++session.overLimitStrikes > 100) socket.close();
return send(socket, { type: "error", message: "slow down" });
}
let msg: Record<string, unknown>; let msg: Record<string, unknown>;
try { try {
msg = JSON.parse(data.toString()); msg = JSON.parse(data.toString());
@@ -145,8 +198,12 @@ wss.on("connection", (socket) => {
try { try {
switch (msg.type) { switch (msg.type) {
case "create": { case "create": {
const name = String(msg.name ?? "").trim(); const name = cleanName(msg.name);
if (!name) return send(socket, { type: "error", message: "name required" }); 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); const { room, token } = createRoom(name);
session.playerId = name; session.playerId = name;
session.roomId = room.id; session.roomId = room.id;
@@ -156,8 +213,8 @@ wss.on("connection", (socket) => {
break; break;
} }
case "join": { case "join": {
const name = String(msg.name ?? "").trim(); const name = cleanName(msg.name);
const roomId = String(msg.roomId ?? "").trim(); const roomId = String(msg.roomId ?? "").trim().slice(0, 8);
if (!name || !roomId) return send(socket, { type: "error", message: "name and roomId required" }); if (!name || !roomId) return send(socket, { type: "error", message: "name and roomId required" });
const room = getRoom(roomId); const room = getRoom(roomId);
if (!room) return send(socket, { type: "error", message: "no such room" }); if (!room) return send(socket, { type: "error", message: "no such room" });
@@ -187,6 +244,9 @@ wss.on("connection", (socket) => {
case "command": { case "command": {
const room = session.roomId ? getRoom(session.roomId) : undefined; const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); 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); const result = runCommand(room, session.playerId, msg.command as Command);
if ("error" in result) return send(socket, { type: "error", message: result.error }); if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) })); broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
@@ -217,6 +277,12 @@ wss.on("connection", (socket) => {
case "catchUp": { case "catchUp": {
const room = session.roomId ? getRoom(session.roomId) : undefined; const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); 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)); const steps = catchUpSteps(room, session.playerId, Number(msg.sinceSeq ?? 0));
if ("error" in steps) return send(socket, { type: "error", message: steps.error }); if ("error" in steps) return send(socket, { type: "error", message: steps.error });
send(socket, { type: "catchUp", steps }); send(socket, { type: "catchUp", steps });
@@ -232,13 +298,14 @@ wss.on("connection", (socket) => {
} }
case "myGames": { case "myGames": {
// {seats: [{roomId, name, token}]} -> summaries for valid seats. // {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 = []; const games = [];
for (const seat of seats) { for (const seat of seats) {
if (typeof seat !== "object" || seat === null) continue;
const room = getRoom(String(seat.roomId ?? "")); const room = getRoom(String(seat.roomId ?? ""));
if (!room) continue; if (!room) continue;
const name = String(seat.name ?? ""); 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)); games.push(summarize(room, name));
} }
send(socket, { type: "games", games }); send(socket, { type: "games", games });
@@ -248,7 +315,10 @@ wss.on("connection", (socket) => {
send(socket, { type: "error", message: `unknown message type: ${String(msg.type)}` }); send(socket, { type: "error", message: `unknown message type: ${String(msg.type)}` });
} }
} catch (e) { } 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" });
} }
}); });
}); });
+10
View File
@@ -48,6 +48,11 @@ function hashToken(raw: string): string {
return createHash("sha256").update(raw).digest("hex"); 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 { function tokenMatches(room: Room, playerId: PlayerId, raw: string | null): boolean {
if (!raw) return false; if (!raw) return false;
const stored = room.tokens.get(playerId); const stored = room.tokens.get(playerId);
@@ -65,6 +70,10 @@ function makeRoomCode(): string {
return rooms.has(code) ? makeRoomCode() : code; return rooms.has(code) ? makeRoomCode() : code;
} }
export function roomCount(): number {
return rooms.size;
}
export function createRoom(hostId: PlayerId): { room: Room; token: string } { export function createRoom(hostId: PlayerId): { room: Room; token: string } {
const token = randomBytes(16).toString("hex"); const token = randomBytes(16).toString("hex");
const room: Room = { const room: Room = {
@@ -312,6 +321,7 @@ export function makeTransferCode(
for (const [code, t] of transfers) { for (const [code, t] of transfers) {
if (t.expiresAt < now) transfers.delete(code); 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; let code: string;
do { do {
code = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomInt(TRANSFER_WORDS.length)]).join("-"); code = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomInt(TRANSFER_WORDS.length)]).join("-");