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
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue