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:
@@ -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 & storage</h3>
|
||||
<h3 className="mb-1 smallcaps">Admin · users & 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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user