diff --git a/server.ts b/server.ts index 2b4a58a..965a2a4 100644 --- a/server.ts +++ b/server.ts @@ -1,4 +1,5 @@ import type { ServerWebSocket } from "bun"; +import { timingSafeEqual } from "node:crypto"; import indexHtml from "./index.html"; import historyHtml from "./history.html"; import { getRounds, getAllRounds } from "./db.ts"; @@ -50,9 +51,96 @@ const gameState: GameState = { isPaused: false, }; +// ── Guardrails ────────────────────────────────────────────────────────────── + +type WsData = { ip: string }; + +const WINDOW_MS = 60_000; +const WS_UPGRADE_LIMIT_PER_MIN = parsePositiveInt(process.env.WS_UPGRADE_LIMIT_PER_MIN, 20); +const HISTORY_LIMIT_PER_MIN = parsePositiveInt(process.env.HISTORY_LIMIT_PER_MIN, 120); +const ADMIN_LIMIT_PER_MIN = parsePositiveInt(process.env.ADMIN_LIMIT_PER_MIN, 10); +const MAX_WS_GLOBAL = parsePositiveInt(process.env.MAX_WS_GLOBAL, 2_000); +const MAX_WS_PER_IP = parsePositiveInt(process.env.MAX_WS_PER_IP, 8); +const MAX_HISTORY_PAGE = parsePositiveInt(process.env.MAX_HISTORY_PAGE, 100_000); +const MAX_HISTORY_LIMIT = parsePositiveInt(process.env.MAX_HISTORY_LIMIT, 50); +const HISTORY_CACHE_TTL_MS = parsePositiveInt(process.env.HISTORY_CACHE_TTL_MS, 5_000); +const MAX_HISTORY_CACHE_KEYS = parsePositiveInt(process.env.MAX_HISTORY_CACHE_KEYS, 500); + +const requestWindows = new Map(); +const wsByIp = new Map(); +const historyCache = new Map(); +let lastRateWindowSweep = 0; +let lastHistoryCacheSweep = 0; + +function parsePositiveInt(value: string | undefined, fallback: number): number { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function getClientIp(req: Request, server: Bun.Server): string { + return server.requestIP(req)?.address ?? "unknown"; +} + +function isRateLimited(key: string, limit: number, windowMs: number): boolean { + const now = Date.now(); + if (now - lastRateWindowSweep >= windowMs) { + for (const [bucketKey, timestamps] of requestWindows) { + const recent = timestamps.filter((timestamp) => now - timestamp <= windowMs); + if (recent.length === 0) { + requestWindows.delete(bucketKey); + } else { + requestWindows.set(bucketKey, recent); + } + } + lastRateWindowSweep = now; + } + + const existing = requestWindows.get(key) ?? []; + const recent = existing.filter((timestamp) => now - timestamp <= windowMs); + if (recent.length >= limit) { + requestWindows.set(key, recent); + return true; + } + recent.push(now); + requestWindows.set(key, recent); + return false; +} + +function secureCompare(a: string, b: string): boolean { + const aBuf = Buffer.from(a); + const bBuf = Buffer.from(b); + if (aBuf.length !== bBuf.length) return false; + return timingSafeEqual(aBuf, bBuf); +} + +function isAdminAuthorized(req: Request, url: URL): boolean { + const expected = process.env.ADMIN_SECRET; + if (!expected) return false; + const provided = req.headers.get("x-admin-secret") ?? url.searchParams.get("secret") ?? ""; + if (!provided) return false; + return secureCompare(provided, expected); +} + +function decrementIpConnection(ip: string) { + const current = wsByIp.get(ip) ?? 0; + if (current <= 1) { + wsByIp.delete(ip); + return; + } + wsByIp.set(ip, current - 1); +} + +function setHistoryCache(key: string, body: string, expiresAt: number) { + if (historyCache.size >= MAX_HISTORY_CACHE_KEYS) { + const firstKey = historyCache.keys().next().value; + if (firstKey) historyCache.delete(firstKey); + } + historyCache.set(key, { body, expiresAt }); +} + // ── WebSocket clients ─────────────────────────────────────────────────────── -const clients = new Set>(); +const clients = new Set>(); function broadcast() { const msg = JSON.stringify({ @@ -70,7 +158,7 @@ function broadcast() { const port = parseInt(process.env.PORT ?? "5109", 10); // 5109 = SLOP -const server = Bun.serve({ +const server = Bun.serve({ port, routes: { "/": indexHtml, @@ -78,47 +166,118 @@ const server = Bun.serve({ }, fetch(req, server) { const url = new URL(req.url); + const ip = getClientIp(req, server); + if (url.pathname.startsWith("/assets/")) { const path = `./public${url.pathname}`; const file = Bun.file(path); - return new Response(file); - } - if (url.pathname === "/api/pause") { - const secret = url.searchParams.get("secret"); - if (process.env.ADMIN_SECRET && secret === process.env.ADMIN_SECRET) { - gameState.isPaused = true; - broadcast(); - return new Response("Paused", { status: 200 }); - } - return new Response("Unauthorized", { status: 401 }); - } - if (url.pathname === "/api/resume") { - const secret = url.searchParams.get("secret"); - if (process.env.ADMIN_SECRET && secret === process.env.ADMIN_SECRET) { - gameState.isPaused = false; - broadcast(); - return new Response("Resumed", { status: 200 }); - } - return new Response("Unauthorized", { status: 401 }); - } - if (url.pathname === "/api/history") { - const page = parseInt(url.searchParams.get("page") || "1", 10); - return new Response(JSON.stringify(getRounds(page)), { - headers: { "Content-Type": "application/json" } + return new Response(file, { + headers: { + "Cache-Control": "public, max-age=604800, immutable", + "X-Content-Type-Options": "nosniff", + }, }); } + + if (url.pathname === "/healthz") { + return new Response("ok", { status: 200 }); + } + + if (url.pathname === "/api/pause" || url.pathname === "/api/resume") { + if (req.method !== "POST") { + return new Response("Method Not Allowed", { + status: 405, + headers: { Allow: "POST" }, + }); + } + if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) { + return new Response("Too Many Requests", { status: 429 }); + } + if (!isAdminAuthorized(req, url)) { + return new Response("Unauthorized", { status: 401 }); + } + + if (url.pathname === "/api/pause") { + gameState.isPaused = true; + } else { + gameState.isPaused = false; + } + broadcast(); + return new Response(url.pathname === "/api/pause" ? "Paused" : "Resumed", { + status: 200, + }); + } + + if (url.pathname === "/api/history") { + if (isRateLimited(`history:${ip}`, HISTORY_LIMIT_PER_MIN, WINDOW_MS)) { + return new Response("Too Many Requests", { status: 429 }); + } + const rawPage = parseInt(url.searchParams.get("page") || "1", 10); + const rawLimit = parseInt(url.searchParams.get("limit") || "10", 10); + const page = Number.isFinite(rawPage) ? Math.min(Math.max(rawPage, 1), MAX_HISTORY_PAGE) : 1; + const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), MAX_HISTORY_LIMIT) : 10; + const cacheKey = `${page}:${limit}`; + const now = Date.now(); + if (now - lastHistoryCacheSweep >= HISTORY_CACHE_TTL_MS) { + for (const [key, value] of historyCache) { + if (value.expiresAt <= now) historyCache.delete(key); + } + lastHistoryCacheSweep = now; + } + const cached = historyCache.get(cacheKey); + if (cached && cached.expiresAt > now) { + return new Response(cached.body, { + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=5, stale-while-revalidate=30", + "X-Content-Type-Options": "nosniff", + }, + }); + } + + const body = JSON.stringify(getRounds(page, limit)); + setHistoryCache(cacheKey, body, now + HISTORY_CACHE_TTL_MS); + return new Response(body, { + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=5, stale-while-revalidate=30", + "X-Content-Type-Options": "nosniff", + }, + }); + } + if (url.pathname === "/ws") { - const upgraded = server.upgrade(req); + if (req.method !== "GET") { + return new Response("Method Not Allowed", { + status: 405, + headers: { Allow: "GET" }, + }); + } + if (isRateLimited(`ws-upgrade:${ip}`, WS_UPGRADE_LIMIT_PER_MIN, WINDOW_MS)) { + return new Response("Too Many Requests", { status: 429 }); + } + if (clients.size >= MAX_WS_GLOBAL) { + return new Response("Service Unavailable", { status: 503 }); + } + const existingForIp = wsByIp.get(ip) ?? 0; + if (existingForIp >= MAX_WS_PER_IP) { + return new Response("Too Many Requests", { status: 429 }); + } + + const upgraded = server.upgrade(req, { data: { ip } }); if (!upgraded) { return new Response("WebSocket upgrade failed", { status: 400 }); } return undefined; } + return new Response("Not found", { status: 404 }); }, websocket: { + data: {} as WsData, open(ws) { clients.add(ws); + wsByIp.set(ws.data.ip, (wsByIp.get(ws.data.ip) ?? 0) + 1); broadcast(); }, message(_ws, _message) { @@ -126,6 +285,7 @@ const server = Bun.serve({ }, close(ws) { clients.delete(ws); + decrementIpConnection(ws.data.ip); broadcast(); }, }, @@ -133,6 +293,13 @@ const server = Bun.serve({ hmr: true, console: true, }, + error(error) { + log("ERROR", "server", "Unhandled fetch/websocket error", { + message: error.message, + stack: error.stack, + }); + return new Response("Internal Server Error", { status: 500 }); + }, }); console.log(`\n🎮 Qwipslop Web — http://localhost:${server.port}`);