From e18b8b4430617cef34e99c5349f44dec6676ffe8 Mon Sep 17 00:00:00 2001 From: MrEgosar Date: Sun, 1 Nov 2026 20:25:45 +0100 Subject: [PATCH] Absicherung: Socket-Eingaben, Geheimnisse in Fehlern, Logparser, Start/Stopp, RCON, Stream-Zugang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Socket-Nachrichten werden geprüft, Handlerfehler abgefangen, Abfragen pro Verbindung begrenzt (vorher beendete stream:subscribe mit null ohne Anmeldung das Backend). - Fehlermeldungen von Prozessen ohne Befehlszeile und mit geschwärzten Passwörtern/Tokens. - Logparser wertet nur ganze Systemzeilen nach dem Zeitstempel aus; Chat kann keine Map beenden. - Verspätet ankommende Starts nach Stopp, Neuanforderung oder Löschen bauen ihren Container ab. - RCON: kaputte Pakete trennen nur die Verbindung, Befehle ohne Antwort gelten als Fehler. - Organisatoren bekommen einen Stream-Schlüssel nur für ihr Match statt des globalen STREAM_KEY. - Rate-Limiter mit regelmäßiger Bereinigung und Obergrenze, alte Admin-Sitzungen werden entfernt. --- README.md | 2 +- server/src/adminRoutes.js | 2 + server/src/http.js | 8 +- server/src/liveScore.js | 25 +++- server/src/matchService.js | 33 +++- server/src/realtime.js | 58 +++++-- server/src/security.js | 34 ++++- server/src/servers/dockerAdapter.js | 13 +- server/src/servers/knifeServers.js | 12 +- server/src/servers/rcon.js | 21 ++- server/src/servers/rconConfigure.js | 5 +- server/src/servers/serverManager.js | 21 ++- server/test/hardening.test.js | 224 ++++++++++++++++++++++++++++ 13 files changed, 409 insertions(+), 49 deletions(-) create mode 100644 server/test/hardening.test.js diff --git a/README.md b/README.md index af8675f..e285551 100644 --- a/README.md +++ b/README.md @@ -394,7 +394,7 @@ Unter `/stream` (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle lau - **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden. - **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht. - **Einzelnes Match:** Ein Klick auf eine Karte oder einen Eintrag öffnet die Übersicht nur dieses Matches unter `/stream/match/`. Der Link enthält keinen Zuschauercode, der Schlüssel `?key=` wird übernommen. -- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls. +- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls. Der Organisator bekommt im Link „Match-Übersicht“ nicht den globalen Schlüssel, sondern einen eigenen, der nur sein Match zeigt. ### Beamer-Diashow diff --git a/server/src/adminRoutes.js b/server/src/adminRoutes.js index 28b1ad1..8c507c4 100644 --- a/server/src/adminRoutes.js +++ b/server/src/adminRoutes.js @@ -66,6 +66,8 @@ export function createAdminRouter({ service, manager, config, settings, knife, p } const token = randomToken(); const expiresAt = Date.now() + SESSION_HOURS * 3_600_000; + // Abgelaufene Anmeldungen entfernen, damit die Liste nicht wächst + for (const [t, until] of sessions) if (until < Date.now()) sessions.delete(t); sessions.set(token, expiresAt); return res.json({ token, expiresAt }); }); diff --git a/server/src/http.js b/server/src/http.js index 14ef1c0..4d2bcdf 100644 --- a/server/src/http.js +++ b/server/src/http.js @@ -76,10 +76,10 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett // Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key= router.get('/stream/overview', wrap((req) => { - if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) { - throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden'); - } - return service.streamOverview(); + // Globaler STREAM_KEY: alle Matches; Match-Schlüssel (vom Organisator): nur dieses Match + const access = service.streamAccess(typeof req.query.key === 'string' ? req.query.key : ''); + if (!access) throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden'); + return service.streamOverview({ matchId: access.matchId }); })); router.post('/matches', limit('create'), wrap((req) => { diff --git a/server/src/liveScore.js b/server/src/liveScore.js index 890cf45..f81753c 100644 --- a/server/src/liveScore.js +++ b/server/src/liveScore.js @@ -9,16 +9,26 @@ // Game Over: competitive mg_active de_mirage score 13:9 after 35 min // Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min (Wingman: leere Map-Gruppe, zwei Leerzeichen) +// Alle Muster gelten für die ganze Zeile nach dem Zeitstempel. Spielerzeilen (z. B. Chat: +// "Name<2><[U:1:1]>" say "Game Over: …") beginnen mit einem Anführungszeichen und passen nie. const RE = { - matchStart: /World triggered "Match_Start" on "([^"]+)"/, - warmupStart: /World triggered "Warmup_Start"/, - worldRoundEnd: /World triggered "Round_End"/, - teamPlaying: /Team playing "(CT|TERRORIST)": (.+?)\s*$/, - roundEnd: /Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)/, + matchStart: /^World triggered "Match_Start" on "([^"]+)"$/, + warmupStart: /^World triggered "Warmup_Start"$/, + worldRoundEnd: /^World triggered "Round_End"$/, + teamPlaying: /^(?:MatchStatus: )?Team playing "(CT|TERRORIST)": (.+?)\s*$/, + roundEnd: /^Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)$/, // Das Wort direkt vor "score" ist die Map – Modus und Map-Gruppe davor sind beliebig bzw. leer. - gameOver: /Game Over:.*?(\S+)\s+score\s+(\d+):(\d+)/, + gameOver: /^Game Over: [\w ]*?(\S+)\s+score\s+(\d+):(\d+) after \d+ min$/, }; +// Zeitstempel am Zeilenanfang: "10/25/2026 - 18:07:52.396 - " (HTTP-Log) oder "L 10/25/2026 - 18:07:52: " (Datei) +const PREFIX = /^(?:L\s+)?\d{2}\/\d{2}\/\d{4}\s+-\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s*[-:]\s*/; + +/** Zeile ohne Zeitstempel, so wie sie die Muster oben erwarten. */ +export function logPayload(line) { + return String(line).trim().replace(PREFIX, ''); +} + // Gründe für ein Rundenende aus den SFUI-Hinweisen const REASONS = { Target_Bombed: 'bomb', @@ -85,7 +95,8 @@ export function applyLogLines(state, lines, ctx) { Object.assign(s, { status, score: { A: 0, B: 0 }, rounds: [], winner: null, pendingRound: null }); }; - for (const line of lines) { + for (const raw of lines) { + const line = logPayload(raw); let m; if ((m = RE.matchStart.exec(line))) { // Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu. diff --git a/server/src/matchService.js b/server/src/matchService.js index 8d4a3a1..39644cf 100644 --- a/server/src/matchService.js +++ b/server/src/matchService.js @@ -1,5 +1,6 @@ import { EventEmitter } from 'node:events'; import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js'; +import crypto from 'node:crypto'; import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js'; import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js'; import { trimLiveState } from './rounds.js'; @@ -696,6 +697,10 @@ export class MatchService extends EventEmitter { const recent = (this.chatRate.get(who) ?? []).filter((t) => now - t < 10_000); if (recent.length >= 5) throw new HttpError(429, 'Zu viele Nachrichten, kurz warten', 'rate_limited'); this.chatRate.set(who, [...recent, now]); + // Gelegentlich alte Einträge anderer Spieler entfernen + if (this.chatRate.size > 500) { + for (const [k, times] of this.chatRate) if (!times.some((t) => now - t < 10_000)) this.chatRate.delete(k); + } const player = viewer.playerId ? this.db.prepare('SELECT nickname, team FROM players WHERE id = ?').get(viewer.playerId) : null; this.db.prepare(` @@ -823,8 +828,30 @@ export class MatchService extends EventEmitter { * Lobbys und kürzlich beendete Matches knapp. Enthält nur, was Zuschauer sehen dürfen – * keine Codes, keine Verbindungsdaten. */ - streamOverview({ resultsWithinMs = 3 * 3_600_000 } = {}) { - const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all(); + /** Schlüssel für die Stream-Übersicht nur eines Matches, abgeleitet aus STREAM_KEY (ohne ihn preiszugeben). */ + matchStreamKey(matchId) { + const mac = crypto.createHmac('sha256', this.config.streamKey).update(`stream:${matchId}`).digest('base64url').slice(0, 22); + return `${matchId}.${mac}`; + } + + /** + * Prüft einen Stream-Schlüssel: { matchId: null } = alle Matches, { matchId } = nur dieses Match, null = kein Zugang. + * Ohne STREAM_KEY ist die Übersicht offen. + */ + streamAccess(key) { + if (!this.config.streamKey) return { matchId: null }; + const value = String(key ?? ''); + if (safeEqual(value, this.config.streamKey)) return { matchId: null }; + const dot = value.lastIndexOf('.'); + const matchId = dot > 0 ? value.slice(0, dot) : ''; + if (matchId && safeEqual(value, this.matchStreamKey(matchId))) return { matchId }; + return null; + } + + streamOverview({ resultsWithinMs = 3 * 3_600_000, matchId = null } = {}) { + const rows = matchId + ? this.db.prepare('SELECT * FROM matches WHERE id = ?').all(matchId) + : this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all(); const now = this.now(); const active = []; const upcoming = []; @@ -1047,7 +1074,7 @@ export class MatchService extends EventEmitter { // Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode stream: `${base}/stream/${formatCode(match.code_spectator)}`, // Stream-Übersicht nur für dieses Match, ohne Zuschauercode (Schlüssel nur wenn gesetzt) - overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.config.streamKey)}` : ''}`, + overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.matchStreamKey(match.id))}` : ''}`, }, }; } diff --git a/server/src/realtime.js b/server/src/realtime.js index 0ffa103..83f0052 100644 --- a/server/src/realtime.js +++ b/server/src/realtime.js @@ -1,5 +1,8 @@ import { Server } from 'socket.io'; -import { safeEqual } from './security.js'; +// Abfragen pro Verbindung: kurzer Schwall erlaubt, danach 2 pro Sekunde +const SOCKET_BURST = 20; +const SOCKET_REFILL = 2; +const SOCKET_REFILL_MS = 1000; /** * Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die @@ -54,7 +57,13 @@ export function attachRealtime(httpServer, { service, config }) { let streamTimer = null; const broadcastStream = () => { clearTimeout(streamTimer); - streamTimer = setTimeout(() => io.to('stream-overview').emit('stream:overview', service.streamOverview()), 200); + streamTimer = setTimeout(async () => { + io.to('stream-overview').emit('stream:overview', service.streamOverview()); + // Zugänge mit Match-Schlüssel bekommen nur ihr Match + for (const s of await io.in('stream-match').fetchSockets()) { + s.emit('stream:overview', service.streamOverview({ matchId: s.data.streamMatch })); + } + }, 200); }; service.on('changed', (matchId) => { @@ -64,25 +73,47 @@ export function attachRealtime(httpServer, { service, config }) { }); io.on('connection', (socket) => { - socket.on('overview:subscribe', () => { + // Jede Nachricht kommt ungeprüft vom Client: Fehler im Handler dürfen nie das Backend beenden, + // und Abfragen sind pro Verbindung begrenzt (jede baut einen vollständigen Zustand). + let budget = SOCKET_BURST; + const refill = setInterval(() => { budget = Math.min(SOCKET_BURST, budget + SOCKET_REFILL); }, SOCKET_REFILL_MS); + const on = (event, handler) => socket.on(event, (payload) => { + if (budget <= 0) { + socket.emit('rate_limited', { error: 'Zu viele Anfragen – bitte kurz warten' }); + return; + } + budget -= 1; + try { + handler(payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {}); + } catch (err) { + console.error(`[socket] ${event}: ${err.message}`); + } + }); + + on('overview:subscribe', () => { socket.join('overview'); socket.emit('overview', { matches: service.listMatches() }); }); - socket.on('stream:subscribe', ({ key } = {}) => { - if (config.streamKey && !safeEqual(String(key ?? ''), config.streamKey)) { + on('stream:subscribe', ({ key }) => { + const access = service.streamAccess(typeof key === 'string' ? key : ''); + if (!access) { socket.emit('stream:error', { error: 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt' }); return; } - socket.join('stream-overview'); - socket.emit('stream:overview', service.streamOverview()); + socket.leave('stream-overview'); + socket.leave('stream-match'); + // Mit einem Match-Schlüssel gibt es nur die Daten dieses einen Matches + socket.data.streamMatch = access.matchId ?? null; + socket.join(access.matchId ? 'stream-match' : 'stream-overview'); + socket.emit('stream:overview', service.streamOverview({ matchId: access.matchId })); }); - socket.on('match:subscribe', ({ matchId, token, playerToken } = {}) => { - if (typeof matchId !== 'string') return; + on('match:subscribe', ({ matchId, token, playerToken }) => { + if (typeof matchId !== 'string' || matchId.length > 64) return; for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6)); const str = (v) => (typeof v === 'string' ? v : null); - socket.data = { matchId, token: str(token), playerToken: str(playerToken) }; + socket.data = { ...socket.data, matchId, token: str(token), playerToken: str(playerToken) }; try { service.getMatch(matchId); } catch (err) { @@ -107,7 +138,12 @@ export function attachRealtime(httpServer, { service, config }) { }; socket.on('disconnect', () => { - if (socket.data?.matchId) leave(socket.data.matchId); + clearInterval(refill); + try { + if (socket.data?.matchId) leave(socket.data.matchId); + } catch (err) { + console.error(`[socket] disconnect: ${err.message}`); + } }); }); diff --git a/server/src/security.js b/server/src/security.js index 59fbb4c..4ca0240 100644 --- a/server/src/security.js +++ b/server/src/security.js @@ -47,20 +47,42 @@ export function safeEqual(a, b) { return ba.length === bb.length && crypto.timingSafeEqual(ba, bb); } +/** + * Fehlermeldung ohne Zugangsdaten. Node schreibt bei einem fehlgeschlagenen Prozess die ganze Befehlszeile + * in die Meldung ("Command failed: docker run -e CS2_RCONPW=…"). Die Zeile fällt weg, übrig bleibt die + * Ausgabe des Programms; Werte von Variablen wie *_PW, *PASSWORD*, *TOKEN* werden zusätzlich geschwärzt, + * ebenso alle in `secrets` genannten Werte. + */ +export function safeErrorMessage(err, secrets = []) { + const stderr = String(err?.stderr ?? '').trim(); + let text = stderr || String(err?.message ?? err ?? 'Unbekannter Fehler'); + text = text.replace(/^Command failed:.*$/gm, '').trim() || 'Befehl fehlgeschlagen'; + text = text.replace(/\b([A-Z0-9_]*(?:PW|PASS|PASSWORD|TOKEN|SECRET|KEY)[A-Z0-9_]*)=("[^"]*"|\S+)/gi, '$1=***'); + for (const s of secrets) if (s && String(s).length >= 4) text = text.split(String(s)).join('***'); + return text.slice(0, 500); +} + /** Einfacher In-Memory-Ratenbegrenzer (Fenster pro Schlüssel). */ -export function createRateLimiter({ windowMs, max }) { +export function createRateLimiter({ windowMs, max, maxKeys = 10_000 }) { const hits = new Map(); - return function check(key) { + let lastSweep = Date.now(); + function check(key) { const now = Date.now(); + // Abgelaufene Fenster regelmäßig entfernen, unabhängig davon, ob der Schlüssel neu ist + if (now - lastSweep > windowMs) { + for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k); + lastSweep = now; + } const entry = hits.get(key); if (!entry || now - entry.start > windowMs) { + // Obergrenze für den Speicher: ältesten Eintrag verdrängen (Map hält die Einfügereihenfolge) + if (!entry && hits.size >= maxKeys) hits.delete(hits.keys().next().value); hits.set(key, { start: now, count: 1 }); return true; } entry.count += 1; - if (hits.size > 10_000) { - for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k); - } return entry.count <= max; - }; + } + check.size = () => hits.size; + return check; } diff --git a/server/src/servers/dockerAdapter.js b/server/src/servers/dockerAdapter.js index 8c77cf7..5a66042 100644 --- a/server/src/servers/dockerAdapter.js +++ b/server/src/servers/dockerAdapter.js @@ -3,6 +3,7 @@ import { promisify } from 'node:util'; import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js'; import { runRcon } from './rcon.js'; import { sanitizeConsoleText } from './cs2Config.js'; +import { safeErrorMessage } from '../security.js'; const exec = promisify(execFile); @@ -149,8 +150,16 @@ export class DockerServerAdapter { async docker(args, timeout = 60_000) { if (this.runDocker) return this.runDocker(args, this.options.name); const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env; - const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env }); - return stdout.trim(); + try { + const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env }); + return stdout.trim(); + } catch (err) { + // Die Befehlszeile enthält bei "docker run" die Passwörter: nie weiterreichen + const clean = new Error(safeErrorMessage(err)); + clean.stderr = safeErrorMessage({ message: err.stderr ?? '' }); + clean.code = err.code; + throw clean; + } } allocatePorts(blocked = new Set()) { diff --git a/server/src/servers/knifeServers.js b/server/src/servers/knifeServers.js index 32d08f1..847d2a0 100644 --- a/server/src/servers/knifeServers.js +++ b/server/src/servers/knifeServers.js @@ -3,7 +3,7 @@ // leer wird, setzt er sich von selbst zurück. Fällt ein Server aus, startet ihn die App neu. import { HttpError } from '../matchService.js'; -import { randomPassword } from '../security.js'; +import { randomPassword, safeErrorMessage } from '../security.js'; const SLOW_POLL_MS = 10_000; // Nach so vielen Ausfällen in Folge gibt die App auf, damit ein kaputter Host nicht endlos neu startet @@ -208,6 +208,11 @@ export class KnifeServers { secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) }, serverName: `${prefix ? `${prefix} | ` : ''}${row.name} ${size ? `${size}v${size} ` : ''}(Seitenwahl)`, }); + // Während des Starts entfernt: Container gleich wieder abbauen + if (!this.row(id)) { + await this.adapter.stop(result.handle).catch(() => {}); + return; + } this.update(id, { status: 'starting', message: 'Container läuft, Spielserver lädt', @@ -219,8 +224,9 @@ export class KnifeServers { started_at: Date.now(), }); } catch (err) { - this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${err.message}` }); - throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${err.message}`, 'start_failed'); + const message = safeErrorMessage(err, [row.rcon_password]); + if (this.row(id)) this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${message}` }); + throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${message}`, 'start_failed'); } finally { this.busy.delete(id); } diff --git a/server/src/servers/rcon.js b/server/src/servers/rcon.js index 5b986e4..30f11f7 100644 --- a/server/src/servers/rcon.js +++ b/server/src/servers/rcon.js @@ -75,9 +75,17 @@ export class RconClient { onData(chunk) { this.buffer = Buffer.concat([this.buffer, chunk]); - const { packets, rest } = decodePackets(this.buffer); - this.buffer = rest; - for (const packet of packets) for (const listener of this.listeners) listener(packet); + let decoded; + try { + decoded = decodePackets(this.buffer); + } catch (err) { + // Kaputtes Paket: Verbindung trennen, laufende Befehle scheitern über "close" – nie das Backend beenden + this.buffer = Buffer.alloc(0); + this.socket?.destroy(new RconError(err.message)); + return; + } + this.buffer = decoded.rest; + for (const packet of decoded.packets) for (const listener of this.listeners) listener(packet); } authenticate() { @@ -105,15 +113,18 @@ export class RconClient { const id = this.nextId++; return new Promise((resolve, reject) => { let body = ''; + let answered = false; let quietTimer = null; - const timer = setTimeout(() => finish(), this.timeoutMs); + // Ohne jede Antwort ist der Befehl nicht bestätigt: als Fehler melden statt als Erfolg + const timer = setTimeout(() => finish(answered ? null : new RconError(`keine Antwort auf "${cmd.split(' ')[0]}"`)), this.timeoutMs); const listener = (packet) => { if (packet.id !== id) return; + answered = true; body += packet.body; clearTimeout(quietTimer); quietTimer = setTimeout(finish, quietMs); }; - const onClose = () => finish(body ? null : new RconError('RCON-Verbindung wurde geschlossen')); + const onClose = () => finish(answered ? null : new RconError('RCON-Verbindung wurde geschlossen')); const finish = (err) => { clearTimeout(timer); clearTimeout(quietTimer); diff --git a/server/src/servers/rconConfigure.js b/server/src/servers/rconConfigure.js index 40d5a33..c423063 100644 --- a/server/src/servers/rconConfigure.js +++ b/server/src/servers/rconConfigure.js @@ -11,8 +11,9 @@ export async function configureViaRcon(target, plan, { loadTimeoutMs = 120_000, try { await runRcon(target, plan.beforeMapChange); } catch (err) { - // Beim changelevel trennt der Server die RCON-Verbindung teilweise – das ist kein Fehler. - if (!/geschlossen/.test(err.message)) throw new Error(`Befehle konnten nicht gesendet werden: ${err.message}`); + // Beim changelevel trennt der Server die RCON-Verbindung teilweise oder antwortet erst nach dem Laden – + // beides ist kein Fehler, ob die Map lädt, prüft die Schleife unten. + if (!/geschlossen|keine Antwort auf "changelevel"/.test(err.message)) throw new Error(`Befehle konnten nicht gesendet werden: ${err.message}`); } const deadline = Date.now() + loadTimeoutMs; diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index a2a8cf4..d7e445c 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -3,7 +3,7 @@ import { buildKnifeCommands, KNIFE_UNDO_COMMANDS } from './knifeServers.js'; import { DemoMatchSimulator } from './demoMatchSimulator.js'; import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js'; import { HttpError, CLOSED_STATUSES } from '../matchService.js'; -import { randomPassword } from '../security.js'; +import { randomPassword, safeErrorMessage } from '../security.js'; export const SERVER_STATUS = { starting: 'Server startet', @@ -343,6 +343,7 @@ export class ServerManager { this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) wird vorbereitet' : 'Server wird gestartet'); const match = this.service.getMatch(matchId); + const attempt = (previous?.attempts ?? 0) + 1; try { const result = await this.adapter.start({ matchId, @@ -350,6 +351,14 @@ export class ServerManager { secrets, teamNames: { A: match.team_a_name, B: match.team_b_name }, }); + // Während des (oft langen) Starts gestoppt, neu angefordert oder gelöscht: den neuen Container + // gleich wieder abbauen, sonst liefe er ohne Überwachung weiter + const current = this.row(matchId); + if (!current || current.status !== 'starting' || current.attempts !== attempt) { + this.logger.warn?.(`[server] ${matchId}: Start kam nach dem Stopp an, Container wird wieder entfernt`); + await this.adapter.stop(result.handle, { resetPassword: randomPassword(16) }).catch(() => {}); + return; + } this.update(matchId, { handle: JSON.stringify(result.handle), host: result.host, @@ -358,8 +367,9 @@ export class ServerManager { message: this.adapter.simulated ? 'Simulierter Start läuft' : 'Server fährt hoch', }); } catch (err) { - this.update(matchId, { status: 'start_failed', message: err.message }); - this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${err.message}`); + const message = safeErrorMessage(err, Object.values(secrets)); + if (this.row(matchId)?.status === 'starting') this.update(matchId, { status: 'start_failed', message }); + if (this.row(matchId)) this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${message}`); } } finally { this.busy.delete(matchId); @@ -536,8 +546,9 @@ export class ServerManager { try { await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan }); } catch (err) { - this.update(matchId, { status: 'config_failed', message: err.message }); - this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${err.message}`); + const message = safeErrorMessage(err, [row.rcon_password, row.server_password, row.tv_password]); + this.update(matchId, { status: 'config_failed', message }); + this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${message}`); return; } this.update(matchId, { diff --git a/server/test/hardening.test.js b/server/test/hardening.test.js new file mode 100644 index 0000000..75e4947 --- /dev/null +++ b/server/test/hardening.test.js @@ -0,0 +1,224 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import net from 'node:net'; +import { io as connect } from 'socket.io-client'; +import { loadConfig } from '../src/config.js'; +import { createApp } from '../src/app.js'; +import { DemoServerAdapter } from '../src/servers/demoAdapter.js'; +import { applyLogLines, initialLiveState } from '../src/liveScore.js'; +import { RconClient } from '../src/servers/rcon.js'; +import { createRateLimiter, safeErrorMessage } from '../src/security.js'; + +// Absicherungen aus der Prüfung: jede Stelle mit dem Fehler, der vorher möglich war. + +const quiet = { log() {}, warn() {}, error() {} }; + +async function start(overrides = {}, adapter) { + const config = loadConfig({ + databaseFile: ':memory:', serverAdapter: 'demo', createMatchPassword: '', + demo: { startSeconds: 0, failFirstConfigure: false }, ...overrides, + }); + const app = createApp(config, { logger: quiet, adapter }); + await new Promise((resolve) => app.server.listen(0, '127.0.0.1', resolve)); + const port = app.server.address().port; + const call = async (method, path, { token, body } = {}) => { + const res = await fetch(`http://127.0.0.1:${port}/api${path}`, { + method, + headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + return { status: res.status, body: await res.json() }; + }; + return { app, port, call, close: () => app.close() }; +} + +const newMatch = (call) => call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'A' } }); + +test('Kaputte Socket-Nachrichten beenden das Backend nicht', async () => { + const { port, call, close } = await start(); + const socket = connect(`http://127.0.0.1:${port}`, { transports: ['websocket'], reconnection: false }); + try { + await new Promise((resolve, reject) => { socket.on('connect', resolve); socket.on('connect_error', reject); }); + for (const payload of [null, 5, 'x', [], { matchId: { $ne: 1 } }]) { + socket.emit('stream:subscribe', payload); + socket.emit('match:subscribe', payload); + socket.emit('overview:subscribe', payload); + } + await new Promise((r) => setTimeout(r, 200)); + assert.equal((await call('GET', '/config')).status, 200, 'Backend antwortet noch'); + } finally { + socket.close(); + await close(); + } +}); + +test('Zu viele Socket-Abfragen werden gebremst', async () => { + const { port, close } = await start(); + const socket = connect(`http://127.0.0.1:${port}`, { transports: ['websocket'], reconnection: false }); + try { + await new Promise((resolve) => socket.on('connect', resolve)); + let limited = 0; + socket.on('rate_limited', () => { limited += 1; }); + for (let i = 0; i < 60; i++) socket.emit('overview:subscribe'); + await new Promise((r) => setTimeout(r, 300)); + assert.ok(limited > 0, 'ab einer Grenze werden Abfragen abgewiesen'); + } finally { + socket.close(); + await close(); + } +}); + +test('Fehlgeschlagener Serverstart veröffentlicht keine Passwörter', async () => { + class Failing extends DemoServerAdapter { + async start({ secrets }) { + const err = new Error(`Command failed: docker run -e CS2_RCONPW=${secrets.rconPassword} -e CS2_PW=${secrets.serverPassword} joedwards32/cs2`); + err.stderr = ''; + throw err; + } + } + const { app, call, close } = await start({}, new Failing({ startSeconds: 0 })); + try { + const { id } = (await newMatch(call)).body; + await app.manager.idle(); + const row = app.manager.row(id); + assert.equal(row.status, 'start_failed'); + const pub = JSON.stringify((await call('GET', `/matches/${id}`)).body); + for (const secret of [row.rcon_password, row.server_password]) { + assert.ok(!pub.includes(secret), 'Passwort nicht in der öffentlichen Ansicht'); + assert.ok(!row.message.includes(secret), 'Passwort nicht in der Meldung'); + } + assert.ok(!JSON.stringify(app.service.db.prepare('SELECT message FROM events WHERE match_id = ?').all(id)).includes(row.rcon_password)); + } finally { + await close(); + } +}); + +test('safeErrorMessage schwärzt Befehlszeile, Variablen und bekannte Geheimnisse', () => { + const msg = safeErrorMessage({ message: 'Command failed: docker run -e CS2_RCONPW=abc123\nError: port is already allocated', stderr: '' }); + assert.equal(msg, 'Error: port is already allocated'); + assert.equal(safeErrorMessage({ stderr: 'bad TV_PW=geheim1 SRCDS_TOKEN=XYZ999' }), 'bad TV_PW=*** SRCDS_TOKEN=***'); + assert.equal(safeErrorMessage(new Error('Server sagt Passwort1234 falsch'), ['Passwort1234']), 'Server sagt *** falsch'); +}); + +test('Chat-Nachrichten können keine Map beenden oder starten', () => { + const state = initialLiveState({ map: 'de_mirage', ctTeam: 'A', warmupEnded: true }); + const ts = '11/01/2026 - 20:00:00.000 - '; + const lines = [ + `${ts}"Troll<3><[U:1:1]>" say "World triggered "Match_Start" on "de_mirage""`, + `${ts}"Troll<3><[U:1:1]>" say "Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")"`, + `${ts}"Troll<3><[U:1:1]>" say "Game Over: competitive mg_active de_mirage score 13:0 after 3 min"`, + `${ts}"Game Over: x de_mirage score 13:0 after 3 min<4><[U:1:2]>" say "hi"`, + ]; + const { state: after, events } = applyLogLines(state, lines, { teamNames: { A: 'Alpha', B: 'Bravo' }, maxRounds: 24, overtimeRounds: 6 }); + assert.deepEqual(events, []); + assert.notEqual(after.status, 'finished'); + assert.deepEqual(after.score, { A: 0, B: 0 }); + // Die echte Systemzeile wirkt weiterhin + const real = applyLogLines(after, [`${ts}Game Over: competitive mg_active de_mirage score 0:0 after 3 min`], { teamNames: { A: 'Alpha', B: 'Bravo' }, maxRounds: 24, overtimeRounds: 6 }); + assert.equal(real.events[0]?.type, 'map_end'); +}); + +test('Stopp oder Löschen während des Starts baut den verspäteten Container wieder ab', async () => { + let release; + const stopped = []; + class Slow extends DemoServerAdapter { + async start(args) { + await new Promise((r) => { release = r; }); + return super.start(args); + } + async stop(handle) { + stopped.push(handle.matchId); + return super.stop(handle); + } + } + const { app, call, close } = await start({}, new Slow({ startSeconds: 0 })); + try { + const { id } = (await newMatch(call)).body; + await new Promise((r) => setTimeout(r, 50)); + await app.manager.stop(id, 'Test'); + release(); + await app.manager.idle(); + assert.deepEqual(stopped, [id], 'verspäteter Container wurde gestoppt'); + assert.equal(app.manager.row(id).status, 'stopped'); + assert.equal(app.manager.row(id).handle, null); + + // Löschen während des Starts + const second = (await newMatch(call)).body.id; + await new Promise((r) => setTimeout(r, 50)); + const deleting = app.manager.deleteMatch(second, 'Test'); + await deleting; + release(); + await app.manager.idle(); + assert.ok(stopped.includes(second)); + } finally { + await close(); + } +}); + +/** Kleiner RCON-Server, der nach der Anmeldung tut, was der Test vorgibt. */ +async function fakeRcon(onCommand) { + const server = net.createServer((socket) => { + socket.on('data', (buf) => { + const id = buf.readInt32LE(4); + const type = buf.readInt32LE(8); + if (type === 3) { + const ok = Buffer.alloc(14); + ok.writeInt32LE(10, 0); ok.writeInt32LE(id, 4); ok.writeInt32LE(2, 8); + socket.write(ok); + } else onCommand(socket, id); + }); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + return { port: server.address().port, close: () => server.close() }; +} + +test('RCON: kaputte Pakete trennen nur die Verbindung, fehlende Antworten sind ein Fehler', async () => { + const bad = await fakeRcon((socket) => { + const junk = Buffer.alloc(8); + junk.writeInt32LE(5, 0); // Paketgröße unter dem Minimum + socket.write(junk); + }); + const silent = await fakeRcon(() => {}); + try { + const a = await new RconClient({ host: '127.0.0.1', port: bad.port, password: 'x', timeoutMs: 1000 }).connect(); + await assert.rejects(a.command('status'), /geschlossen|keine Antwort/); + a.close(); + const b = await new RconClient({ host: '127.0.0.1', port: silent.port, password: 'x', timeoutMs: 300 }).connect(); + await assert.rejects(b.command('mp_warmup_end'), /keine Antwort auf "mp_warmup_end"/); + b.close(); + } finally { + bad.close(); + silent.close(); + } +}); + +test('Organisatoren bekommen nur einen Stream-Schlüssel für ihr Match, nicht den globalen', async () => { + const { call, close } = await start({ streamKey: 'GlobalerSchluessel42' }); + try { + const one = (await newMatch(call)).body; + const two = (await newMatch(call)).body; + const view = (await call('GET', `/matches/${one.id}`, { token: one.organizerToken })).body; + const link = view.organizer.links.overview; + assert.ok(!link.includes('GlobalerSchluessel42'), 'globaler Schlüssel nicht im Link'); + const key = new URL(link).searchParams.get('key'); + const scoped = (await call('GET', `/stream/overview?key=${encodeURIComponent(key)}`)).body; + const ids = [...scoped.active, ...scoped.upcoming, ...scoped.results].map((m) => m.id); + assert.deepEqual(ids, [one.id], 'nur das eigene Match'); + assert.ok(!ids.includes(two.id)); + // Gefälschter Schlüssel für ein anderes Match geht nicht + assert.equal((await call('GET', `/stream/overview?key=${encodeURIComponent(`${two.id}.${key.split('.').pop()}`)}`)).status, 403); + // Der globale Schlüssel zeigt weiter alles + const all = (await call('GET', '/stream/overview?key=GlobalerSchluessel42')).body; + assert.equal(all.upcoming.length, 2); + } finally { + await close(); + } +}); + +test('Rate-Limiter hält seinen Speicher begrenzt', () => { + const check = createRateLimiter({ windowMs: 60_000, max: 5, maxKeys: 100 }); + for (let i = 0; i < 1000; i++) check(`ip-${i}`); + assert.ok(check.size() <= 100); + for (let i = 0; i < 5; i++) assert.equal(check('x'), true); + assert.equal(check('x'), false); +});