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:
1 parent
35bdfc1f67
commit
e18b8b4430
13 files changed
+409
-49
No files matched your search
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user