From 8640f5bd35e43c013cde42467d7b685673d73845 Mon Sep 17 00:00:00 2001 From: Flegma Date: Thu, 2 Apr 2026 13:56:50 +0200 Subject: [PATCH 1/5] 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/5] 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"; From 28c77e50c06dce756101ef7050237020019b72f7 Mon Sep 17 00:00:00 2001 From: Flegma Date: Wed, 8 Apr 2026 14:46:29 +0200 Subject: [PATCH 3/5] fix: add ValidateNested decorators and convert DTOs to classes Without @ValidateNested and @Type, nested objects bypass validation entirely. Convert MatchOptions, Lineup, MatchMap from interfaces to classes with class-validator decorators so nested field validation is enforced by the ValidationPipe. --- src/offline-matches/types/Lineup.ts | 14 +++++++++++- src/offline-matches/types/MatchData.ts | 10 ++++++++ src/offline-matches/types/MatchMap.ts | 14 +++++++++++- src/offline-matches/types/MatchOptions.ts | 28 ++++++++++++++++++++++- 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/offline-matches/types/Lineup.ts b/src/offline-matches/types/Lineup.ts index 1cea808..5ddf7f5 100644 --- a/src/offline-matches/types/Lineup.ts +++ b/src/offline-matches/types/Lineup.ts @@ -1,8 +1,20 @@ +import { IsString, IsOptional, IsArray, ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; import { Player } from "./Player"; -export interface Lineup { +export class Lineup { + @IsString() id: string; + + @IsString() name: string; + + @IsOptional() + @IsString() coach_steam_id?: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Player) lineup_players: Player[]; } diff --git a/src/offline-matches/types/MatchData.ts b/src/offline-matches/types/MatchData.ts index 821aaff..64fe10d 100644 --- a/src/offline-matches/types/MatchData.ts +++ b/src/offline-matches/types/MatchData.ts @@ -4,7 +4,9 @@ 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"; @@ -25,13 +27,21 @@ export class MatchData { @IsString() current_match_map_id: string; + @ValidateNested() + @Type(() => MatchOptions) options: MatchOptions; @IsArray() + @ValidateNested({ each: true }) + @Type(() => MatchMap) match_maps: MatchMap[]; + @ValidateNested() + @Type(() => Lineup) lineup_1: Lineup; + @ValidateNested() + @Type(() => Lineup) lineup_2: Lineup; @IsBoolean() diff --git a/src/offline-matches/types/MatchMap.ts b/src/offline-matches/types/MatchMap.ts index 852061d..5c47a42 100644 --- a/src/offline-matches/types/MatchMap.ts +++ b/src/offline-matches/types/MatchMap.ts @@ -1,11 +1,23 @@ -export interface MatchMap { +import { IsString, IsNumber, IsOptional } from "class-validator"; + +export class MatchMap { + @IsString() id: string; + map: { name: string; workshop_map_id?: string; }; + + @IsNumber() order: number; + + @IsString() status: string; + + @IsString() lineup_1_side: string; + + @IsString() lineup_2_side: string; } diff --git a/src/offline-matches/types/MatchOptions.ts b/src/offline-matches/types/MatchOptions.ts index 06afeb0..d04eab9 100644 --- a/src/offline-matches/types/MatchOptions.ts +++ b/src/offline-matches/types/MatchOptions.ts @@ -1,14 +1,40 @@ -export interface MatchOptions { +import { IsString, IsNumber, IsBoolean, IsOptional } from "class-validator"; + +export class MatchOptions { + @IsNumber() mr: number; + + @IsString() type: string; + + @IsNumber() best_of: number; + + @IsBoolean() coaches: boolean; + + @IsBoolean() overtime: boolean; + + @IsNumber() tv_delay: number; + + @IsBoolean() knife_round: boolean; + + @IsString() ready_setting: string; + + @IsString() timeout_setting: string; + + @IsString() tech_timeout_setting: string; + + @IsNumber() number_of_substitutes: number; + + @IsOptional() + @IsString() cfg_override: string; } From e18923cf245b5ac00a5a06a762db3127f04ac6b4 Mon Sep 17 00:00:00 2001 From: Flegma Date: Wed, 8 Apr 2026 15:03:51 +0200 Subject: [PATCH 4/5] chore: remove unused IsOptional import from MatchMap --- src/offline-matches/types/MatchMap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/offline-matches/types/MatchMap.ts b/src/offline-matches/types/MatchMap.ts index 5c47a42..a74d3ab 100644 --- a/src/offline-matches/types/MatchMap.ts +++ b/src/offline-matches/types/MatchMap.ts @@ -1,4 +1,4 @@ -import { IsString, IsNumber, IsOptional } from "class-validator"; +import { IsString, IsNumber } from "class-validator"; export class MatchMap { @IsString() From 20c0a31b6622a6733e2ec0ba75ed70aa8f25e487 Mon Sep 17 00:00:00 2001 From: Flegma Date: Fri, 10 Apr 2026 11:41:59 +0200 Subject: [PATCH 5/5] fix: remove redundant typeof string check --- src/rcon/rcon.gateway.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rcon/rcon.gateway.ts b/src/rcon/rcon.gateway.ts index 92cf235..a567124 100644 --- a/src/rcon/rcon.gateway.ts +++ b/src/rcon/rcon.gateway.ts @@ -16,7 +16,7 @@ export class RconGateway { private static readonly MAX_COMMAND_LENGTH = 512; private validateCommand(command: string): string | null { - if (!command || typeof command !== "string") { + if (!command) { return "invalid command"; } if (command.length > RconGateway.MAX_COMMAND_LENGTH) {