diff --git a/README.md b/README.md index 6adc15e..64d660e 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,29 @@ Danach läuft das komplette Spiel inkl. Online-Multiplayer unter **http://localh | --- | --- | --- | | `PORT` | `3001` | HTTP- und WebSocket-Port | | `DIST_DIR` | `./dist` | Ordner mit den statischen Dateien | +| `ALLOWED_ORIGINS` | – | Zusätzlich erlaubte WebSocket-Origins (Komma-Liste, z. B. `https://spiel.example.com`) | + +**Öffentliches Hosting hinter Reverse Proxy (Nginx Proxy Manager / NPMplus):** + +1. Container **nur im internen Netz** erreichbar machen (Port 3001 **nicht** am Router freigeben!), z. B.: + ```yaml + # docker-compose.yml — gemeinsames Netzwerk mit dem Proxy + services: + trxtd: + build: . + restart: unless-stopped + expose: ["3001"] # intern, kein ports:-Mapping nach außen + # networks: proxy_net ... + ``` +2. In NPM/NPMplus einen **Proxy Host** anlegen: + - **Domain:** `spiel.example.com` (DNS-A-Record auf den Server) + - **Scheme:** `http` · **Forward Hostname:** Containername (gemeinsames Docker-Netz) oder Server-LAN-IP · **Port:** `3001` + - **Websockets Support: ✔ aktivieren** (zwingend erforderlich für Multiplayer!) + - Block Common Exploits: ✔ · Cache Assets: ✘ (der Server setzt eigene Cache-Header) + - **SSL-Tab:** Let's-Encrypt-Zertifikat anfordern, *Force SSL* + *HTTP/2* aktivieren +3. Fertig – das Spiel (inkl. Coop/1v1 über `wss://`) läuft unter `https://spiel.example.com`. + +**Serverseitige Härtung (aktiv):** CSP-/Frame-/Referrer-Header · Origin-Check gegen Cross-Site-WebSocket-Hijacking · `X-Forwarded-For`-Auswertung nur aus privaten Proxy-Netzen · max. 500 WebSocket-Verbindungen total / 20 pro IP · max. 300 Räume · Payload-Limit 4 KB · Rate-Limit 30 Aktionen/s · Schema-Validierung · Ping/Pong-Heartbeat · Raum-Timeout 30 min. **Manuell ohne Docker im Produktionsmodus starten:** ```bash diff --git a/scripts/security-test.mjs b/scripts/security-test.mjs new file mode 100644 index 0000000..dc606d2 --- /dev/null +++ b/scripts/security-test.mjs @@ -0,0 +1,64 @@ +import { request } from 'node:http' + +const PORT = 3101 + +function wsUpgrade(origin) { + return new Promise((resolve) => { + const req = request({ + host: 'localhost', + port: PORT, + path: '/', + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': '13', + ...(origin ? { Origin: origin } : {}), + }, + }) + req.on('response', (res) => resolve({ status: res.statusCode })) + req.on('error', () => resolve({ status: 'destroyed' })) + req.end() + }) +} + +const { WebSocket } = await import('ws') + +function wsOpen(origin, sendCreate) { + return new Promise((resolve) => { + const ws = new WebSocket(`ws://localhost:${PORT}`, origin ? { origin } : undefined) + const done = (ok, info) => { + try { ws.close() } catch {} + resolve({ ok, info }) + } + ws.on('open', () => { + if (sendCreate) ws.send(JSON.stringify({ t: 'create', mode: 'coop', name: 'Test' })) + else done(true, 'connected') + }) + ws.on('message', (d) => { + const m = JSON.parse(d.toString()) + if (m.t === 'room') done(true, 'room ' + m.code) + if (m.t === 'error') done(false, m.msg) + }) + ws.on('error', () => done(false, 'error')) + setTimeout(() => resolve({ ok: false, info: 'timeout' }), 3000) + }) +} + +// 1) böser Origin → 403 +const evil = await wsUpgrade('https://boese-seite.example.com') +console.log('1) Origin-Check böser Origin :', evil.status === 403 ? 'OK (403)' : `FEHLER (${evil.status})`) + +// 2) same-origin → erlaubt +const same = await wsOpen('http://localhost:' + PORT, false) +console.log('2) Same-Origin :', same.ok ? 'OK' : 'FEHLER ' + same.info) + +// 3) dev-origin (localhost:5173) → erlaubt +const dev = await wsOpen('http://localhost:5173', false) +console.log('3) Dev-Origin (:5173) :', dev.ok ? 'OK' : 'FEHLER ' + dev.info) + +// 4) ohne Origin + Raum erstellen +const plain = await wsOpen(undefined, true) +console.log('4) Non-Browser + Raum :', plain.ok ? 'OK (' + plain.info + ')' : 'FEHLER ' + plain.info) + +process.exit(0) diff --git a/server/server.mjs b/server/server.mjs index f9a3bbf..d4d76b6 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -25,6 +25,68 @@ const PORT = Number(process.env.PORT || 3001) const MAX_PAYLOAD_BYTES = 4096 const MAX_ACTIONS_PER_SEC = 30 const ROOM_TIMEOUT_MS = 30 * 60 * 1000 +// public hosting hardening +const MAX_WS_TOTAL = 500 // total concurrent websocket connections +const MAX_WS_PER_IP = 20 // concurrent websocket connections per client IP +const MAX_ROOMS = 300 // concurrent rooms + +const CSP = + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; " + + "base-uri 'self'; frame-ancestors 'none'" + +// ------------------------------------------------------------------ client ip (proxy aware) + +function isPrivateIp(ip) { + if (!ip) return false + if (ip === '::1' || ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80')) return true + const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) + if (!m) return false + const a = Number(m[1]) + const b = Number(m[2]) + return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) +} + +/** + * Real client IP. X-Forwarded-For is only trusted when the direct peer is a + * private address (reverse proxy / docker network), so external clients cannot + * spoof it. Do not expose the port directly to the internet. + */ +function clientIp(req) { + const socketIp = String(req.socket?.remoteAddress || 'unknown').replace(/^::ffff:/, '') + const xff = req.headers['x-forwarded-for'] + if (xff && isPrivateIp(socketIp)) { + const first = String(xff).split(',')[0].trim().replace(/^::ffff:/, '') + if (first) return first + } + return socketIp +} + +/** + * WebSocket origin check: blocks cross-site websocket hijacking (other + * websites opening connections to this server from a victim's browser). + * Allowed: same origin, local development, entries in ALLOWED_ORIGINS. + */ +function originAllowed(req) { + const origin = req.headers.origin + if (!origin) return true // non-browser clients (curl, node, bots without origin) + const host = String(req.headers.host || '').toLowerCase() + let originHost = '' + try { + originHost = new URL(origin).host.toLowerCase() + } catch { + return false + } + if (host && originHost === host) return true + if (/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(originHost)) return true + const extra = String(process.env.ALLOWED_ORIGINS || '') + .toLowerCase() + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + if (extra.some((o) => o === originHost || o === origin.toLowerCase())) return true + return false +} // ------------------------------------------------------------------ static files @@ -101,6 +163,9 @@ async function serveStatic(req, res) { 'Content-Type': type, 'Cache-Control': isHashedAsset ? 'public, max-age=31536000, immutable' : 'no-cache', 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': CSP, + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'no-referrer', }) res.end(req.method === 'HEAD' ? undefined : data) } @@ -119,8 +184,37 @@ const wss = new WebSocketServer({ maxPayload: MAX_PAYLOAD_BYTES, }) +// concurrent websocket connection tracking (per IP + global) +const wsPerIp = new Map() + +function rejectUpgrade(socket, status, reason) { + try { + socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`) + } catch { + /* ignore */ + } + socket.destroy() +} + httpServer.on('upgrade', (req, socket, head) => { + if (!originAllowed(req)) { + console.warn(`[ws] Origin abgelehnt: ${req.headers.origin} von ${clientIp(req)}`) + return rejectUpgrade(socket, 403, 'Forbidden') + } + const ip = clientIp(req) + const perIp = wsPerIp.get(ip) || 0 + if (wss.clients.size >= MAX_WS_TOTAL || perIp >= MAX_WS_PER_IP) { + console.warn(`[ws] Verbindungslimit erreicht: ${ip} (${perIp}/${MAX_WS_PER_IP}, total ${wss.clients.size}/${MAX_WS_TOTAL})`) + return rejectUpgrade(socket, 503, 'Service Unavailable') + } wss.handleUpgrade(req, socket, head, (ws) => { + ws._ip = ip + wsPerIp.set(ip, perIp + 1) + ws.on('close', () => { + const n = (wsPerIp.get(ip) || 1) - 1 + if (n <= 0) wsPerIp.delete(ip) + else wsPerIp.set(ip, n) + }) wss.emit('connection', ws, req) }) }) @@ -286,6 +380,9 @@ wss.on('connection', (ws) => { case 'create': { leaveRoom(ws) if (m.mode !== 'coop' && m.mode !== 'duel') return + if (rooms.size >= MAX_ROOMS) { + return send(ws, { t: 'error', msg: 'Zu viele aktive Räume. Bitte später erneut versuchen.' }) + } const code = makeCode() const room = { code, mode: m.mode, created: Date.now(), seq: 0, players: [{ ws, id: 0, name: sanitizeName(m.name) }] } rooms.set(code, room)