diff --git a/app/game-api/src/router/diplomacy/index.ts b/app/game-api/src/router/diplomacy/index.ts index c32ea05..843c412 100644 --- a/app/game-api/src/router/diplomacy/index.ts +++ b/app/game-api/src/router/diplomacy/index.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { asRecord } from '@sammo-ts/common'; import type { GamePrisma } from '@sammo-ts/infra'; +import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js'; import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; import { assertNationAccess, resolveNationPermission } from '../nation/shared.js'; @@ -56,7 +57,8 @@ export const diplomacyRouter = router({ const src = asRecord(aux.src); const dest = asRecord(aux.dest); const stateOpt = typeof aux.state_opt === 'string' ? aux.state_opt : null; - const detail = permission < 3 && letter.textDetail ? '(권한이 부족합니다)' : letter.textDetail; + const detail = + permission < 3 && letter.textDetail ? '(권한이 부족합니다)' : purifyDiplomacyHtml(letter.textDetail); const reason = asRecord(aux.reason); return { @@ -80,7 +82,7 @@ export const diplomacyRouter = router({ prevId: letter.prevId, state: mapLetterState(letter.state), stateOpt, - brief: letter.textBrief, + brief: purifyDiplomacyHtml(letter.textBrief), detail, date: letter.date.toISOString(), reason: { @@ -207,8 +209,8 @@ export const diplomacyRouter = router({ destNationId: destNation.id, prevId, state: 'PROPOSED', - textBrief: input.brief, - textDetail: input.detail, + textBrief: purifyDiplomacyHtml(input.brief), + textDetail: purifyDiplomacyHtml(input.detail), srcSignerId: me.id, aux: aux as GamePrisma.InputJsonValue, }, diff --git a/app/game-api/src/security/diplomacyHtml.ts b/app/game-api/src/security/diplomacyHtml.ts new file mode 100644 index 0000000..78bb676 --- /dev/null +++ b/app/game-api/src/security/diplomacyHtml.ts @@ -0,0 +1,68 @@ +import sanitizeHtml from 'sanitize-html'; + +const options: sanitizeHtml.IOptions = { + allowedTags: [ + 'p', + 'br', + 'strong', + 'b', + 'em', + 'i', + 'u', + 's', + 'strike', + 'blockquote', + 'ul', + 'ol', + 'li', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'code', + 'pre', + 'hr', + 'a', + 'img', + ], + allowedAttributes: { + a: ['href', 'target', 'rel', 'title'], + img: ['src', 'alt', 'title', 'width', 'height'], + }, + allowedSchemes: ['http', 'https', 'mailto', 'tel'], + allowedSchemesByTag: { + a: ['http', 'https', 'mailto', 'tel'], + img: ['http', 'https'], + }, + allowProtocolRelative: false, + transformTags: { + a: (tagName, attribs) => { + const target = attribs.target === '_blank' || attribs.target === '_self' ? attribs.target : undefined; + const { target: _target, rel: _rel, ...rest } = attribs; + return { + tagName, + attribs: target + ? { + ...rest, + target, + ...(target === '_blank' ? { rel: 'noopener noreferrer nofollow' } : {}), + } + : rest, + }; + }, + }, + exclusiveFilter: (frame) => frame.tag === 'img' && !frame.attribs.src, +}; + +/** + * Canonicalizes the HTML emitted by the diplomacy Tiptap editors. Writes are + * purified before persistence and reads are purified again for legacy rows. + */ +export const purifyDiplomacyHtml = (value: string | null | undefined): string => { + if (!value) { + return ''; + } + return sanitizeHtml(value, options); +}; diff --git a/app/game-api/test/diplomacyHtml.test.ts b/app/game-api/test/diplomacyHtml.test.ts new file mode 100644 index 0000000..5c56b1e --- /dev/null +++ b/app/game-api/test/diplomacyHtml.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { purifyDiplomacyHtml } from '../src/security/diplomacyHtml.js'; + +describe('diplomacy HTML purification', () => { + it('removes executable markup, unsafe URLs, SVG, MathML, styles, and event handlers', () => { + const dirty = [ + '', + '', + '', + '위험 링크', + 'SVG', + '', + '

