diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 7e89509cc..1721f9f33 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -1,5 +1,6 @@ import z from "zod"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; +import { GameType } from "../core/game/Game"; import { GameID, GameRecord, @@ -14,8 +15,15 @@ const config = getServerConfigFromServer(); const log = logger.child({ component: "Archive" }); -export async function archive(gameRecord: GameRecord) { +export async function archive( + gameRecord: GameRecord, + trustedCosmeticFlagUrls: Set = new Set(), +) { try { + if (gameRecord.info.config.gameType === GameType.Singleplayer) { + stripUntrustedFlagUrls(gameRecord, trustedCosmeticFlagUrls); + } + const parsed = GameRecordSchema.safeParse(gameRecord); if (!parsed.success) { log.error(`invalid game record: ${z.prettifyError(parsed.error)}`, { @@ -88,3 +96,24 @@ export function finalizeGameRecord( domain: config.domain(), }; } + +function stripUntrustedFlagUrls( + gameRecord: GameRecord, + trustedCosmeticFlagUrls: Set, +): void { + for (const player of gameRecord.info.players) { + const flag = player.cosmetics?.flag; + if ( + flag === undefined || + !/^https?:\/\//i.test(flag) || + trustedCosmeticFlagUrls.has(flag) + ) { + continue; + } + log.warn("dropping untrusted singleplayer replay flag", { + gameID: gameRecord.info.gameID, + clientID: player.clientID, + }); + player.cosmetics!.flag = undefined; + } +} diff --git a/src/server/PrivilegeRefresher.ts b/src/server/PrivilegeRefresher.ts index 086c7218a..7561c57ec 100644 --- a/src/server/PrivilegeRefresher.ts +++ b/src/server/PrivilegeRefresher.ts @@ -14,6 +14,7 @@ export class PrivilegeRefresher { private privilegeChecker: PrivilegeChecker | null = null; private failOpenPrivilegeChecker: PrivilegeChecker = new FailOpenPrivilegeChecker(); + private cosmeticFlagUrls: Set = new Set(); private log: Logger; @@ -38,6 +39,10 @@ export class PrivilegeRefresher { return this.privilegeChecker ?? this.failOpenPrivilegeChecker; } + public getCosmeticFlagUrls(): Set { + return this.cosmeticFlagUrls; + } + private async loadPrivilegeChecker(): Promise { this.log.info(`Loading privilege checker`); try { @@ -92,6 +97,9 @@ export class PrivilegeRefresher { base64url.decode, bannedWords, ); + this.cosmeticFlagUrls = new Set( + Object.values(result.data.flags).map((f) => f.url), + ); this.log.info(`Privilege checker loaded successfully`); } catch (error) { this.log.error(`Failed to load privilege checker:`, error); diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 3d0e58c42..b775d393a 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -287,7 +287,10 @@ export async function startWorker() { gameID: gameRecord.info.gameID, }); - archive(finalizeGameRecord(gameRecord)); + archive( + finalizeGameRecord(gameRecord), + privilegeRefresher.getCosmeticFlagUrls(), + ); res.json({ success: true, }); diff --git a/tests/server/Archive.test.ts b/tests/server/Archive.test.ts new file mode 100644 index 000000000..4f1561652 --- /dev/null +++ b/tests/server/Archive.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/core/configuration/ConfigLoader", () => ({ + getServerConfigFromServer: () => ({ + jwtIssuer: () => "https://archive.test.invalid", + apiKey: () => "test-key", + gitCommit: () => "DEV", + subdomain: () => "test", + domain: () => "test", + }), +})); + +vi.mock("../../src/server/Logger", () => ({ + logger: { + child: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +vi.mock("../../src/core/Schemas", async () => { + const actual = (await vi.importActual("../../src/core/Schemas")) as any; + return { + ...actual, + GameRecordSchema: { + safeParse: (data: any) => ({ success: true, data }), + }, + }; +}); + +import { GameType } from "../../src/core/game/Game"; +import type { GameRecord } from "../../src/core/Schemas"; +import { archive } from "../../src/server/Archive"; + +function buildRecord(gameType: GameType, flag: string | undefined): GameRecord { + return { + info: { + gameID: "TEST123456", + config: { gameType } as any, + players: [ + { + clientID: "client-1", + username: "Test", + clanTag: null, + persistentID: "persist-1", + stats: {} as any, + cosmetics: flag ? { flag } : undefined, + } as any, + ], + } as any, + version: "v0.0.2", + gitCommit: "DEV", + subdomain: "test", + domain: "test", + turns: [], + } as GameRecord; +} + +function archivedBody(fetchMock: ReturnType): any { + expect(fetchMock).toHaveBeenCalledOnce(); + return JSON.parse(fetchMock.mock.calls[0][1].body); +} + +describe("archive() singleplayer flag sanitization", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue({ ok: true, statusText: "OK" }); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("preserves same-origin country flag paths", async () => { + await archive( + buildRecord(GameType.Singleplayer, "/flags/us.svg"), + new Set(), + ); + expect(archivedBody(fetchMock).info.players[0].cosmetics.flag).toBe( + "/flags/us.svg", + ); + }); + + it("preserves manifest-resolved asset paths", async () => { + await archive( + buildRecord(GameType.Singleplayer, "/_assets/flags/us-abc123.svg"), + new Set(), + ); + expect(archivedBody(fetchMock).info.players[0].cosmetics.flag).toBe( + "/_assets/flags/us-abc123.svg", + ); + }); + + it("preserves cosmetic flag URLs that are in the trusted set", async () => { + const trustedUrl = "https://example.com/cool.png"; + await archive( + buildRecord(GameType.Singleplayer, trustedUrl), + new Set([trustedUrl]), + ); + expect(archivedBody(fetchMock).info.players[0].cosmetics.flag).toBe( + trustedUrl, + ); + }); + + it("drops attacker-controlled URLs not in the trusted set", async () => { + await archive( + buildRecord( + GameType.Singleplayer, + "https://attacker.example/payload.png", + ), + new Set(["https://example.com/cool.png"]), + ); + expect( + archivedBody(fetchMock).info.players[0].cosmetics?.flag, + ).toBeUndefined(); + }); + + it("drops http URLs regardless of case", async () => { + await archive( + buildRecord(GameType.Singleplayer, "HTTP://attacker.example/x.png"), + new Set(), + ); + expect( + archivedBody(fetchMock).info.players[0].cosmetics?.flag, + ).toBeUndefined(); + }); + + it("preserves untouched player when no flag is set", async () => { + await archive(buildRecord(GameType.Singleplayer, undefined), new Set()); + expect(archivedBody(fetchMock).info.players[0].cosmetics).toBeUndefined(); + }); + + it("drops absolute URLs even when the trusted set is omitted", async () => { + await archive( + buildRecord(GameType.Singleplayer, "https://example.com/cool.png"), + ); + expect( + archivedBody(fetchMock).info.players[0].cosmetics?.flag, + ).toBeUndefined(); + }); +}); + +describe("archive() multiplayer paths skip sanitization", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue({ ok: true, statusText: "OK" }); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not modify cosmetics for public games", async () => { + const attackerUrl = "https://attacker.example/payload.png"; + await archive(buildRecord(GameType.Public, attackerUrl)); + expect(archivedBody(fetchMock).info.players[0].cosmetics.flag).toBe( + attackerUrl, + ); + }); + + it("does not modify cosmetics for private games", async () => { + const attackerUrl = "https://attacker.example/payload.png"; + await archive(buildRecord(GameType.Private, attackerUrl)); + expect(archivedBody(fetchMock).info.players[0].cosmetics.flag).toBe( + attackerUrl, + ); + }); +});