From da7e5834452aeccde534f594c2f778a4aad8a6be Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Mon, 2 Jun 2025 16:33:47 -0400 Subject: [PATCH] Validate incoming API data with zod (#891) ## Description: Validate incoming API data with zod. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/core/Schemas.ts | 2 +- src/core/WorkerSchemas.ts | 11 +++++++++++ src/server/MapPlaylist.ts | 3 +-- src/server/Master.ts | 4 +--- src/server/Worker.ts | 38 ++++++++++++++++++-------------------- 5 files changed, 32 insertions(+), 26 deletions(-) create mode 100644 src/core/WorkerSchemas.ts diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index f50ad78f8..74aa99b2a 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -112,7 +112,7 @@ export enum LogSeverity { Fatal = "FATAL", } -const GameConfigSchema = z.object({ +export const GameConfigSchema = z.object({ gameMap: z.nativeEnum(GameMapType), difficulty: z.nativeEnum(Difficulty), gameType: z.nativeEnum(GameType), diff --git a/src/core/WorkerSchemas.ts b/src/core/WorkerSchemas.ts new file mode 100644 index 000000000..0a06b1571 --- /dev/null +++ b/src/core/WorkerSchemas.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; +import { GameConfigSchema } from "./Schemas"; + +export const CreateGameInputSchema = GameConfigSchema.or( + z + .object({}) + .strict() + .transform((val) => undefined), +); + +export const GameInputSchema = GameConfigSchema.partial(); diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index cebf4a0b8..f03bf3232 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -58,11 +58,10 @@ export class MapPlaylist { infiniteTroops: false, instantBuild: false, disableNPCs: mode === GameMode.Team, - disableNukes: false, gameMode: mode, playerTeams: numPlayerTeams, bots: 400, - } as GameConfig; + } satisfies GameConfig; } private getNextMap(): MapWithMode { diff --git a/src/server/Master.ts b/src/server/Master.ts index 19bddcfad..388aba19c 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -282,9 +282,7 @@ async function schedulePublicGame(playlist: MapPlaylist) { "Content-Type": "application/json", [config.adminHeader()]: config.adminToken(), }, - body: JSON.stringify({ - gameConfig: playlist.gameConfig(), - }), + body: JSON.stringify(playlist.gameConfig()), }, ); diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 2ec20a3fd..f3e4d5de2 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -11,10 +11,10 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { ClientMessageSchema, - GameConfig, GameRecord, GameRecordSchema, } from "../core/Schemas"; +import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas"; import { archive, readGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; @@ -89,7 +89,13 @@ export function startWorker() { return res.status(400).json({ error: "Game ID is required" }); } const clientIP = req.ip || req.socket.remoteAddress || "unknown"; - const gc = req.body?.gameConfig as GameConfig; + const result = CreateGameInputSchema.safeParse(req.body); + if (!result.success) { + const error = z.prettifyError(result.error); + return res.status(400).json({ error }); + } + + const gc = result.data; if ( gc?.gameType === GameType.Public && req.headers[config.adminHeader()] !== config.adminToken() @@ -97,9 +103,7 @@ export function startWorker() { log.warn( `cannot create public game ${id}, ip ${ipAnonymize(clientIP)} incorrect admin token`, ); - return res - .status(400) - .json({ error: "Invalid admin token for public game creation" }); + return res.status(401).send("Unauthorized"); } // Double-check this worker should host this game @@ -144,9 +148,15 @@ export function startWorker() { app.put( "/api/game/:id", gatekeeper.httpHandler(LimiterType.Put, async (req, res) => { + const result = GameInputSchema.safeParse(req.body); + if (!result.success) { + const error = z.prettifyError(result.error); + return res.status(400).json({ error }); + } + const config = result.data; // TODO: only update public game if from local host const lobbyID = req.params.id; - if (req.body.gameType === GameType.Public) { + if (config.gameType === GameType.Public) { log.info(`cannot update game ${lobbyID} to public`); return res.status(400).json({ error: "Cannot update public game" }); } @@ -167,18 +177,7 @@ export function startWorker() { .status(400) .json({ error: "Cannot update game after it has started" }); } - game.updateGameConfig({ - gameMap: req.body.gameMap, - difficulty: req.body.difficulty, - infiniteGold: req.body.infiniteGold, - infiniteTroops: req.body.infiniteTroops, - instantBuild: req.body.instantBuild, - bots: req.body.bots, - disableNPCs: req.body.disableNPCs, - disabledUnits: req.body.disabledUnits, - gameMode: req.body.gameMode, - playerTeams: req.body.playerTeams, - }); + game.updateGameConfig(config); res.status(200).json({ success: true }); }), ); @@ -251,8 +250,7 @@ export function startWorker() { if (!result.success) { const error = z.prettifyError(result.error); log.info(error); - res.status(400).json({ error }); - return; + return res.status(400).json({ error }); } const gameRecord: GameRecord = result.data;