안전 본문

', + ].join(''); + + const clean = purifyDiplomacyHtml(dirty); + + expect(clean).toBe('위험 링크SVG

안전 본문

'); + expect(clean).not.toMatch(/script|onerror|onclick|javascript:|style=|class=| { + const source = [ + '

외교 제안

', + '

굵게 기울임 밑줄 취소

', + '

인용

', + '', + '
  1. 번호

', + '링크', + '문서', + ].join(''); + + expect(purifyDiplomacyHtml(source)).toBe( + [ + '

외교 제안

', + '

굵게 기울임 밑줄 취소

', + '

인용

', + '', + '
  1. 번호

', + '링크', + '문서', + ].join('') + ); + }); + + it('is idempotent and removes protocol-relative external resources', () => { + const first = purifyDiplomacyHtml( + '

본문

링크' + ); + expect(first).toBe('

본문

링크'); + expect(purifyDiplomacyHtml(first)).toBe(first); + }); +}); diff --git a/app/game-api/test/diplomacyHtmlTransport.integration.test.ts b/app/game-api/test/diplomacyHtmlTransport.integration.test.ts new file mode 100644 index 0000000..64cccf1 --- /dev/null +++ b/app/game-api/test/diplomacyHtmlTransport.integration.test.ts @@ -0,0 +1,222 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { + createGamePostgresConnector, + createRedisConnector, + resolveRedisConfigFromEnv, + type GamePrismaClient, + type RedisConnector, +} from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { createGameApiServer } from '../src/server.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl || !process.env.REDIS_URL); +const profileId = process.env.POSTGRES_SCHEMA ?? 'public'; +const profileName = `che:diplomacy-html-${process.pid}`; +const userId = `diplomacy-html-user-${process.pid}`; +const fixtureId = 920_000 + (process.pid % 50_000); +const foreignNationId = fixtureId + 1; +const secret = 'diplomacy-html-http-secret'; +const redisPrefix = `sammo:diplomacy-html:${process.pid}`; +const envKeys = [ + 'PROFILE', + 'SCENARIO', + 'GAME_PROFILE_NAME', + 'GAME_API_HOST', + 'GAME_API_PORT', + 'GAME_TOKEN_SECRET', + 'GATEWAY_REDIS_PREFIX', + 'GAME_UPLOAD_DIR', + 'DATABASE_URL', +] as const; +const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]])); + +type RunningServer = Awaited>; + +let server: RunningServer | null = null; +let baseUrl = ''; +let uploadDir = ''; +let db: GamePrismaClient; +let disconnectDb: (() => Promise) | null = null; +let redis: RedisConnector | null = null; +let accessToken = ''; +let legacyLetterId = 0; + +const restoreEnv = (): void => { + for (const [key, value] of originalEnv) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}; + +const deleteProfileRedisKeys = async (): Promise => { + if (!redis) return; + for await (const keys of redis.client.scanIterator({ MATCH: `sammo:game:*:${profileName}:*`, COUNT: 100 })) { + if (keys.length > 0) await redis.client.del(keys); + } +}; + +const cleanup = async (): Promise => { + await db.diplomacyLetter.deleteMany({ + where: { + OR: [ + { srcNationId: fixtureId }, + { destNationId: fixtureId }, + { srcNationId: foreignNationId }, + { destNationId: foreignNationId }, + ], + }, + }); + await db.generalAccessLog.deleteMany({ where: { generalId: fixtureId } }); + await db.general.deleteMany({ where: { id: fixtureId } }); + await db.nation.deleteMany({ where: { id: { in: [fixtureId, foreignNationId] } } }); + await db.worldState.deleteMany({ where: { scenarioCode: 'diplomacy-html' } }); +}; + +integration('diplomacy HTML purification over PostgreSQL and HTTP transport', () => { + beforeAll(async () => { + uploadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-diplomacy-html-http-')); + process.env.PROFILE = profileId; + process.env.SCENARIO = 'diplomacy-html'; + process.env.GAME_PROFILE_NAME = profileName; + process.env.GAME_API_HOST = '127.0.0.1'; + process.env.GAME_API_PORT = '0'; + process.env.GAME_TOKEN_SECRET = secret; + process.env.GATEWAY_REDIS_PREFIX = redisPrefix; + process.env.GAME_UPLOAD_DIR = uploadDir; + process.env.DATABASE_URL = databaseUrl; + + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + disconnectDb = () => connector.disconnect(); + await cleanup(); + + await db.worldState.create({ + data: { + scenarioCode: 'diplomacy-html', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + meta: {}, + }, + }); + await db.nation.createMany({ + data: [ + { id: fixtureId, name: '정화국', color: '#00ffff' }, + { id: foreignNationId, name: '상대국', color: '#ff0000' }, + ], + }); + await db.general.create({ + data: { + id: fixtureId, + userId, + name: '정화외교관', + nationId: fixtureId, + officerLevel: 12, + turnTime: new Date('2026-07-31T00:00:00.000Z'), + }, + }); + const legacyLetter = await db.diplomacyLetter.create({ + data: { + srcNationId: fixtureId, + destNationId: foreignNationId, + state: 'PROPOSED', + textBrief: '

과거 공개

', + textDetail: '과거 기밀', + srcSignerId: fixtureId, + aux: { + src: { nationName: '정화국', nationColor: '#00ffff', generalId: fixtureId }, + dest: { nationName: '상대국', nationColor: '#ff0000' }, + }, + }, + }); + legacyLetterId = legacyLetter.id; + + redis = createRedisConnector(resolveRedisConfigFromEnv()); + await redis.connect(); + const store = new RedisAccessTokenStore(redis.client, profileName); + const payload: GameSessionTokenPayload = { + version: 1, + profile: profileName, + issuedAt: new Date(Date.now() - 1_000).toISOString(), + expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), + sessionId: `diplomacy-html-session-${process.pid}`, + user: { + id: userId, + username: 'diplomacy-html-user', + displayName: 'Diplomacy HTML User', + roles: ['user'], + createdAt: '2026-07-31T00:00:00.000Z', + }, + sanctions: {}, + }; + const created = await store.create(payload); + if (!created) throw new Error('failed to seed diplomacy HTML access token'); + accessToken = created.accessToken; + + server = await createGameApiServer(); + baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port }); + }, 30_000); + + afterAll(async () => { + await server?.app.close(); + if (db) await cleanup(); + await disconnectDb?.(); + await deleteProfileRedisKeys(); + await redis?.disconnect(); + if (uploadDir) await fs.rm(uploadDir, { recursive: true, force: true }); + restoreEnv(); + }, 30_000); + + it('purifies contaminated stored rows before HTTP serialization', async () => { + const response = await fetch(`${baseUrl}/trpc/diplomacy.getLetters`, { + headers: { authorization: `Bearer ${accessToken}` }, + }); + const body = (await response.json()) as { + result?: { data?: { letters?: Array<{ id: number; brief: string; detail: string }> } }; + }; + + expect(response.status, JSON.stringify(body)).toBe(200); + expect(body.result?.data?.letters?.find(({ id }) => id === legacyLetterId)).toMatchObject({ + brief: '

과거 공개

', + detail: '과거 기밀', + }); + expect(JSON.stringify(body)).not.toMatch(/onerror| { + const response = await fetch(`${baseUrl}/trpc/diplomacy.sendLetter`, { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + destNationId: foreignNationId, + brief: '

신규 공개

', + detail: '
  1. 조건
자료', + }), + }); + const body = (await response.json()) as { result?: { data?: { id?: number } } }; + + expect(response.status, JSON.stringify(body)).toBe(200); + const createdId = body.result?.data?.id; + expect(createdId).toBeTypeOf('number'); + const stored = await db.diplomacyLetter.findUniqueOrThrow({ where: { id: createdId } }); + expect(stored.textBrief).toBe('

신규 공개

'); + expect(stored.textDetail).toBe( + '
  1. 조건
자료' + ); + }); +}); diff --git a/app/game-api/test/diplomacyRouter.test.ts b/app/game-api/test/diplomacyRouter.test.ts new file mode 100644 index 0000000..d0a00bc --- /dev/null +++ b/app/game-api/test/diplomacyRouter.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { appRouter } from '../src/router.js'; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:default', + issuedAt: '2026-07-31T00:00:00.000Z', + expiresAt: '2026-08-01T00:00:00.000Z', + sessionId: 'diplomacy-html-session', + user: { + id: 'diplomacy-user', + username: 'diplomacy-user', + displayName: '외교 사용자', + roles: [], + }, + sanctions: {}, +}; + +const buildGeneral = (officerLevel: number): GeneralRow => ({ + id: 1, + userId: auth.user.id, + name: '외교담당', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: null, + imageServer: 0, + leadership: 50, + strength: 50, + intel: 50, + injury: 0, + experience: 0, + dedication: 0, + officerLevel, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-07-31T00:00:00.000Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: {}, + penalty: {}, + createdAt: new Date('2026-07-31T00:00:00.000Z'), + updatedAt: new Date('2026-07-31T00:00:00.000Z'), +}); + +const storedLetter = { + id: 7, + srcNationId: 1, + destNationId: 2, + prevId: null, + state: 'PROPOSED', + textBrief: '

공개

', + textDetail: '기밀링크', + date: new Date('2026-07-31T00:00:00.000Z'), + aux: { + src: { nationName: '위', nationColor: '#0000ff', generalId: 1, generalName: '외교담당' }, + dest: { nationName: '촉', nationColor: '#ff0000' }, + }, +}; + +const buildContext = (officerLevel = 12) => { + const create = vi.fn(async () => ({ id: 9 })); + const db = { + general: { + findFirst: vi.fn(async () => buildGeneral(officerLevel)), + }, + nation: { + findUnique: vi.fn(async () => ({ meta: {} })), + findMany: vi.fn(async ({ where }: { where: { id?: { in?: number[]; not?: number } } }) => { + if (where.id?.in) { + return [ + { id: 1, name: '위', color: '#0000ff' }, + { id: 2, name: '촉', color: '#ff0000' }, + ]; + } + return [{ id: 2, name: '촉', color: '#ff0000', level: 5 }]; + }), + }, + diplomacyLetter: { + findMany: vi.fn(async () => [storedLetter]), + findFirst: vi.fn(async () => null), + create, + }, + }; + const redis = { + get: async () => null, + set: async () => null, + } as unknown as RedisConnector['client']; + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + turnDaemon: new InMemoryTurnDaemonTransport(), + battleSim: new InMemoryBattleSimTransport(), + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + redis, + accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { caller: appRouter.createCaller(context), create }; +}; + +describe('diplomacy HTML API boundary', () => { + it('purifies editor HTML before persistence', async () => { + const fixture = buildContext(); + await expect( + fixture.caller.diplomacy.sendLetter({ + destNationId: 2, + brief: '

공개

', + detail: '
  • 조건
자료', + }) + ).resolves.toEqual({ id: 9 }); + + expect(fixture.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + textBrief: '

공개

', + textDetail: + '
  • 조건
자료', + }), + }); + }); + + it('purifies legacy stored rows on every read while preserving secret redaction', async () => { + const visible = await buildContext(12).caller.diplomacy.getLetters(); + expect(visible.letters[0]).toMatchObject({ + brief: '

공개

', + detail: '기밀링크', + }); + expect(JSON.stringify(visible)).not.toMatch(/onerror|onclick|javascript:|