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:
parent
b506ffad55
commit
4ec0e483aa
3 changed files with 184 additions and 0 deletions
23
README.md
23
README.md
|
|
@ -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
64
scripts/security-test.mjs
Normal 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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue