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
+47
-11
@@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user