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>
This commit is contained in:
Scott Anderson
2025-06-02 16:33:47 -04:00
committed by 1brucben
parent 8dc6941fe7
commit 3d032192e1
5 changed files with 32 additions and 26 deletions
+1 -1
View File
@@ -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),
+11
View File
@@ -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();
+1 -2
View File
@@ -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 {
+1 -3
View File
@@ -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()),
},
);
+18 -20
View File
@@ -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;