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:
+58
-15
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user