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 = [
+ '',
+ '
',
+ '
',
+ '위험 링크',
+ '',
+ '',
+ '
안전 본문
',
+ ].join('');
+
+ const clean = purifyDiplomacyHtml(dirty);
+
+ expect(clean).toBe('위험 링크SVG
안전 본문
');
+ expect(clean).not.toMatch(/script|onerror|onclick|javascript:|style=|class=|