Backend security hardening for a small public server
Audit-driven; goal: one user can't exhaust the box or starve the shared Traefik stack. Resource containment: - compose: mem_limit 512m, memswap_limit, pids_limit 200, cpus 1.0 (blast radius = container) - Enforce per-user cloud quota (default 30MB, admin override) on /api/save + /api/characters → 413 - Cap published-character size (2MB); cap rooms (200), images/room (40), image bytes/room (40MB) - Cap WS image dataUrl length in the wire schema; per-IP WS connection cap (20) - MAX_USERS registration ceiling → 503 when full Availability + correctness: - Async crypto.scrypt (was scryptSync) so password hashing can't freeze the event loop; constant-time on unknown users to avoid username probing - trustProxy so req.ip is the real client behind Traefik — rate limits are per-IP, not global - Tighter auth-endpoint bucket (10/min) on /register + /login; prune stale rate buckets - O(1) token->user index; log persist() failures instead of swallowing them - Trim /healthz info; surface quota in /api/usage + the admin dashboard storage bar - Client surfaces 413 (quota) / 429 (rate) clearly Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+30
-2
@@ -34,6 +34,8 @@ interface Room {
|
||||
players: Set<Sender>;
|
||||
snapshot: Snapshot | null;
|
||||
images: Map<string, string>;
|
||||
/** running total of bytes in `images`, kept so the cap is O(1) to check */
|
||||
imageBytes: number;
|
||||
/** granted seats keyed by playerId */
|
||||
seats: Map<string, Seat>;
|
||||
/** seat requests awaiting GM approval (re-sent when the GM reconnects) */
|
||||
@@ -43,6 +45,13 @@ interface Room {
|
||||
|
||||
const JOIN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no ambiguous chars
|
||||
const ROOM_TTL_MS = 6 * 60 * 60 * 1000; // 6h idle
|
||||
// Memory guardrails so an anonymous GM can't balloon the (in-RAM) hub on a small box.
|
||||
export interface RoomLimits { maxRooms: number; maxImagesPerRoom: number; maxRoomImageBytes: number }
|
||||
const DEFAULT_LIMITS: RoomLimits = {
|
||||
maxRooms: Number(process.env.MAX_ROOMS) || 200,
|
||||
maxImagesPerRoom: 40,
|
||||
maxRoomImageBytes: 40 * 1024 * 1024, // 40 MB of map images per room
|
||||
};
|
||||
|
||||
function sha256(s: string): string {
|
||||
return crypto.createHash('sha256').update(s).digest('hex');
|
||||
@@ -62,7 +71,10 @@ export class RoomHub {
|
||||
private rooms = new Map<string, Room>();
|
||||
private byCode = new Map<string, string>();
|
||||
private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player'; playerId: string; name?: string }>();
|
||||
constructor(private now: () => number = () => Date.now()) {}
|
||||
private limits: RoomLimits;
|
||||
constructor(private now: () => number = () => Date.now(), limits: Partial<RoomLimits> = {}) {
|
||||
this.limits = { ...DEFAULT_LIMITS, ...limits };
|
||||
}
|
||||
|
||||
private mintJoinCode(): string {
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
@@ -90,13 +102,19 @@ export class RoomHub {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cap concurrent rooms so socket-spam can't exhaust memory. Idle rooms TTL out;
|
||||
// a sweep runs first to reclaim any that just expired before we refuse.
|
||||
if (this.rooms.size >= this.limits.maxRooms) {
|
||||
this.sweep();
|
||||
if (this.rooms.size >= this.limits.maxRooms) { socket.send({ t: 'error', code: 'busy', message: 'The server is at capacity — please try again shortly.' }); return; }
|
||||
}
|
||||
const roomId = crypto.randomUUID();
|
||||
const joinCode = this.mintJoinCode();
|
||||
const gmSecret = crypto.randomBytes(32).toString('base64url');
|
||||
const room: Room = {
|
||||
roomId, joinCode, gmSecretHash: sha256(gmSecret),
|
||||
passwordHash: password ? sha256(password) : null,
|
||||
gm: socket, players: new Set(), snapshot: null, images: new Map(),
|
||||
gm: socket, players: new Set(), snapshot: null, images: new Map(), imageBytes: 0,
|
||||
seats: new Map(), pendingSeatRequests: [], lastActivity: this.now(),
|
||||
};
|
||||
this.rooms.set(roomId, room);
|
||||
@@ -148,7 +166,17 @@ export class RoomHub {
|
||||
image(socket: Sender, gmSecret: string, id: string, dataUrl: string): void {
|
||||
const room = this.gmRoom(socket, gmSecret);
|
||||
if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; }
|
||||
// Bound per-room image memory: cap the count of distinct images and the total bytes.
|
||||
const size = Buffer.byteLength(dataUrl);
|
||||
const prev = room.images.get(id);
|
||||
const prevSize = prev ? Buffer.byteLength(prev) : 0;
|
||||
const isNew = prev === undefined;
|
||||
if ((isNew && room.images.size >= this.limits.maxImagesPerRoom) || room.imageBytes - prevSize + size > this.limits.maxRoomImageBytes) {
|
||||
socket.send({ t: 'error', code: 'image-limit', message: 'This session has reached its map-image limit.' });
|
||||
return;
|
||||
}
|
||||
room.images.set(id, dataUrl);
|
||||
room.imageBytes += size - prevSize;
|
||||
room.lastActivity = this.now();
|
||||
for (const p of room.players) p.send({ t: 'mapImage', id, dataUrl });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user