feat(server): add security hardening for public hosting

- Add CSP, frame and referrer headers for served static files
- Enforce WebSocket origin check to prevent cross-site hijacking
- Trust X-Forwarded-For only when the peer is from a private proxy network
- Limit concurrent connections (500 total / 20 per IP) and rooms (300)
- Add ALLOWED_ORIGINS env var for additional WebSocket origins
- Document reverse proxy setup (NPM/NPMplus) in README
- Add scripts/security-test.mjs to verify origin and limit behavior
This commit is contained in:
Tronax 2026-08-16 13:16:20 +02:00
parent b506ffad55
commit 4ec0e483aa
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
3 changed files with 184 additions and 0 deletions

View file

@ -55,6 +55,29 @@ Danach läuft das komplette Spiel inkl. Online-Multiplayer unter **http://localh
| --- | --- | --- | | --- | --- | --- |
| `PORT` | `3001` | HTTP- und WebSocket-Port | | `PORT` | `3001` | HTTP- und WebSocket-Port |
| `DIST_DIR` | `./dist` | Ordner mit den statischen Dateien | | `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:** **Manuell ohne Docker im Produktionsmodus starten:**
```bash ```bash

64
scripts/security-test.mjs Normal file
View file

@ -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)

View file

@ -25,6 +25,68 @@ const PORT = Number(process.env.PORT || 3001)
const MAX_PAYLOAD_BYTES = 4096 const MAX_PAYLOAD_BYTES = 4096
const MAX_ACTIONS_PER_SEC = 30 const MAX_ACTIONS_PER_SEC = 30
const ROOM_TIMEOUT_MS = 30 * 60 * 1000 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 // ------------------------------------------------------------------ static files
@ -101,6 +163,9 @@ async function serveStatic(req, res) {
'Content-Type': type, 'Content-Type': type,
'Cache-Control': isHashedAsset ? 'public, max-age=31536000, immutable' : 'no-cache', 'Cache-Control': isHashedAsset ? 'public, max-age=31536000, immutable' : 'no-cache',
'X-Content-Type-Options': 'nosniff', 'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': CSP,
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'no-referrer',
}) })
res.end(req.method === 'HEAD' ? undefined : data) res.end(req.method === 'HEAD' ? undefined : data)
} }
@ -119,8 +184,37 @@ const wss = new WebSocketServer({
maxPayload: MAX_PAYLOAD_BYTES, 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) => { 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) => { 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) wss.emit('connection', ws, req)
}) })
}) })
@ -286,6 +380,9 @@ wss.on('connection', (ws) => {
case 'create': { case 'create': {
leaveRoom(ws) leaveRoom(ws)
if (m.mode !== 'coop' && m.mode !== 'duel') return 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 code = makeCode()
const room = { code, mode: m.mode, created: Date.now(), seq: 0, players: [{ ws, id: 0, name: sanitizeName(m.name) }] } const room = { code, mode: m.mode, created: Date.now(), seq: 0, players: [{ ws, id: 0, name: sanitizeName(m.name) }] }
rooms.set(code, room) rooms.set(code, room)