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
+10
View File
@@ -17,10 +17,20 @@ services:
- ALLOWED_ORIGINS=https://ttrpg.briggen.dev
# comma-separated usernames that can see the admin panel (set to your account)
- ADMIN_USERS=nilsb
# default per-user cloud storage cap (bytes); admins override per user in the dashboard
- DEFAULT_QUOTA_BYTES=31457280 # 30 MB
# hard ceiling on accounts so open registration can't fill the disk
- MAX_USERS=500
volumes:
- ttrpg-data:/data
security_opt:
- no-new-privileges:true
# Hard caps so a runaway session can't starve the host or the shared Traefik
# stack — the blast radius stays this container, then `restart: unless-stopped`.
mem_limit: 512m
memswap_limit: 512m
pids_limit: 200
cpus: 1.0
labels:
- "traefik.enable=true"
- "traefik.http.routers.ttrpg.rule=Host(`ttrpg.briggen.dev`)"
+18
View File
@@ -54,4 +54,22 @@ describe('AccountStore', () => {
expect(await adminStore.setQuota('ghost', 1)).toBe(false);
expect((await adminStore.listUsers()).find((u) => u.username === 'peon')?.quotaBytes).toBe(5_000_000_000);
});
it('quotaFor uses the override when set, else the default', async () => {
const r = await store.register('quotaUser', 'password123');
if (!r.ok) throw new Error('register failed');
const u = (await store.userByToken(r.token))!;
const def = store.quotaFor(u); // instance default
expect(def).toBeGreaterThan(0);
await store.setQuota('quotaUser', 99);
expect(store.quotaFor((await store.userByToken(r.token))!)).toBe(99);
});
it('refuses registration past the max-users cap', async () => {
const capped = new AccountStore(dir, undefined, [], 1);
expect((await capped.register('first', 'password123')).ok).toBe(true);
const second = await capped.register('second', 'password123');
expect(second.ok).toBe(false);
expect((second as { code: string }).code).toBe('closed');
});
});
+46 -14
View File
@@ -1,7 +1,12 @@
import crypto from 'node:crypto';
import { promisify } from 'node:util';
import { promises as fs } from 'node:fs';
import path from 'node:path';
// Async scrypt so password hashing runs on the threadpool, NOT the single event
// loop — a burst of logins can't freeze the whole server (scryptSync did).
const scryptAsync = promisify(crypto.scrypt) as (pw: crypto.BinaryLike, salt: crypto.BinaryLike, keylen: number) => Promise<Buffer>;
/**
* Lean file-backed accounts + cloud-backup store. No external DB — a single
* users.json plus one blob file per user under DATA_DIR. Passwords are scrypt-
@@ -23,9 +28,11 @@ interface User {
const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/;
const MAX_TOKENS = 10;
/** Default per-user cloud storage cap when no admin override is set. */
export const DEFAULT_QUOTA_BYTES = Number(process.env.DEFAULT_QUOTA_BYTES) || 30 * 1024 * 1024;
function sha256(s: string): string { return crypto.createHash('sha256').update(s).digest('hex'); }
function hashPassword(password: string, salt: string): string { return crypto.scryptSync(password, salt, 64).toString('hex'); }
async function hashPassword(password: string, salt: string): Promise<string> { return (await scryptAsync(password, salt, 64)).toString('hex'); }
function timingEqual(a: string, b: string): boolean {
const ab = Buffer.from(a), bb = Buffer.from(b);
return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
@@ -37,15 +44,22 @@ export interface AuthError { ok: false; code: string; message: string }
export class AccountStore {
private users = new Map<string, User>(); // key: lowercased username
private byId = new Map<string, User>();
private byToken = new Map<string, User>(); // sha256(token) → user, for O(1) auth lookups
private queue: Promise<unknown> = Promise.resolve();
private loaded = false;
constructor(private dir: string, private now: () => number = () => Date.now(), private admins: string[] = []) {}
constructor(private dir: string, private now: () => number = () => Date.now(), private admins: string[] = [], private maxUsers = Infinity) {}
private usersFile() { return path.join(this.dir, 'users.json'); }
private blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); }
isAdmin(username: string): boolean { return this.admins.includes(username.toLowerCase()); }
/** This user's storage limit: their admin override, else the instance default. */
quotaFor(u: User): number { return u.quotaBytes && u.quotaBytes > 0 ? u.quotaBytes : DEFAULT_QUOTA_BYTES; }
/** Whether a new account can be created (registration is open under the cap). */
atCapacity(): boolean { return this.users.size >= this.maxUsers; }
async listUsers(): Promise<Array<{ id: string; username: string; quotaBytes?: number }>> {
await this.load();
return [...this.users.values()].map((u) => ({ id: u.id, username: u.username, ...(u.quotaBytes ? { quotaBytes: u.quotaBytes } : {}) }));
@@ -72,8 +86,15 @@ export class AccountStore {
try {
const raw = await fs.readFile(this.usersFile(), 'utf8');
const arr = JSON.parse(raw) as User[];
for (const u of arr) { this.users.set(u.username.toLowerCase(), u); this.byId.set(u.id, u); }
} catch { /* no file yet */ }
for (const u of arr) {
this.users.set(u.username.toLowerCase(), u);
this.byId.set(u.id, u);
for (const h of u.tokens) this.byToken.set(h, u);
}
} catch (e) {
// ENOENT on first boot is expected; anything else is a real problem worth seeing.
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[accounts] load failed', e);
}
}
private persist(): Promise<void> {
@@ -82,14 +103,19 @@ export class AccountStore {
const tmp = `${this.usersFile()}.tmp`;
await fs.writeFile(tmp, JSON.stringify([...this.users.values()]));
await fs.rename(tmp, this.usersFile());
}).catch(() => {});
}).catch((e) => { console.error('[accounts] persist failed — data may be lost on restart', e); });
return this.queue as Promise<void>;
}
private issueToken(u: User): string {
const token = crypto.randomBytes(32).toString('base64url');
u.tokens.push(sha256(token));
if (u.tokens.length > MAX_TOKENS) u.tokens = u.tokens.slice(-MAX_TOKENS);
const h = sha256(token);
u.tokens.push(h);
this.byToken.set(h, u);
if (u.tokens.length > MAX_TOKENS) {
for (const stale of u.tokens.slice(0, u.tokens.length - MAX_TOKENS)) this.byToken.delete(stale);
u.tokens = u.tokens.slice(-MAX_TOKENS);
}
u.updatedAt = this.now();
return token;
}
@@ -99,8 +125,9 @@ export class AccountStore {
if (!USERNAME_RE.test(username)) return { ok: false, code: 'bad-username', message: 'Username must be 332 letters, digits, . _ or -.' };
if (typeof password !== 'string' || password.length < 8) return { ok: false, code: 'weak-password', message: 'Password must be at least 8 characters.' };
if (this.users.has(username.toLowerCase())) return { ok: false, code: 'taken', message: 'That username is taken.' };
if (this.atCapacity()) return { ok: false, code: 'closed', message: 'Registration is temporarily closed (instance at capacity).' };
const salt = crypto.randomBytes(16).toString('hex');
const u: User = { id: crypto.randomUUID(), username, salt, hash: hashPassword(password, salt), tokens: [], createdAt: this.now(), updatedAt: this.now() };
const u: User = { id: crypto.randomUUID(), username, salt, hash: await hashPassword(password, salt), tokens: [], createdAt: this.now(), updatedAt: this.now() };
const token = this.issueToken(u);
this.users.set(username.toLowerCase(), u);
this.byId.set(u.id, u);
@@ -111,7 +138,11 @@ export class AccountStore {
async login(username: string, password: string): Promise<AuthResult | AuthError> {
await this.load();
const u = this.users.get((username ?? '').toLowerCase());
if (!u || !timingEqual(u.hash, hashPassword(password ?? '', u.salt))) return { ok: false, code: 'bad-credentials', message: 'Wrong username or password.' };
// Hash even when the user is unknown (against a throwaway salt) so the response
// time doesn't reveal whether a username exists.
const salt = u?.salt ?? 'absent';
const candidate = await hashPassword(password ?? '', salt);
if (!u || !timingEqual(u.hash, candidate)) return { ok: false, code: 'bad-credentials', message: 'Wrong username or password.' };
const token = this.issueToken(u);
await this.persist();
return { ok: true, token, username: u.username };
@@ -120,15 +151,16 @@ export class AccountStore {
async userByToken(token: string | undefined): Promise<User | null> {
if (!token) return null;
await this.load();
const h = sha256(token);
for (const u of this.users.values()) if (u.tokens.includes(h)) return u;
return null;
return this.byToken.get(sha256(token)) ?? null;
}
async logout(token: string): Promise<void> {
const u = await this.userByToken(token);
await this.load();
const h = sha256(token);
const u = this.byToken.get(h);
if (!u) return;
u.tokens = u.tokens.filter((t) => t !== sha256(token));
u.tokens = u.tokens.filter((t) => t !== h);
this.byToken.delete(h);
await this.persist();
}
+14 -1
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { CloudStore } from './campaigns';
import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
describe('CloudStore', () => {
let dir: string;
@@ -42,6 +42,19 @@ describe('CloudStore', () => {
expect(await store.usageBytes('p1')).toBeGreaterThan(0);
});
it('rejects an oversized character and reports usage deltas', async () => {
const c = await store.createCampaign('gm1', 'C', '5e');
await store.joinByInvite('p1', c.inviteCode);
const huge = 'x'.repeat(MAX_CHARACTER_BYTES + 1);
expect(await store.putCharacter('p1', c.id, { id: 'big', name: 'Big', data: huge })).toBeNull();
expect(await store.characterBytes('big')).toBe(0); // nothing stored
await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{"hp":1}' });
expect(store.ownsCharacter('p1', 'ch1')).toBe(true);
expect(store.ownsCharacter('p2', 'ch1')).toBe(false);
expect(await store.characterBytes('ch1')).toBe(Buffer.byteLength('{"hp":1}'));
});
it('lets the char owner or campaign owner delete; persists across instances', async () => {
const c = await store.createCampaign('gm1', 'C', '5e');
await store.joinByInvite('p1', c.inviteCode);
+20 -2
View File
@@ -32,6 +32,9 @@ export interface CloudCharacter {
}
const INVITE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
/** A single published character can't exceed this (a sheet with an embedded portrait
* is well under 2 MB); a hard stop before per-user quota even comes into play. */
export const MAX_CHARACTER_BYTES = 2 * 1024 * 1024;
export class CloudStore {
private campaigns = new Map<string, CloudCampaign>();
@@ -50,7 +53,9 @@ export class CloudStore {
const raw = JSON.parse(await fs.readFile(this.file(), 'utf8')) as { campaigns: CloudCampaign[]; characters: CloudCharacter[] };
for (const c of raw.campaigns ?? []) { this.campaigns.set(c.id, c); this.byInvite.set(c.inviteCode, c.id); }
for (const ch of raw.characters ?? []) this.characters.set(ch.id, ch);
} catch { /* no file yet */ }
} catch (e) {
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[cloud] load failed', e);
}
}
private persist(): Promise<void> {
@@ -59,7 +64,7 @@ export class CloudStore {
const tmp = `${this.file()}.tmp`;
await fs.writeFile(tmp, JSON.stringify({ campaigns: [...this.campaigns.values()], characters: [...this.characters.values()] }));
await fs.rename(tmp, this.file());
}).catch(() => {});
}).catch((e) => { console.error('[cloud] persist failed — data may be lost on restart', e); });
return this.queue as Promise<void>;
}
@@ -140,6 +145,7 @@ export class CloudStore {
async putCharacter(userId: string, campaignId: string, char: { id: string; name: string; data: string }): Promise<CloudCharacter | null> {
await this.load();
if (!this.isMember(campaignId, userId)) return null;
if (Buffer.byteLength(char.data) > MAX_CHARACTER_BYTES) return null; // oversized blob — reject
const existing = this.characters.get(char.id);
if (existing && existing.ownerUserId !== userId) return null; // only the owner may update
const record: CloudCharacter = { id: char.id, campaignId, ownerUserId: existing?.ownerUserId ?? userId, name: char.name.slice(0, 120), data: char.data, updatedAt: this.now() };
@@ -163,6 +169,18 @@ export class CloudStore {
return true;
}
/** Byte size of one stored character's data (0 if it doesn't exist) — for quota deltas. */
async characterBytes(characterId: string): Promise<number> {
await this.load();
const ch = this.characters.get(characterId);
return ch ? Buffer.byteLength(ch.data) : 0;
}
/** Whether the user already owns a published character with this id. */
ownsCharacter(userId: string, characterId: string): boolean {
return this.characters.get(characterId)?.ownerUserId === userId;
}
/** Total bytes a user is responsible for (their characters' data) — for usage tracking. */
async usageBytes(userId: string): Promise<number> {
await this.load();
+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 });
+25
View File
@@ -92,6 +92,31 @@ describe('RoomHub', () => {
expect(hub.roomCount()).toBe(0);
});
it('caps the number of concurrent rooms', () => {
const hub = new RoomHub(() => 0, { maxRooms: 2 });
const a = fake(); hub.host(a);
const b = fake(); hub.host(b);
expect(hub.roomCount()).toBe(2);
const c = fake(); hub.host(c);
expect(hub.roomCount()).toBe(2); // refused
expect(lastOf(c, 'error')).toMatchObject({ code: 'busy' });
expect(lastOf(c, 'hosted')).toBeUndefined();
});
it('caps per-room image count and total bytes', () => {
const hub = new RoomHub(() => 0, { maxImagesPerRoom: 2, maxRoomImageBytes: 100 });
const gm = fake(); hub.host(gm);
const { gmSecret } = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
hub.image(gm, gmSecret, 'm1', 'x'.repeat(40));
hub.image(gm, gmSecret, 'm2', 'x'.repeat(40)); // total 80 ≤ 100, count 2 ≤ 2
expect(lastOf(gm, 'error')).toBeUndefined();
hub.image(gm, gmSecret, 'm3', 'x'.repeat(10)); // 3rd distinct image → count cap
expect(lastOf(gm, 'error')).toMatchObject({ code: 'image-limit' });
// overwriting an existing id is allowed, but not past the byte budget
hub.image(gm, gmSecret, 'm1', 'x'.repeat(90)); // 90 + 40 = 130 > 100 → rejected
expect(lastOf(gm, 'error')).toMatchObject({ code: 'image-limit' });
});
it('routes a seat claim → GM grant → player gets their sheet', () => {
const hub = new RoomHub();
const { gm, player, secret } = hostAndJoin(hub);
+30 -2
View File
@@ -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 });
}
+17 -3
View File
@@ -21,7 +21,7 @@ export function CloudCampaigns() {
const [targetCloud, setTargetCloud] = useState('');
const [viewing, setViewing] = useState<string | null>(null);
const [party, setParty] = useState<CloudCharInfo[]>([]);
const [usage, setUsage] = useState<{ bytes: number; admin: boolean } | null>(null);
const [usage, setUsage] = useState<{ bytes: number; quota: number; admin: boolean } | null>(null);
const [admins, setAdmins] = useState<AdminUserRow[]>([]);
useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]);
@@ -122,11 +122,25 @@ export function CloudCampaigns() {
</div>
</div>
{usage && <p className="text-xs text-muted">Your cloud storage: <span className="text-ink">{fmtBytes(usage.bytes)}</span></p>}
{usage && (() => {
const pct = usage.quota > 0 ? Math.min(100, Math.round((usage.bytes / usage.quota) * 100)) : 0;
const near = pct >= 90;
return (
<div className="text-xs">
<p className={near ? 'text-warning' : 'text-muted'}>
Your cloud storage: <span className="text-ink">{fmtBytes(usage.bytes)}</span> of {fmtBytes(usage.quota)} ({pct}%)
{near && ' — almost full; trim old data or ask the admin to raise your quota.'}
</p>
<div className="mt-1 h-1.5 w-full max-w-xs overflow-hidden rounded-full bg-elevated">
<div className={near ? 'h-full bg-warning' : 'h-full bg-accent'} style={{ width: `${pct}%` }} />
</div>
</div>
);
})()}
{usage?.admin && (
<div className="rounded-md border border-accent/30 bg-accent/5 p-2">
<h3 className="mb-1 smallcaps">Admin · users &amp; storage</h3>
<h3 className="mb-1 smallcaps">Admin · users &amp; storage <span className="font-normal text-muted">(you are signed in as the admin)</span></h3>
<table className="w-full text-xs">
<thead><tr className="text-left text-muted"><th className="py-1 font-medium">User</th><th className="font-medium">Used</th><th className="font-medium">Quota (GB, 0=default)</th></tr></thead>
<tbody>
+2 -2
View File
@@ -53,8 +53,8 @@ export async function cloudUsageBytes(): Promise<number> {
}
export interface AdminUserRow { username: string; usageBytes: number; quotaBytes: number }
export function getUsage(): Promise<{ bytes: number; admin: boolean }> {
return req<{ bytes: number; admin: boolean }>('/usage');
export function getUsage(): Promise<{ bytes: number; quota: number; admin: boolean }> {
return req<{ bytes: number; quota: number; admin: boolean }>('/usage');
}
export function adminListUsers(): Promise<{ users: AdminUserRow[] }> {
return req<{ users: AdminUserRow[] }>('/admin/users');
+2
View File
@@ -60,6 +60,8 @@ export async function pushBackup(opts?: { force?: boolean }): Promise<PushResult
const res = await fetch(`${base()}/save`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${t}` }, body: JSON.stringify({ blob, baseSavedAt }) });
if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); }
if (res.status === 409) { const d = (await res.json().catch(() => ({}))) as { savedAt?: number }; return { ok: false, conflict: true, savedAt: Number(d.savedAt) || 0 }; }
if (res.status === 413) throw new CloudError('Cloud storage limit reached — trim old data or ask the admin to raise your quota.');
if (res.status === 429) throw new CloudError('Too many requests — please wait a moment and try again.');
if (!res.ok) throw new CloudError('Upload failed.');
const d = (await res.json().catch(() => ({}))) as { savedAt?: number };
if (typeof d.savedAt === 'number') setLastSyncedAt(d.savedAt);
+2 -2
View File
@@ -130,8 +130,8 @@ export const clientMessageSchema = z.discriminatedUnion('t', [
z.object({ t: z.literal('host'), campaignId: z.string(), password: z.string().max(128).optional(), resume: z.string().optional() }),
z.object({ t: z.literal('join'), joinCode: z.string().max(16), password: z.string().max(128).optional(), playerId: z.string().max(64).optional() }),
z.object({ t: z.literal('state'), gmSecret: z.string(), snapshot: snapshotSchema }),
z.object({ t: z.literal('image'), gmSecret: z.string(), id: z.string(), dataUrl: z.string() }),
z.object({ t: z.literal('requestImage'), id: z.string() }),
z.object({ t: z.literal('image'), gmSecret: z.string(), id: z.string().max(128), dataUrl: z.string().max(10_000_000) }),
z.object({ t: z.literal('requestImage'), id: z.string().max(128) }),
// two-way play (player → server)
z.object({ t: z.literal('claimSeat'), characterId: z.string(), offlineSnapshot: characterSchema.optional() }),
z.object({ t: z.literal('playerPatch'), characterId: z.string(), diff: partialCharacterDiffSchema }),