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
+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 }),