From 0e10b64fb7116db22e04cbfb9534e0e17c9f7879 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 19:48:43 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=98=88=EC=95=BD=20=EB=AA=85=EB=A0=B9?= =?UTF-8?q?=EC=9D=98=20Ref=20=EC=9D=B8=EC=9E=90=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=EB=A5=BC=20=EB=B3=B5=EC=9B=90=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/turns/index.ts | 55 +++++- app/game-api/src/turns/commandInput.ts | 185 +++++++++++++++++- app/game-api/test/commandInput.test.ts | 42 ++++ app/game-api/test/router.test.ts | 48 ++++- .../securityTransport.integration.test.ts | 54 +++-- 5 files changed, 359 insertions(+), 25 deletions(-) diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index 659d9b67..8d47ccbf 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -14,6 +14,8 @@ import { } from '../../turns/commandTable.js'; import { loadMapDefinitionByName } from '../../maps/mapDefinition.js'; import { + assertReservedTurnActionAvailable, + assertReservedTurnArgsPassLegacyBasicValidation, buildEquipmentTradeItemOptions, parseReservedTurnArgs, TURN_COMMAND_NATION_COLORS, @@ -85,6 +87,27 @@ const parseCommandArgs = async ( } }; +const preflightCommandArgs = async ( + scope: 'general' | 'nation', + action: string, + args: unknown, + worldState: WorldStateRow +): Promise => { + try { + // Ref checks scenario availability and common argument types before + // officer/penalty gates. Core keeps actor ownership first, then leaves + // required command-specific fields for the later parser. + await assertReservedTurnActionAvailable(scope, action, asRecord(worldState.config).const); + assertReservedTurnArgsPassLegacyBasicValidation(args); + } catch (error) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: error instanceof Error ? error.message : 'Invalid turn command arguments.', + cause: error, + }); + } +}; + const mutateReservedTurns = async (mutation: () => Promise): Promise => { try { return await mutation(); @@ -484,6 +507,7 @@ export const turnsRouter = router({ .mutation(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); const worldState = await getReservationWorldState(ctx); + await preflightCommandArgs('general', input.action, input.args, worldState); const args = await parseCommandArgs('general', input.action, input.args, worldState); await assertReservedTurnPermission(worldState, general, 'general', input.action, args); @@ -540,15 +564,20 @@ export const turnsRouter = router({ .mutation(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); const worldState = await getReservationWorldState(ctx); - const updates = await Promise.all( - input.entries.map(async (entry) => ({ + const firstEntry = input.entries[0]; + await preflightCommandArgs('general', firstEntry.action, firstEntry.args, worldState); + const updates: ReservedTurnUpdate[] = []; + for (const [index, entry] of input.entries.entries()) { + if (index > 0) { + await preflightCommandArgs('general', entry.action, entry.args, worldState); + } + const update = { turnIndices: expandGeneralTurnIndices(entry.turnList), action: entry.action, args: await parseCommandArgs('general', entry.action, entry.args, worldState), - })) - ); - for (const update of updates) { + }; await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args); + updates.push(update); } const snapshot = await mutateReservedTurns(() => setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision) @@ -573,6 +602,8 @@ export const turnsRouter = router({ ) .mutation(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); + const worldState = await getReservationWorldState(ctx); + await preflightCommandArgs('nation', input.action, input.args, worldState); if (general.nationId <= 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', @@ -585,9 +616,8 @@ export const turnsRouter = router({ message: 'General is not an officer.', }); } - const worldState = await getReservationWorldState(ctx); - const args = await parseCommandArgs('nation', input.action, input.args, worldState); assertNationTurnInputAllowed(general); + const args = await parseCommandArgs('nation', input.action, input.args, worldState); await assertReservedTurnPermission(worldState, general, 'nation', input.action, args); const snapshot = await mutateReservedTurns(() => @@ -684,6 +714,9 @@ export const turnsRouter = router({ ) .mutation(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); + const worldState = await getReservationWorldState(ctx); + const firstEntry = input.entries[0]; + await preflightCommandArgs('nation', firstEntry.action, firstEntry.args, worldState); if (general.nationId <= 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', @@ -696,15 +729,17 @@ export const turnsRouter = router({ message: 'General is not an officer.', }); } - const worldState = await getReservationWorldState(ctx); const updates: ReservedTurnUpdate[] = []; - for (const entry of input.entries) { + for (const [index, entry] of input.entries.entries()) { + if (index > 0) { + await preflightCommandArgs('nation', entry.action, entry.args, worldState); + } + assertNationTurnInputAllowed(general); const update = { turnIndices: entry.turnList, action: entry.action, args: await parseCommandArgs('nation', entry.action, entry.args, worldState), }; - assertNationTurnInputAllowed(general); await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args); updates.push(update); } diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index fa12257e..ea99df55 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -1,6 +1,7 @@ import { isGeneralTurnCommandKey, isNationTurnCommandKey, + getLegacyStringWidth, loadGeneralTurnCommandSpecs, loadNationTurnCommandSpecs, type GeneralTurnCommandSpec, @@ -385,6 +386,188 @@ const parseRegisteredTurnArgs = async ( return spec.argsSchema.parse(rawArgs); }; +const LEGACY_REMOVED_TURN_ARG_CHARACTERS = new Set([ + '"', + "'", + 'ⓝ', + 'ⓜ', + 'ⓖ', + 'ⓞ', + 'ⓧ', + '㉥', + '\\', + '/', + '`', + '#', + '-', + '|', +]); + +const sanitizeLegacyTurnArgString = (value: string): string => { + // Ref StringUtil::neutralize() treats the string "0" as empty because of + // PHP truthiness, both before and after removeSpecialCharacter(). + if (value === '' || value === '0') { + return ''; + } + const stripped = Array.from(value) + .filter((character) => !LEGACY_REMOVED_TURN_ARG_CHARACTERS.has(character)) + .join(''); + if (stripped === '' || stripped === '0') { + return ''; + } + return stripped + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replace(/^[\p{Z}\p{C}]+|[\p{Z}\p{C}]+$/gu, ''); +}; + +export const sanitizeReservedTurnArgs = (value: unknown): unknown => { + if (typeof value === 'string') { + return sanitizeLegacyTurnArgString(value); + } + if (Array.isArray(value)) { + return value.map((entry) => sanitizeReservedTurnArgs(entry)); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, sanitizeReservedTurnArgs(entry)])); + } + return value; +}; + +const LEGACY_INTEGER_TURN_ARG_KEYS = new Set([ + 'crewType', + 'destGeneralId', + 'destGeneralID', + 'destCityId', + 'destCityID', + 'destNationId', + 'destNationID', + 'amount', + 'colorType', + 'srcArmType', + 'destArmType', +]); +const LEGACY_BOOLEAN_TURN_ARG_KEYS = new Set(['isGold', 'buyRice']); +const LEGACY_INTEGER_ARRAY_TURN_ARG_KEYS = new Set([ + 'destNationIdList', + 'destNationIDList', + 'destGeneralIdList', + 'destGeneralIDList', + 'amountList', +]); +const LEGACY_NUMERIC_PATTERN = /^[\t\n\r\f\v ]*[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[\t\n\r\f\v ]*$/; + +const skipsLegacyOptionalValidation = (value: unknown): boolean => value === null || value === ''; + +const isLegacyNumeric = (value: unknown): boolean => { + if (typeof value === 'number') { + return Number.isFinite(value); + } + if (typeof value !== 'string' || !LEGACY_NUMERIC_PATTERN.test(value)) { + return false; + } + return Number.isFinite(Number(value)); +}; + +const throwLegacyBasicTurnArgError = (): never => { + throw new Error('턴이 입력되지 않았습니다.'); +}; + +/** + * Ref checkCommandArg() only validates common fields that are present. Missing + * command-specific fields are deliberately left for command construction, so + * nation penalties and officer checks retain their legacy error priority. + */ +export const assertReservedTurnArgsPassLegacyBasicValidation = (rawArgs: unknown): void => { + const sanitizedArgs = sanitizeReservedTurnArgs(rawArgs); + if (sanitizedArgs === null || sanitizedArgs === undefined) { + return; + } + if (typeof sanitizedArgs !== 'object') { + throwLegacyBasicTurnArgError(); + } + + const args = sanitizedArgs as Record; + for (const key of LEGACY_INTEGER_TURN_ARG_KEYS) { + if ( + Object.prototype.hasOwnProperty.call(args, key) && + !skipsLegacyOptionalValidation(args[key]) && + !Number.isInteger(args[key]) + ) { + throwLegacyBasicTurnArgError(); + } + } + for (const key of LEGACY_BOOLEAN_TURN_ARG_KEYS) { + if ( + Object.prototype.hasOwnProperty.call(args, key) && + !skipsLegacyOptionalValidation(args[key]) && + typeof args[key] !== 'boolean' + ) { + throwLegacyBasicTurnArgError(); + } + } + for (const key of LEGACY_INTEGER_ARRAY_TURN_ARG_KEYS) { + if (!Object.prototype.hasOwnProperty.call(args, key)) { + continue; + } + const value = args[key]; + if (skipsLegacyOptionalValidation(value)) { + continue; + } + if (!Array.isArray(value) || value.some((entry) => !Number.isInteger(entry))) { + throwLegacyBasicTurnArgError(); + } + } + + const month = args.month; + if ( + Object.prototype.hasOwnProperty.call(args, 'month') && + !skipsLegacyOptionalValidation(month) && + (!isLegacyNumeric(month) || Number(month) < 1 || Number(month) > 12) + ) { + throwLegacyBasicTurnArgError(); + } + const year = args.year; + if ( + Object.prototype.hasOwnProperty.call(args, 'year') && + !skipsLegacyOptionalValidation(year) && + (!isLegacyNumeric(year) || Number(year) < 0) + ) { + throwLegacyBasicTurnArgError(); + } + for (const [key, minimum] of [ + ['destGeneralId', 1], + ['destGeneralID', 1], + ['destCityId', 1], + ['destCityID', 1], + ['destNationId', 1], + ['destNationID', 1], + ['amount', 1], + ['crewType', 0], + ] as const) { + if ( + Object.prototype.hasOwnProperty.call(args, key) && + !skipsLegacyOptionalValidation(args[key]) && + Number(args[key]) < minimum + ) { + throwLegacyBasicTurnArgError(); + } + } + + if (Object.prototype.hasOwnProperty.call(args, 'nationName')) { + const nationName = args.nationName; + if ( + !skipsLegacyOptionalValidation(nationName) && + (typeof nationName !== 'string' || + getLegacyStringWidth(nationName) < 1 || + getLegacyStringWidth(nationName) > 18) + ) { + throwLegacyBasicTurnArgError(); + } + } +}; + export const assertReservedTurnActionAvailable = async ( scope: 'general' | 'nation', action: string, @@ -403,5 +586,5 @@ export const parseReservedTurnArgs = async ( scenarioConst?: unknown ): Promise> => { await assertReservedTurnActionAvailable(scope, action, scenarioConst); - return parseRegisteredTurnArgs(scope, action, rawArgs); + return parseRegisteredTurnArgs(scope, action, sanitizeReservedTurnArgs(rawArgs)); }; diff --git a/app/game-api/test/commandInput.test.ts b/app/game-api/test/commandInput.test.ts index a6b143d2..e12f032c 100644 --- a/app/game-api/test/commandInput.test.ts +++ b/app/game-api/test/commandInput.test.ts @@ -8,9 +8,11 @@ import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scena import { describe, expect, it } from 'vitest'; import { + assertReservedTurnArgsPassLegacyBasicValidation, buildEquipmentTradeItemOptions, buildTurnCommandInputFields, parseReservedTurnArgs, + sanitizeReservedTurnArgs, } from '../src/turns/commandInput.js'; const buildShopItem = (key: string, name: string) => ({ @@ -99,6 +101,46 @@ describe('turn command argument input', () => { await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command'); }); + it('keeps Ref common validation separate from command-specific required fields', async () => { + expect(() => assertReservedTurnArgsPassLegacyBasicValidation({})).not.toThrow(); + expect(() => + assertReservedTurnArgsPassLegacyBasicValidation({ + isGold: true, + amount: '1', + destGeneralId: 7, + }) + ).toThrow('턴이 입력되지 않았습니다.'); + expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ month: '12', year: 0 })).not.toThrow(); + expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '0' })).not.toThrow(); + expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ year: '0x10' })).toThrow( + '턴이 입력되지 않았습니다.' + ); + await expect(parseReservedTurnArgs('nation', 'che_국호변경', { nationName: '0' })).rejects.toBeDefined(); + }); + + it('recursively sanitizes reserved command strings like Ref before command parsing', async () => { + expect( + sanitizeReservedTurnArgs({ + nationName: ' <신-국># ', + nested: ['A/B', '0'], + }) + ).toEqual({ + nationName: '<신국>', + nested: ['AB', ''], + }); + await expect( + parseReservedTurnArgs('nation', 'che_국호변경', { + nationName: ' <신-국># ', + }) + ).resolves.toEqual({ nationName: '<신국>' }); + await expect( + parseReservedTurnArgs('nation', 'che_피장파장', { + destNationId: 2, + commandType: 'che_-수몰', + }) + ).resolves.toEqual({ destNationId: 2, commandType: 'che_수몰' }); + }); + it('rejects internal general commands before parsing their arguments or scenario overrides', async () => { await expect(parseReservedTurnArgs('general', 'che_NPC능동', {})).rejects.toThrow( 'Unknown general turn command: che_NPC능동' diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index bbe9523f..796f0a19 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -1112,6 +1112,30 @@ describe('appRouter', () => { expect(nationWrites).toHaveLength(2); }); + it('stores Ref-sanitized nation command strings in the reserved queue', async () => { + const general = buildGeneralRow({ id: 19, nationId: 3, officerLevel: 12 }); + const nationWrites: unknown[] = []; + const caller = appRouter.createCaller( + buildContext({ state: buildWorldState(), general, nationTurnWrites: nationWrites }) + ); + + const response = await caller.turns.reserved.setNation({ + generalId: general.id, + turnIndex: 0, + action: 'che_국호변경', + args: { nationName: ' <신-국># ' }, + expectedRevision: 0, + }); + + expect(response.turns[0]?.args).toEqual({ nationName: '<신국>' }); + expect(nationWrites).toHaveLength(1); + expect(nationWrites[0]).toMatchObject({ + data: expect.arrayContaining([ + expect.objectContaining({ turnIdx: 0, arg: { nationName: '<신국>' } }), + ]), + }); + }); + it('preserves Ref nation-turn penalty key semantics and validation priority', async () => { const general = buildGeneralRow({ id: 22, @@ -1159,7 +1183,8 @@ describe('appRouter', () => { singleCaller.turns.reserved.setNation({ generalId: general.id, turnIndex: 0, - action: '휴식', + action: 'che_포상', + args: {}, expectedRevision: 0, }) ).rejects.toMatchObject({ @@ -1184,7 +1209,7 @@ describe('appRouter', () => { bulkCaller.turns.reserved.setNationBulk({ generalId: general.id, entries: [ - { turnList: [0], action: '휴식' }, + { turnList: [0], action: 'che_포상', args: {} }, { turnList: [1], action: 'not-a-command' }, ], expectedRevision: 0, @@ -1195,6 +1220,25 @@ describe('appRouter', () => { }); expect(bulkWrites).toHaveLength(0); expect(bulkUpdates).toHaveLength(0); + + const allowedCaller = appRouter.createCaller( + buildContext({ + state: buildWorldState('full', 12), + general: buildGeneralRow({ + ...general, + penalty: {}, + }), + }) + ); + await expect( + allowedCaller.turns.reserved.setNation({ + generalId: general.id, + turnIndex: 0, + action: 'che_포상', + args: {}, + expectedRevision: 0, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); }); it('refills user killturn after a successful nation reservation and invalidates its readers', async () => { diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index f67efda8..b4ebe304 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -524,26 +524,36 @@ const requestReservedGeneral = (accessToken: string | undefined, idempotencyKey: idempotencyKey, }); -const requestReservedNation = (accessToken: string, idempotencyKey: string, targetGeneralId: number) => +const requestReservedNation = ( + accessToken: string, + idempotencyKey: string, + targetGeneralId: number, + command: { action: string; args: unknown } = { action: '휴식', args: {} } +) => requestTrpc('turns.reserved.setNation', { method: 'POST', input: { generalId: targetGeneralId, turnIndex: 0, - action: '휴식', - args: {}, + action: command.action, + args: command.args, expectedRevision: 0, }, accessToken, idempotencyKey, }); -const requestReservedNationBulk = (accessToken: string, idempotencyKey: string, targetGeneralId: number) => +const requestReservedNationBulk = ( + accessToken: string, + idempotencyKey: string, + targetGeneralId: number, + command: { action: string; args: unknown } = { action: '휴식', args: {} } +) => requestTrpc('turns.reserved.setNationBulk', { method: 'POST', input: { generalId: targetGeneralId, - entries: [{ turnList: [0, 1], action: '휴식', args: {} }], + entries: [{ turnList: [0, 1], action: command.action, args: command.args }], expectedRevision: 0, }, accessToken, @@ -559,11 +569,12 @@ const requestNationReservation = ( kind: NationReservationKind, accessToken: string, idempotencyKey: string, - targetGeneralId = generalId + targetGeneralId = generalId, + command: { action: string; args: unknown } = { action: '휴식', args: {} } ) => kind === 'single' - ? requestReservedNation(accessToken, idempotencyKey, targetGeneralId) - : requestReservedNationBulk(accessToken, idempotencyKey, targetGeneralId); + ? requestReservedNation(accessToken, idempotencyKey, targetGeneralId, command) + : requestReservedNationBulk(accessToken, idempotencyKey, targetGeneralId, command); const ownershipDenialCases = [ { @@ -680,7 +691,11 @@ integration('game API security over HTTP transport', () => { }, ], }); - if ((await db.worldState.count()) === 0) { + const existingWorlds = await db.worldState.findMany({ + select: { id: true, scenarioCode: true }, + orderBy: { id: 'asc' }, + }); + if (existingWorlds.length === 0) { await db.worldState.create({ data: { id: fixtureWorldId, @@ -693,11 +708,23 @@ integration('game API security over HTTP transport', () => { }, }); createdFixtureWorld = true; + } else if ( + existingWorlds.length === 1 && + existingWorlds[0]?.id === fixtureWorldId && + existingWorlds[0].scenarioCode === 'security-http' + ) { + // A previously interrupted run may leave our own fixture row. It + // remains owned by this suite and is removed during teardown. + createdFixtureWorld = true; + } else { + throw new Error( + `security transport fixture requires an empty schema or its owned world row, got ${JSON.stringify(existingWorlds)}` + ); } const reservationWorlds = await db.worldState.findMany({ select: { id: true } }); - if (reservationWorlds.length !== 1 || !reservationWorlds[0]) { + if (reservationWorlds.length !== 1 || reservationWorlds[0]?.id !== fixtureWorldId) { throw new Error( - `security transport fixture requires exactly one world row, got ${reservationWorlds.length}` + `security transport fixture requires world ${fixtureWorldId}, got ${JSON.stringify(reservationWorlds)}` ); } reservationWorldId = reservationWorlds[0].id; @@ -1136,7 +1163,10 @@ integration('game API security over HTTP transport', () => { data: { meta: { killturn: 12 } }, }); - const result = await requestNationReservation(kind, accessToken, idempotencyKey); + const result = await requestNationReservation(kind, accessToken, idempotencyKey, generalId, { + action: 'che_포상', + args: {}, + }); expect(result.response.status).toBe(412); expect(result.body).toMatchObject({