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

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