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..821aaff 100644 --- a/src/offline-matches/types/MatchData.ts +++ b/src/offline-matches/types/MatchData.ts @@ -1,18 +1,47 @@ +import { + IsString, + IsBoolean, + IsArray, + IsOptional, + IsNumber, +} from "class-validator"; 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) {