From 8640f5bd35e43c013cde42467d7b685673d73845 Mon Sep 17 00:00:00 2001 From: Flegma Date: Thu, 2 Apr 2026 13:56:50 +0200 Subject: [PATCH 1/2] fix: add input validation to YAML templates, RCON gateway, and match DTOs - YAML template: sanitize replacement values (strip newlines) and use split/join instead of RegExp to prevent injection via special chars - RCON gateway: validate command length (max 512), reject command chaining (semicolons, newlines), log all commands for audit trail - Offline matches: convert MatchData to class with class-validator decorators, use @Body() with ValidationPipe instead of raw req.body cast Closes 5stackgg/5stack-panel#404 Closes 5stackgg/5stack-panel#405 Closes 5stackgg/5stack-panel#406 --- .../offline-matches.controller.ts | 23 ++++++------ .../offline-matches.service.ts | 8 +++-- src/offline-matches/types/MatchData.ts | 33 ++++++++++++++++- src/rcon/rcon.gateway.ts | 36 +++++++++++++++++++ 4 files changed, 87 insertions(+), 13 deletions(-) diff --git a/src/offline-matches/offline-matches.controller.ts b/src/offline-matches/offline-matches.controller.ts index 5b1a81d..c48d89b 100644 --- a/src/offline-matches/offline-matches.controller.ts +++ b/src/offline-matches/offline-matches.controller.ts @@ -2,15 +2,17 @@ import { Controller, Get, Post, + Body, Render, - Req, Param, Res, UseGuards, + UsePipes, + ValidationPipe, } from "@nestjs/common"; import { OfflineMatchesService } from "./offline-matches.service"; import { MatchData } from "./types/MatchData"; -import { type Request, type Response } from "express"; +import { type Response } from "express"; import { BasicGuardGuard } from "./basic-guard.guard"; import { KubernetesService } from "src/kubernetes/kubernetes.service"; import { NetworkService } from "src/system/network.service"; @@ -36,10 +38,12 @@ export class OfflineMatchesController { @Post("matches") @UseGuards(BasicGuardGuard) - public async generateYaml(@Req() req: Request, @Res() res: Response) { - await this.offlineMatchesService.generateYamlFiles( - (await req.body) as unknown as MatchData, - ); + @UsePipes(new ValidationPipe({ whitelist: true })) + public async generateYaml( + @Body() matchData: MatchData, + @Res() res: Response, + ) { + await this.offlineMatchesService.generateYamlFiles(matchData); return res.redirect("/"); } @@ -66,14 +70,13 @@ export class OfflineMatchesController { @Post("matches/:id") @UseGuards(BasicGuardGuard) + @UsePipes(new ValidationPipe({ whitelist: true })) public async updateMatch( @Param("id") id: string, - @Req() req: Request, + @Body() matchData: MatchData, @Res() res: Response, ) { - await this.offlineMatchesService.updateMatchData( - (await req.body) as unknown as MatchData, - ); + await this.offlineMatchesService.updateMatchData(matchData); return res.redirect("/"); } diff --git a/src/offline-matches/offline-matches.service.ts b/src/offline-matches/offline-matches.service.ts index e4e6b7c..960be77 100644 --- a/src/offline-matches/offline-matches.service.ts +++ b/src/offline-matches/offline-matches.service.ts @@ -128,7 +128,10 @@ export class OfflineMatchesService { } } - // Helper function to replace placeholders in YAML template + private sanitizeYamlValue(value: string): string { + return value.replace(/[\r\n]/g, ""); + } + private replacePlaceholders( template: string, replacements: Record, @@ -136,7 +139,8 @@ export class OfflineMatchesService { let result = template; for (const [key, value] of Object.entries(replacements)) { const placeholder = `{{${key}}}`; - result = result.replace(new RegExp(placeholder, "g"), value); + const sanitized = this.sanitizeYamlValue(value); + result = result.split(placeholder).join(sanitized); } return result; } diff --git a/src/offline-matches/types/MatchData.ts b/src/offline-matches/types/MatchData.ts index fc8de68..f74d05c 100644 --- a/src/offline-matches/types/MatchData.ts +++ b/src/offline-matches/types/MatchData.ts @@ -1,18 +1,49 @@ +import { + IsString, + IsBoolean, + IsArray, + IsOptional, + IsNumber, + ValidateNested, +} from "class-validator"; +import { Type } from "class-transformer"; import { Lineup } from "./Lineup"; import { MatchMap } from "./MatchMap"; import { MatchOptions } from "./MatchOptions"; -export interface MatchData { +export class MatchData { + @IsString() id: string; + + @IsString() password: string; + + @IsString() lineup_1_id: string; + + @IsString() lineup_2_id: string; + + @IsString() current_match_map_id: string; + options: MatchOptions; + + @IsArray() match_maps: MatchMap[]; + lineup_1: Lineup; + lineup_2: Lineup; + + @IsBoolean() is_lan: boolean; + + @IsOptional() + @IsNumber() server_port?: number; + + @IsOptional() + @IsNumber() tv_port?: number; } diff --git a/src/rcon/rcon.gateway.ts b/src/rcon/rcon.gateway.ts index 9dcc33f..92cf235 100644 --- a/src/rcon/rcon.gateway.ts +++ b/src/rcon/rcon.gateway.ts @@ -4,12 +4,30 @@ import { SubscribeMessage, WebSocketGateway, } from "@nestjs/websockets"; +import { Logger } from "@nestjs/common"; import { RconService } from "../rcon/rcon.service"; @WebSocketGateway() export class RconGateway { + private readonly logger = new Logger(RconGateway.name); + constructor(private readonly rconService: RconService) {} + private static readonly MAX_COMMAND_LENGTH = 512; + + private validateCommand(command: string): string | null { + if (!command || typeof command !== "string") { + return "invalid command"; + } + if (command.length > RconGateway.MAX_COMMAND_LENGTH) { + return "command too long"; + } + if (/[;\n\r]/.test(command)) { + return "command contains invalid characters"; + } + return null; + } + @SubscribeMessage("rcon") async rconEvent( @MessageBody() @@ -20,6 +38,24 @@ export class RconGateway { }, @ConnectedSocket() client: WebSocket, ) { + const validationError = this.validateCommand(data.command); + if (validationError) { + client.send( + JSON.stringify({ + event: "rcon", + data: { + uuid: data.uuid, + result: validationError, + }, + }), + ); + return; + } + + this.logger.log( + `RCON [${data.matchId}]: ${data.command}`, + ); + const rcon = await this.rconService.connect(data.matchId); if (!rcon) { From 5170a6738a1f097c6af7fcc7f9b6c3402dd523c9 Mon Sep 17 00:00:00 2001 From: Flegma Date: Thu, 2 Apr 2026 16:59:32 +0200 Subject: [PATCH 2/2] chore: remove unused ValidateNested and Type imports These were imported for future nested object validation but are not currently used. Will be re-added when nested DTOs are converted. --- src/offline-matches/types/MatchData.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/offline-matches/types/MatchData.ts b/src/offline-matches/types/MatchData.ts index f74d05c..821aaff 100644 --- a/src/offline-matches/types/MatchData.ts +++ b/src/offline-matches/types/MatchData.ts @@ -4,9 +4,7 @@ import { IsArray, IsOptional, IsNumber, - ValidateNested, } from "class-validator"; -import { Type } from "class-transformer"; import { Lineup } from "./Lineup"; import { MatchMap } from "./MatchMap"; import { MatchOptions } from "./MatchOptions";