diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index 723e5e68..946ade00 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -472,8 +472,10 @@ const sanitizeLegacyTurnArgString = (value: string): string => { if (stripped === '' || stripped === '0') { return ''; } + // Core의 Vue text와 formatLegacyLogHtml이 출력 시 &를 escape한다. + // Ref의 입력 단계 & 변환까지 적용하면 국호/예약 인자에 entity가 저장된다. + // <, > 중화는 로그의 legacy markup과 사용자 입력을 구분하기 위해 유지한다. return stripped - .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replace(/^[\p{Z}\p{C}]+|[\p{Z}\p{C}]+$/gu, ''); diff --git a/app/game-api/test/commandInput.test.ts b/app/game-api/test/commandInput.test.ts index 48dd9b83..385f5ec8 100644 --- a/app/game-api/test/commandInput.test.ts +++ b/app/game-api/test/commandInput.test.ts @@ -152,7 +152,29 @@ describe('turn command argument input', () => { ); }); - it('recursively sanitizes reserved command strings like Ref before command parsing', async () => { + it('preserves ampersands through reservation parsing and repeated sanitization', async () => { + const args = { nationName: '풍년F&B', nested: ['A&B', '&', '<img>'] }; + expect(sanitizeReservedTurnArgs(args)).toEqual(args); + expect(sanitizeReservedTurnArgs(sanitizeReservedTurnArgs(args))).toEqual(args); + await expect(parseReservedTurnArgs('nation', 'che_국호변경', args)).resolves.toEqual({ + nationName: '풍년F&B', + }); + await expect( + parseReservedTurnArgs('general', 'che_건국', { + nationName: '풍년F&B', + nationType: 'che_도적', + colorType: 0, + }) + ).resolves.toMatchObject({ nationName: '풍년F&B' }); + // 반각 18자 판정에 &의 저장 길이가 끼어들지 않는다. + const maxWidthName = '가나다라마바사아&B'; + expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: maxWidthName })).not.toThrow(); + await expect(parseReservedTurnArgs('nation', 'che_국호변경', { nationName: maxWidthName })).resolves.toEqual({ + nationName: maxWidthName, + }); + }); + + it('retains Ref character removal and angle-bracket neutralization before command parsing', async () => { expect( sanitizeReservedTurnArgs({ nationName: ' <신-국># ', diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index ce457cfe..3096da40 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -1110,7 +1110,10 @@ describe('appRouter', () => { expect(nationWrites).toHaveLength(2); }); - it('stores Ref-sanitized nation command strings in the reserved queue', async () => { + it.each([ + [' <신-국># ', '<신국>'], + ['풍년F&B', '풍년F&B'], + ])('stores the sanitized nation name %s in the reserved queue as %s', async (input, stored) => { const general = buildGeneralRow({ id: 19, nationId: 3, officerLevel: 12 }); const nationWrites: unknown[] = []; const caller = appRouter.createCaller( @@ -1121,16 +1124,14 @@ describe('appRouter', () => { generalId: general.id, turnIndex: 0, action: 'che_국호변경', - args: { nationName: ' <신-국># ' }, + args: { nationName: input }, expectedRevision: 0, }); - expect(response.turns[0]?.args).toEqual({ nationName: '<신국>' }); + expect(response.turns[0]?.args).toEqual({ nationName: stored }); expect(nationWrites).toHaveLength(1); expect(nationWrites[0]).toMatchObject({ - data: expect.arrayContaining([ - expect.objectContaining({ turnIdx: 0, arg: { nationName: '<신국>' } }), - ]), + data: expect.arrayContaining([expect.objectContaining({ turnIdx: 0, arg: { nationName: stored } })]), }); }); diff --git a/app/game-frontend/e2e/legacyLogHtml.spec.ts b/app/game-frontend/e2e/legacyLogHtml.spec.ts index 597192bf..e22e566d 100644 --- a/app/game-frontend/e2e/legacyLogHtml.spec.ts +++ b/app/game-frontend/e2e/legacyLogHtml.spec.ts @@ -27,6 +27,10 @@ const history = [ id: 3, text: '이번 수입은 금 158입니다.', }, + { + id: 4, + text: '풍년F&B <img src=x onerror=globalThis.__legacyLogXss=4>', + }, ]; const publicResponse = (operation: string): unknown => { @@ -36,7 +40,12 @@ const publicResponse = (operation: string): unknown => { return response({ year: 200, month: 1, cityList: [], nationList: [], history }); } if (operation === 'public.getWorldTrend') return response({ year: 200, month: 1, turnTerm: 10 }); - if (operation === 'public.getNationList' || operation === 'public.getGeneralList') return response([]); + if (operation === 'public.getNationList') { + return response([ + { id: 13, name: '풍년F&B', color: '#330000', level: 1, capitalCityId: 0, generalCount: 1, cityCount: 0 }, + ]); + } + if (operation === 'public.getGeneralList') return response([]); throw new Error(`Unhandled legacy log fixture operation: ${operation}`); }; @@ -64,12 +73,15 @@ for (const viewport of [ await page.goto('public'); const lines = page.locator('.recent-log-line'); - await expect(lines).toHaveCount(3); + await expect(lines).toHaveCount(4); await expect(lines.nth(0).locator('b')).toHaveText('안전 강조'); await expect(lines.nth(1).locator('.small_war_log .war_type_attack')).toHaveText('→'); await expect(lines.nth(1).locator('.ev_highlight')).toHaveText('강조'); await expect(lines.nth(2)).toHaveText('이번 수입은 금 158입니다.'); await expect(lines.nth(2)).not.toContainText('157.5'); + await expect(lines.nth(3).locator('b')).toHaveText('풍년F&B'); + await expect(lines.nth(3)).toContainText('<img'); + await expect(page.getByRole('cell', { name: '풍년F&B', exact: true })).toBeVisible(); await expect(lines.locator('script, img, svg, a, [onerror], [onclick], [style*="url"]')).toHaveCount(0); await expect(lines.nth(0)).toContainText(' { expect(changeNationNameArgsSchema.safeParse({ nationName: '가나다라마바사아자차' }).success).toBe(false); }); - it('changes nation name', () => { + it.each(['NewName', '풍년F&B'])('changes nation name to %s without HTML encoding', (name) => { const nation = buildNation(1, 'OldName'); const general = buildGeneral(1, 1, 1); const definition = new ChangeNationNameAction(); @@ -408,12 +408,12 @@ describe('Nation Actions', () => { addLog: () => {}, }; - const resolution = definition.resolve(context as any, { nationName: 'NewName' }); + const resolution = definition.resolve(context as any, { nationName: name }); expect(resolution.effects).toContainEqual( expect.objectContaining({ type: 'nation:patch', - patch: expect.objectContaining({ name: 'NewName' }), + patch: expect.objectContaining({ name }), }) ); });