fix(security): prevent RCON command injection in player kick/ban/unban (HIGH)
Some checks failed
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Failing after 29s
CI / integration (push) Has been skipped

Player id and ban reason flowed unsanitized into the single-line RCON command,
so a control char (newline/CR) in 'reason' could break the framing and inject a
second console command — an RBAC-escalation vector (a Moderator-role user could
run arbitrary RCON via the ban reason field).

- validate player id against a safe token charset /^[A-Za-z0-9_.:-]{1,64}$/ and
  reject otherwise (multi-game safe — not a Rust-only SteamID64 regex, so
  Conan/Funcom and Dune ids still pass)
- strip C0 control chars from reason, collapse whitespace, cap at 200 chars
- coerce ban duration to a non-negative integer

Flagged by automated commit security review. Backend tsc green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 22:36:44 -04:00
parent 440474290b
commit 215355d1cb

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PlayerAction } from '../../entities/player-action.entity';
@@ -142,14 +142,32 @@ export class PlayersService {
}
private buildRconCommand(dto: PlayerActionDto): string {
// Defense-in-depth against RCON command injection. The command is a single
// line; an id or reason containing a newline/control char could break the
// framing and inject a second console command. So:
// - the player id must be a safe token (no whitespace/control chars) — a
// permissive charset, not a Rust-only SteamID64 regex, so Conan (Funcom)
// and Dune ids still validate. Reject outright if not.
// - the free-text reason has control chars stripped and is length-capped.
// - duration is coerced to a non-negative integer.
const id = dto.steam_id ?? '';
if (!/^[A-Za-z0-9_.:-]{1,64}$/.test(id)) {
throw new BadRequestException('Invalid player id');
}
const safeReason =
(dto.reason ?? 'banned').replace(/[\u0000-\u001F]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200) || 'banned';
const secs = Number.isFinite(dto.duration_minutes)
? Math.max(0, Math.floor((dto.duration_minutes as number) * 60))
: 0;
switch (dto.action_type) {
case 'kick':
return `kick ${dto.steam_id}${dto.reason ? ' ' + dto.reason : ''}`;
return `kick ${id}${dto.reason ? ' ' + safeReason : ''}`;
case 'ban':
// banid <steamId> <reason> <durationSeconds> — 0 = permanent
return `banid ${dto.steam_id} ${dto.reason ?? 'banned'} ${dto.duration_minutes ? dto.duration_minutes * 60 : 0}`;
return `banid ${id} ${safeReason} ${secs}`;
case 'unban':
return `unban ${dto.steam_id}`;
return `unban ${id}`;
default:
return '';
}