From 2e63be87c93e9abd957b6d006476f9654fca86a9 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 16:32:34 +0000 Subject: [PATCH] fix(logs): rebuild legacy HTML safely --- app/game-api/src/battleSim/logFormatter.ts | 40 +---- app/game-api/test/battleSimProcessor.test.ts | 17 +- app/game-frontend/e2e/legacyLogHtml.spec.ts | 106 ++++++++++++ app/game-frontend/e2e/playwright.config.mjs | 1 + app/game-frontend/src/utils/formatLog.ts | 56 +------ .../e2e/legacy-log-html.spec.ts | 153 ++++++++++++++++++ .../e2e/playwright.config.mjs | 1 + app/gateway-frontend/src/utils/formatLog.ts | 27 +--- app/gateway-frontend/src/views/HomeView.vue | 2 +- packages/common/src/index.ts | 1 + .../common/src/logging/formatLegacyLogHtml.ts | 96 +++++++++++ .../test/format-legacy-log-html.test.ts | 57 +++++++ 12 files changed, 438 insertions(+), 119 deletions(-) create mode 100644 app/game-frontend/e2e/legacyLogHtml.spec.ts create mode 100644 app/gateway-frontend/e2e/legacy-log-html.spec.ts create mode 100644 packages/common/src/logging/formatLegacyLogHtml.ts create mode 100644 packages/common/test/format-legacy-log-html.test.ts diff --git a/app/game-api/src/battleSim/logFormatter.ts b/app/game-api/src/battleSim/logFormatter.ts index a93e013..a1a266d 100644 --- a/app/game-api/src/battleSim/logFormatter.ts +++ b/app/game-api/src/battleSim/logFormatter.ts @@ -1,39 +1,3 @@ -export const convertLog = (value: string, type = 1): string => { - if (!value) { - return ''; - } - let result = value; - if (type > 0) { - result = result.replaceAll('<1>', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - return result; - } +import { formatLegacyLogHtml } from '@sammo-ts/common'; - result = result.replaceAll('<1>', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - result = result.replaceAll('', ''); - return result; -}; +export const convertLog = (value: string, type = 1): string => formatLegacyLogHtml(value, { colorize: type > 0 }); diff --git a/app/game-api/test/battleSimProcessor.test.ts b/app/game-api/test/battleSimProcessor.test.ts index c66ebeb..96d3472 100644 --- a/app/game-api/test/battleSimProcessor.test.ts +++ b/app/game-api/test/battleSimProcessor.test.ts @@ -333,6 +333,21 @@ describe('battle sim processor', () => { expect(() => processBattleSimJob(payload)).toThrow('Unknown scenario effect: event_Missing'); }); + it('escapes executable markup from simulator display names while preserving legacy log structure', () => { + const payload = buildPayload('battle'); + payload.attackerGeneral.name = ''; + payload.defenderGenerals[0]!.name = ''; + payload.attackerNation.name = '국가'; + + const result = processBattleSimJob(payload); + const html = JSON.stringify(result.lastWarLog); + + expect(html).toContain('<img src=x onerror='); + expect(html).toContain('<script>globalThis.__battleLogXss=2</script>'); + expect(html).toContain('
'); + expect(html).not.toMatch(/ { const payload = buildPayload('battle'); payload.scenarioEffect = 'event_StrongAttacker'; @@ -346,7 +361,7 @@ describe('battle sim processor', () => { const result = processBattleSimJob(payload); expect(result.lastWarLog?.generalBattleDetailLog).toContain( - '적군의 전멸에 진격이 이어집니다!' + '적군의 전멸에 진격이 이어집니다!' ); }); }); diff --git a/app/game-frontend/e2e/legacyLogHtml.spec.ts b/app/game-frontend/e2e/legacyLogHtml.spec.ts new file mode 100644 index 0000000..66f73ea --- /dev/null +++ b/app/game-frontend/e2e/legacyLogHtml.spec.ts @@ -0,0 +1,106 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { expect, test, type Page, type Route } from '@playwright/test'; + +const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`; +const artifactRoot = process.env.LEGACY_LOG_ARTIFACT_DIR ? resolve(process.env.LEGACY_LOG_ARTIFACT_DIR) : null; +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const history = [ + { + id: 1, + text: '안전 강조', + }, + { + id: 2, + text: + '
정상 장수' + + '강조' + + '오염 이름
', + }, +]; + +const publicResponse = (operation: string): unknown => { + if (operation === 'public.getMapLayout') return response({ mapName: 'che', cityList: [] }); + if (operation === 'public.getCachedMap') { + 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([]); + throw new Error(`Unhandled legacy log fixture operation: ${operation}`); +}; + +const installFixture = async (page: Page) => { + await page.addInitScript(() => { + delete (globalThis as Record).__legacyLogXss; + localStorage.removeItem('sammo-game-token'); + }); + await page.route(`**${basePath}/api/trpc/**`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(operationNames(route).map(publicResponse)), + }); + }); +}; + +for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, +] as const) { + test(`renders only rebuilt legacy log markup on ${viewport.name}`, async ({ page }) => { + await installFixture(page); + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await page.goto('public'); + + const lines = page.locator('.recent-log-line'); + await expect(lines).toHaveCount(2); + 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.locator('script, img, svg, a, [onerror], [onclick], [style*="url"]')).toHaveCount(0); + await expect(lines.nth(0)).toContainText('('b'); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + color: colorSpan ? getComputedStyle(colorSpan).color : null, + fontWeight: bold ? getComputedStyle(bold).fontWeight : null, + }; + }) + ); + expect(geometry[0]?.width).toBeGreaterThan(0); + expect(geometry[0]?.color).toBe('rgb(255, 0, 0)'); + expect(Number(geometry[0]?.fontWeight)).toBeGreaterThanOrEqual(600); + + const refresh = page.getByRole('button', { name: '새로고침' }); + await refresh.focus(); + await expect(refresh).toBeFocused(); + await refresh.hover(); + + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + const name = `legacy-log-${basePath.slice(1)}-${viewport.name}`; + await writeFile( + resolve(artifactRoot, `${name}.json`), + `${JSON.stringify({ basePath, viewport, geometry }, null, 2)}\n`, + 'utf8' + ); + await page.screenshot({ path: resolve(artifactRoot, `${name}.png`), fullPage: true }); + } + }); +} diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index ba23f19..09a31bc 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -19,6 +19,7 @@ export default defineConfig({ 'inGameMenus.spec.ts', 'nationOffices.spec.ts', 'diplomacy.spec.ts', + 'legacyLogHtml.spec.ts', 'directoryLists.spec.ts', 'pastPlays.spec.ts', 'nationGeneralSecret.spec.ts', diff --git a/app/game-frontend/src/utils/formatLog.ts b/app/game-frontend/src/utils/formatLog.ts index f23dd9d..b29bda8 100644 --- a/app/game-frontend/src/utils/formatLog.ts +++ b/app/game-frontend/src/utils/formatLog.ts @@ -1,55 +1,3 @@ -const logRegex = /<([RBGMCLSODYW]1?|1|\/)>/g; +import { formatLegacyLogHtml } from '@sammo-ts/common'; -const convertMap: Record = { - R: 'color: red;', - B: 'color: blue;', - G: 'color: green;', - M: 'color: magenta;', - C: 'color: cyan;', - L: 'color: limegreen;', - S: 'color: skyblue;', - O: 'color: orangered;', - D: 'color: orangered;', - Y: 'color: yellow;', - W: 'color: white;', - 1: 'font-size: 0.9em;', -}; - -const convertMap2: Record = { - 1: 'font-size: 0.9em;', -}; - -export const formatLog = (text?: string): string => { - if (!text) { - return ''; - } - - let lastIndex = 0; - const result: string[] = []; - - for (let match = logRegex.exec(text); match !== null; match = logRegex.exec(text)) { - const partAll = match[0]; - const subPart = match[1]; - const index = match.index; - - if (lastIndex !== index) { - result.push(text.slice(lastIndex, index)); - } - - if (subPart === '/') { - result.push(''); - } else if (subPart.length === 2) { - result.push(``); - } else { - result.push(``); - } - - lastIndex = index + partAll.length; - } - - if (lastIndex !== text.length) { - result.push(text.slice(lastIndex)); - } - - return result.join(''); -}; +export const formatLog = (text?: string): string => formatLegacyLogHtml(text); diff --git a/app/gateway-frontend/e2e/legacy-log-html.spec.ts b/app/gateway-frontend/e2e/legacy-log-html.spec.ts new file mode 100644 index 0000000..3773e1d --- /dev/null +++ b/app/gateway-frontend/e2e/legacy-log-html.spec.ts @@ -0,0 +1,153 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { expect, test, type Page, type Route } from '@playwright/test'; + +const artifactRoot = process.env.LEGACY_LOG_ARTIFACT_DIR ? resolve(process.env.LEGACY_LOG_ARTIFACT_DIR) : null; +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const history = [ + { + id: 1, + text: '안전 강조', + }, + { + id: 2, + text: + '
정상 장수' + + '강조' + + '오염 이름
', + }, +]; + +const installFixture = async (page: Page) => { + await page.addInitScript(() => { + delete (globalThis as Record).__legacyLogXss; + localStorage.removeItem('sammo-session-token'); + }); + await page.route('**/gateway/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'me') return response(null); + if (operation === 'lobby.profiles') { + return response([ + { + profileName: 'che:2', + profile: 'che', + scenario: '2', + status: 'RUNNING', + apiPort: 15003, + korName: '체', + color: '#ffffff', + runtime: { + apiRunning: true, + daemonRunning: true, + auctionRunning: true, + battleSimRunning: true, + tournamentRunning: true, + }, + }, + ]); + } + throw new Error(`Unhandled gateway legacy log fixture operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'lobby.info') { + return response({ + year: 200, + month: 1, + userCnt: 1, + maxUserCnt: 100, + npcCnt: 0, + nationCnt: 1, + turnTerm: 10, + fictionMode: '가상', + starttime: '2026-07-31T00:00:00.000Z', + opentime: '2026-07-31T00:00:00.000Z', + turntime: '2026-07-31T00:10:00.000Z', + otherTextInfo: '', + isUnited: false, + selectionPoolEnabled: false, + npcPossessionEnabled: false, + myGeneral: null, + }); + } + if (operation === 'public.getMapLayout') return response({ mapName: 'che', cityList: [] }); + if (operation === 'public.getCachedMap') { + return response({ year: 200, month: 1, cityList: [], nationList: [], history }); + } + throw new Error(`Unhandled game legacy log fixture operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); +}; + +for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, +] as const) { + test(`renders only rebuilt public history markup on ${viewport.name}`, async ({ page }) => { + await installFixture(page); + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await page.goto('/gateway/'); + + const lines = page.locator('.status-history > div'); + await expect(lines).toHaveCount(2); + 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.locator('script, img, svg, a, [onerror], [onclick], [style*="url"]')).toHaveCount(0); + await expect(lines.nth(0)).toContainText('('b'); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + color: colorSpan ? getComputedStyle(colorSpan).color : null, + fontWeight: bold ? getComputedStyle(bold).fontWeight : null, + }; + }) + ); + expect(geometry[0]?.width).toBeGreaterThan(0); + expect(geometry[0]?.color).toBe('rgb(255, 0, 0)'); + expect(Number(geometry[0]?.fontWeight)).toBeGreaterThanOrEqual(600); + + const refresh = page.getByRole('button', { name: '현황 새로고침' }); + await refresh.focus(); + await expect(refresh).toBeFocused(); + await refresh.hover(); + + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + const name = `legacy-log-gateway-${viewport.name}`; + await writeFile( + resolve(artifactRoot, `${name}.json`), + `${JSON.stringify({ viewport, geometry }, null, 2)}\n`, + 'utf8' + ); + await page.screenshot({ path: resolve(artifactRoot, `${name}.png`), fullPage: true }); + } + }); +} diff --git a/app/gateway-frontend/e2e/playwright.config.mjs b/app/gateway-frontend/e2e/playwright.config.mjs index af9c6d3..523e48e 100644 --- a/app/gateway-frontend/e2e/playwright.config.mjs +++ b/app/gateway-frontend/e2e/playwright.config.mjs @@ -13,6 +13,7 @@ export default defineConfig({ 'lobby-game-auth.spec.ts', 'logout.spec.ts', 'account-icon-sync.spec.ts', + 'legacy-log-html.spec.ts', ], fullyParallel: false, workers: 1, diff --git a/app/gateway-frontend/src/utils/formatLog.ts b/app/gateway-frontend/src/utils/formatLog.ts index 0cc2bc1..f3cdf5f 100644 --- a/app/gateway-frontend/src/utils/formatLog.ts +++ b/app/gateway-frontend/src/utils/formatLog.ts @@ -1,26 +1,3 @@ -const logRegex = /<([RBGMCLSODYW]1?|1|\/)>/g; +import { formatLegacyLogHtml } from '@sammo-ts/common'; -const colorMap: Record = { - R: 'red', - B: 'blue', - G: 'green', - M: 'magenta', - C: 'cyan', - L: 'limegreen', - S: 'skyblue', - O: 'orangered', - D: 'orangered', - Y: 'yellow', - W: 'white', -}; - -export const formatLog = (text: string): string => - text.replace(logRegex, (_all, tag: string) => { - if (tag === '/') { - return '
'; - } - const color = colorMap[tag[0] ?? '']; - const small = tag.includes('1'); - const styles = [color ? `color: ${color}` : '', small ? 'font-size: 0.9em' : ''].filter(Boolean).join('; '); - return ``; - }); +export const formatLog = (text: string): string => formatLegacyLogHtml(text); diff --git a/app/gateway-frontend/src/views/HomeView.vue b/app/gateway-frontend/src/views/HomeView.vue index 7fcb4d0..304107f 100644 --- a/app/gateway-frontend/src/views/HomeView.vue +++ b/app/gateway-frontend/src/views/HomeView.vue @@ -181,7 +181,7 @@ const handlePasswordReset = async (): Promise => {
  • {{ info.turnTerm }}분 턴 서버
  • - +
    diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index f5eb819..52770a7 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -18,3 +18,4 @@ export * from './realtime/types.js'; export * from './ranking/types.js'; export * from './ranking/legacyColor.js'; export * from './auth/accountIconProjection.js'; +export * from './logging/formatLegacyLogHtml.js'; diff --git a/packages/common/src/logging/formatLegacyLogHtml.ts b/packages/common/src/logging/formatLegacyLogHtml.ts new file mode 100644 index 0000000..72a4122 --- /dev/null +++ b/packages/common/src/logging/formatLegacyLogHtml.ts @@ -0,0 +1,96 @@ +const legacyTagPattern = /^<([RBGMCLSODYW]1?|1|\/)>$/; + +const legacyStyleMap: Record = { + R: 'color: red;', + B: 'color: blue;', + G: 'color: green;', + M: 'color: magenta;', + C: 'color: cyan;', + L: 'color: limegreen;', + S: 'color: skyblue;', + O: 'color: orangered;', + D: 'color: orangered;', + Y: 'color: yellow;', + W: 'color: white;', + 1: 'font-size: 0.9em;', +}; + +const safeSpanClasses = new Set([ + 'ev_highlight', + 'ev_failed', + 'ev_notice', + 'me', + 'you', + 'name_plate', + 'crew_type', + 'name_plate_cover', + 'crew_plate', + 'remain_crew', + 'killed_plate', + 'killed_crew', + 'name', + 'war_type', + 'war_type_attack', + 'war_type_defense', + 'war_type_siege', +]); + +const escapeText = (value: string): string => + value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + +const normalizeStructuralTag = (tag: string): string | null => { + if (/^$/i.test(tag)) return ''; + if (/^<\/b\s*>$/i.test(tag)) return ''; + if (/^$/i.test(tag)) return '
    '; + if (/^<\/span\s*>$/i.test(tag)) return ''; + if (/^<\/div\s*>$/i.test(tag)) return '
    '; + + const colorSpan = tag.match(/^$/i); + if (colorSpan) return ``; + + const open = tag.match(/^<(span|div)\s+class\s*=\s*(['"])([^'"]+)\2\s*>$/i); + if (!open) return null; + + const element = open[1]!.toLowerCase(); + const classes = open[3]!.trim().split(/\s+/u); + if (element === 'div') { + return classes.length === 1 && classes[0] === 'small_war_log' ? '
    ' : null; + } + if (classes.length === 0 || classes.some((className) => !safeSpanClasses.has(className))) { + return null; + } + return ``; +}; + +export type FormatLegacyLogHtmlOptions = { + colorize?: boolean; +}; + +/** + * Converts Ref's custom color markers while rebuilding only the small set of + * HTML structures emitted by legacy log writers. Everything else is text. + */ +export const formatLegacyLogHtml = (value?: string | null, options: FormatLegacyLogHtmlOptions = {}): string => { + if (!value) return ''; + + const colorize = options.colorize ?? true; + return value + .split(/(<[^>]*>)/gu) + .map((part) => { + if (!part.startsWith('<') || !part.endsWith('>')) { + return escapeText(part); + } + + const legacy = part.match(legacyTagPattern)?.[1]; + if (legacy) { + if (!colorize) return ''; + if (legacy === '/') return ''; + const colorCode = legacy === '1' ? null : legacy[0]!; + const small = legacy === '1' || legacy.endsWith('1'); + return ``; + } + + return normalizeStructuralTag(part) ?? escapeText(part); + }) + .join(''); +}; diff --git a/packages/common/test/format-legacy-log-html.test.ts b/packages/common/test/format-legacy-log-html.test.ts new file mode 100644 index 0000000..09d7fad --- /dev/null +++ b/packages/common/test/format-legacy-log-html.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { formatLegacyLogHtml } from '../src/logging/formatLegacyLogHtml.js'; + +describe('formatLegacyLogHtml', () => { + it('converts legacy colors and preserves intentional emphasis and line breaks', () => { + expect(formatLegacyLogHtml('위험
    작게<1>작게만')).toBe( + '위험
    작게작게만' + ); + expect(formatLegacyLogHtml('색상', { colorize: false })).toBe('색상'); + }); + + it('escapes executable and unknown markup instead of passing it to v-html', () => { + const dirty = [ + '', + '', + 'SVG', + '이름', + '링크', + ].join(''); + const clean = formatLegacyLogHtml(dirty); + + expect(clean).toContain('<script>globalThis.__logXss=1</script>'); + expect(clean).toContain('<img src=x onerror="globalThis.__logXss=2">'); + expect(clean).toContain('<span class="name" onclick="alert(2)">이름
    '); + expect(clean).not.toMatch(/ { + const source = + '
    장수' + + '강조' + + "실패주의
    "; + expect(formatLegacyLogHtml(source)).toBe( + '
    장수' + + '강조' + + '실패주의
    ' + ); + expect(formatLegacyLogHtml('미허용')).toBe( + '<span class="unknown">미허용' + ); + }); + + it('keeps the fixed hex color form emitted by flag-change logs but rejects other inline CSS', () => { + expect(formatLegacyLogHtml("국기")).toBe( + '국기' + ); + expect(formatLegacyLogHtml("오염")).toBe( + "<span style='color:red;background:url(javascript:x)'>오염" + ); + }); + + it('escapes dynamic text and incomplete tags', () => { + expect(formatLegacyLogHtml('A&B