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:
2026-06-10 20:37:14 +02:00
parent 8fd530df73
commit 0892b8f19c
12 changed files with 244 additions and 41 deletions
+58 -15
View File
@@ -6,15 +6,19 @@ import type { FastifyRequest } from 'fastify';
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
import { RoomHub, type Sender } from './rooms';
import { AccountStore } from './accounts';
import { CloudStore } from './campaigns';
import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
const PORT = Number(process.env.PORT ?? 8787);
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
const DATA_DIR = path.resolve(process.env.DATA_DIR ?? path.join(process.cwd(), 'data'));
const MAX_PAYLOAD = 12 * 1024 * 1024; // 12 MB (map images over WS)
const BODY_LIMIT = 48 * 1024 * 1024; // 48 MB (cloud backup blobs)
const BODY_LIMIT = 32 * 1024 * 1024; // 32 MB (cloud backup blobs; > the per-user quota)
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const ADMIN_USERS = (process.env.ADMIN_USERS ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
const MAX_USERS = Number(process.env.MAX_USERS) || Infinity;
// Concurrent WebSocket connections allowed from one client IP — generous for a
// household sharing a NAT, but a hard stop on socket-spam room/image floods.
const MAX_WS_PER_IP = Number(process.env.MAX_WS_PER_IP) || 20;
const bearer = (req: FastifyRequest): string | undefined => {
const h = req.headers.authorization;
@@ -29,30 +33,49 @@ const RATE = { capacity: 80, refillMs: 10_000 };
const HEARTBEAT_MS = 30_000;
export function buildServer() {
const app = Fastify({ bodyLimit: BODY_LIMIT });
// trustProxy: behind Traefik the socket peer is the proxy, so without this every
// visitor shares one rate-limit bucket. Trust the proxy's X-Forwarded-For so
// req.ip is the real client. Only Traefik can reach the container, so this is safe.
const app = Fastify({ bodyLimit: BODY_LIMIT, trustProxy: true });
const hub = new RoomHub();
const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS);
const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS, MAX_USERS);
void accounts.load();
const cloud = new CloudStore(DATA_DIR);
void cloud.load();
const sweeper = setInterval(() => hub.sweep(), 5 * 60 * 1000);
const sweeper = setInterval(() => { hub.sweep(); pruneRateBuckets(); }, 5 * 60 * 1000);
app.addHook('onClose', async () => clearInterval(sweeper));
// Simple per-IP throttle for the account/cloud API.
// Per-IP throttle for the account/cloud API. Auth endpoints get a tighter bucket
// since each call does password hashing and is the target of credential-stuffing.
const ipHits = new Map<string, { n: number; reset: number }>();
const authHits = new Map<string, { n: number; reset: number }>();
const AUTH_PATHS = new Set(['/api/register', '/api/login']);
const bump = (map: Map<string, { n: number; reset: number }>, ip: string, limit: number, windowMs: number): boolean => {
const now = Date.now();
const e = map.get(ip);
if (!e || now > e.reset) { map.set(ip, { n: 1, reset: now + windowMs }); return true; }
return ++e.n <= limit;
};
const pruneRateBuckets = () => {
const now = Date.now();
for (const [ip, e] of ipHits) if (now > e.reset) ipHits.delete(ip);
for (const [ip, e] of authHits) if (now > e.reset) authHits.delete(ip);
};
app.addHook('onRequest', async (req, reply) => {
if (!req.url.startsWith('/api/')) return;
const now = Date.now();
const e = ipHits.get(req.ip);
if (!e || now > e.reset) ipHits.set(req.ip, { n: 1, reset: now + 60_000 });
else if (++e.n > 60) await reply.code(429).send({ error: 'rate' });
if (!bump(ipHits, req.ip, 60, 60_000)) return reply.code(429).send({ error: 'rate' });
// Strip the querystring before matching the auth path.
const path0 = req.url.split('?')[0]!;
if (AUTH_PATHS.has(path0) && !bump(authHits, req.ip, 10, 60_000)) {
return reply.code(429).send({ error: 'rate', message: 'Too many attempts — wait a minute and try again.' });
}
});
// ---- accounts + cloud backup sync ----
app.post('/api/register', async (req, reply) => {
const { username, password } = (req.body ?? {}) as { username?: string; password?: string };
const r = await accounts.register(String(username ?? ''), String(password ?? ''));
if (!r.ok) return reply.code(400).send({ error: r.code, message: r.message });
if (!r.ok) return reply.code(r.code === 'closed' ? 503 : 400).send({ error: r.code, message: r.message });
return { token: r.token, username: r.username };
});
app.post('/api/login', async (req, reply) => {
@@ -67,6 +90,11 @@ export function buildServer() {
if (!u) return reply.code(401).send({ error: 'unauthorized' });
const body = (req.body as { blob?: string; baseSavedAt?: number | null } | undefined) ?? {};
if (typeof body.blob !== 'string') return reply.code(400).send({ error: 'bad-blob' });
// Quota: the backup blob plus the user's published characters must fit their limit.
const projected = Buffer.byteLength(body.blob) + (await cloud.usageBytes(u.id));
if (projected > accounts.quotaFor(u)) {
return reply.code(413).send({ error: 'quota', message: 'Cloud storage limit reached. Trim old data or ask the admin to raise your quota.', limit: accounts.quotaFor(u) });
}
// Optimistic concurrency: if the caller names the version it last synced and
// the cloud has since moved on (another device pushed), refuse — no silent
// overwrite. The client then resolves (pull, or force-push).
@@ -121,8 +149,15 @@ export function buildServer() {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const { campaignId, character } = (req.body ?? {}) as { campaignId?: string; character?: { id?: string; name?: string; data?: string } };
if (!campaignId || !character?.id || typeof character.data !== 'string') return reply.code(400).send({ error: 'bad-request' });
if (Buffer.byteLength(character.data) > MAX_CHARACTER_BYTES) return reply.code(413).send({ error: 'too-large', message: 'That character is too large to publish.' });
// Quota: total usage after this upsert (replace the old copy's bytes with the new).
const delta = Buffer.byteLength(character.data) - (cloud.ownsCharacter(u.id, character.id) ? await cloud.characterBytes(character.id) : 0);
const projected = (await accounts.blobBytes(u.id)) + (await cloud.usageBytes(u.id)) + delta;
if (projected > accounts.quotaFor(u)) {
return reply.code(413).send({ error: 'quota', message: 'Cloud storage limit reached.', limit: accounts.quotaFor(u) });
}
const r = await cloud.putCharacter(u.id, campaignId, { id: character.id, name: String(character.name ?? ''), data: character.data });
if (!r) return reply.code(403).send({ error: 'forbidden', message: 'Not a member, or you do not own that character.' });
if (!r) return reply.code(403).send({ error: 'forbidden', message: 'Not a member, you do not own that character, or it is too large.' });
return { ok: true, updatedAt: r.updatedAt };
});
app.get('/api/campaigns/:id/characters', async (req, reply) => {
@@ -141,7 +176,7 @@ export function buildServer() {
app.get('/api/usage', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const bytes = (await accounts.blobBytes(u.id)) + (await cloud.usageBytes(u.id));
return { bytes, admin: accounts.isAdmin(u.username) };
return { bytes, quota: accounts.quotaFor(u), admin: accounts.isAdmin(u.username) };
});
// ---- owner admin: usage overview + per-user quota override (tracked, not yet enforced) ----
@@ -166,6 +201,9 @@ export function buildServer() {
void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } });
// Live WebSocket connections per client IP — a hard cap on socket-spam.
const wsPerIp = new Map<string, number>();
void app.register(async (instance) => {
instance.get('/ws', { websocket: true }, (socket, req) => {
// Anti-CSWSH: reject cross-origin upgrades when an allowlist is configured.
@@ -174,6 +212,11 @@ export function buildServer() {
socket.close(1008, 'origin');
return;
}
const ip = req.ip;
const live = wsPerIp.get(ip) ?? 0;
if (live >= MAX_WS_PER_IP) { socket.close(1013, 'too-many'); return; }
wsPerIp.set(ip, live + 1);
const releaseIp = () => { const n = (wsPerIp.get(ip) ?? 1) - 1; if (n <= 0) wsPerIp.delete(ip); else wsPerIp.set(ip, n); };
const sender: Sender = { send: (msg: ServerMessage) => { try { socket.send(JSON.stringify(msg)); } catch { /* closed */ } } };
let tokens = RATE.capacity;
const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs);
@@ -209,11 +252,11 @@ export function buildServer() {
case 'setName': hub.setName(sender, m.name); break;
}
});
socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); hub.disconnect(sender); });
socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); releaseIp(); hub.disconnect(sender); });
});
});
app.get('/healthz', async () => ({ ok: true, rooms: hub.roomCount() }));
app.get('/healthz', async () => ({ ok: true }));
// Serve the built SPA with history fallback (so /play?room=... deep-links work).
void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });