Absicherung: Socket-Eingaben, Geheimnisse in Fehlern, Logparser, Start/Stopp, RCON, Stream-Zugang

- 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.
This commit is contained in:
mregosar committed 2026-11-01 20:25:45 +01:00
1 parent 35bdfc1f67
commit e18b8b4430
13 files changed
+409 -49

No files matched your search

+224
View File
@@ -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]><CT>" say "World triggered "Match_Start" on "de_mirage""`,
`${ts}"Troll<3><[U:1:1]><CT>" say "Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")"`,
`${ts}"Troll<3><[U:1:1]><CT>" 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]><T>" 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);
});