fix: purify nation-authored HTML at server boundaries

This commit is contained in:
2026-07-31 06:41:09 +00:00
parent 71ec02d091
commit 243f58be9a
16 changed files with 898 additions and 55 deletions
+66
View File
@@ -32,6 +32,7 @@ type FixtureState = {
dieOnPrestartInputs?: Array<Record<string, unknown>>;
generalMeQueries?: number;
generalLogQueries?: number;
nationNoticeInput?: string;
settingMutations: Array<Record<string, unknown>>;
accessPages: string[];
};
@@ -170,6 +171,15 @@ const install = async (page: Page, state: FixtureState) => {
available: false,
availableAt: state.dieOnPrestartAvailableAt ?? null,
});
if (operation === 'general.getFrontStatus')
return response({
onlineUserCount: 1,
onlineNations: '【위】',
onlineGenerals: '검증장수',
nationNotice: state.nationNoticeInput ?? '',
lastExecuted: '2026-01-01T00:00:00.000Z',
latestVote: null,
});
if (operation === 'world.getState')
return response({
currentYear: 185,
@@ -190,6 +200,36 @@ const install = async (page: Page, state: FixtureState) => {
autorun_user: {},
},
});
if (operation === 'world.getMapLayout')
return response({ mapName: 'che', cityList: [], regionMap: {}, levelMap: {} });
if (operation === 'world.getMap')
return response({
year: 185,
month: 1,
startYear: 180,
cityList: [],
nationList: [],
myCity: 1,
myNation: 1,
});
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation')
return response({ turns: [], revision: 0 });
if (operation === 'messages.getRecent')
return response({
private: [],
national: [],
public: [],
diplomacy: [],
sequence: -1,
hasMore: { private: false, national: false, public: false, diplomacy: false },
latestRead: { private: 0, national: 0, public: 0, diplomacy: 0 },
canRespondDiplomacy: false,
});
if (operation === 'messages.getContacts') return response({ nation: [] });
if (operation === 'general.getRecentRecords') return response({ global: [], general: [], history: [] });
if (operation === 'board.getAccess') return response({ permission: 4, canMeeting: true, canSecret: true });
if (operation === 'tournament.getState') return response({ stage: 0 });
if (operation === 'public.getTraffic')
return response({
history: [
@@ -273,6 +313,32 @@ const install = async (page: Page, state: FixtureState) => {
});
};
test('정화된 국가 방침은 실행 가능한 속성 없이 Chromium에 표시된다', async ({ page }) => {
const state: FixtureState = {
permission: 'head',
myset: 0,
nationNoticeInput: [
'<p data-flip="horizontal" style="color:#00ffff">안전한 방침</p>',
'<img src="/image/icons/default.jpg" />',
'<a>위험 링크</a>',
].join(''),
settingMutations: [],
accessPages: [],
};
await install(page, state);
await page.goto('');
const notice = page.locator('.nation-notice-body');
await expect(notice).toContainText('안전한 방침');
await expect(notice.locator('[data-flip="horizontal"]')).toHaveCSS('color', 'rgb(0, 255, 255)');
await expect(notice.locator('script, svg, [onerror], [onload], [onclick]')).toHaveCount(0);
await expect(notice.locator('a', { hasText: '위험 링크' })).not.toHaveAttribute('href');
await expect
.poll(() => page.evaluate(() => (globalThis as typeof globalThis & { __nationXss?: number }).__nationXss))
.toBeUndefined();
});
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
await install(page, state);
+73 -10
View File
@@ -10,8 +10,19 @@ type FixtureState = {
failPersonnelLoad?: boolean;
rate: number;
appointedGeneralId?: number;
noticeMutationInput?: string;
scoutMutationInput?: string;
};
type TrpcRequestPayload = {
json?: Record<string, unknown>;
input?: { json?: Record<string, unknown> };
};
const purifiedNoticeResponse =
'<p data-flip="horizontal" style="color:#00ffff">서버 정화 방침</p><img src="/image/icons/default.jpg" alt="default.jpg" />';
const purifiedScoutResponse = '<strong>서버 정화 임관문</strong><a>위험 링크</a>';
const artifactRoot = process.env.OFFICE_PARITY_ARTIFACT_DIR ? resolve(process.env.OFFICE_PARITY_ARTIFACT_DIR) : null;
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const imageRoots = [
@@ -190,7 +201,17 @@ const installFixture = async (page: Page, state: FixtureState) => {
);
await page.route('**/che/api/trpc/**', async (route) => {
const operations = operationName(route).split(',');
const results = operations.map((operation) => {
const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
const requestBody =
rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record<string, unknown>) : {};
const results = operations.map((operation, operationIndex) => {
const rawPayload =
requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined);
const payload =
rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined;
const jsonInput =
payload?.json ?? payload?.input?.json ?? (payload as Record<string, unknown> | undefined) ?? {};
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '조조' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'nation.getPersonnelInfo') {
@@ -212,15 +233,15 @@ const installFixture = async (page: Page, state: FixtureState) => {
state.rate = 25;
return response({ ok: true });
}
if (
[
'nation.setNotice',
'nation.setScoutMsg',
'nation.setBill',
'nation.setSecretLimit',
'nation.setBlockScout',
].includes(operation)
)
if (operation === 'nation.setNotice') {
state.noticeMutationInput = typeof jsonInput.msg === 'string' ? jsonInput.msg : undefined;
return response({ ok: true, msg: purifiedNoticeResponse });
}
if (operation === 'nation.setScoutMsg') {
state.scoutMutationInput = typeof jsonInput.msg === 'string' ? jsonInput.msg : undefined;
return response({ ok: true, msg: purifiedScoutResponse });
}
if (['nation.setBill', 'nation.setSecretLimit', 'nation.setBlockScout'].includes(operation))
return response({ ok: true });
if (operation === 'nation.setBlockWar') return response({ availableCnt: 4 });
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
@@ -426,3 +447,45 @@ test('finance enforces edit permissions and preserves the old value across an AP
await expect(readOnly.locator('.policy-cell').getByRole('button', { name: '변경' })).toHaveCount(0);
await expect(readOnly.getByRole('checkbox', { name: '전쟁 금지' })).toBeDisabled();
});
test('finance adopts the server-purified notice and scout message before rendering the saved preview', async ({
page,
}) => {
const state: FixtureState = { role: 'head', rate: 20 };
await installFixture(page, state);
await gotoOffice(page, 'nation/finance');
const dirtyNotice =
'<script>globalThis.__nationNoticeXss=1</script><img src=x onerror="globalThis.__nationNoticeXss=2"><p data-flip="horizontal" style="color:#00ffff">원문</p>';
await page.getByRole('button', { name: '국가방침 수정' }).click();
await page.getByRole('textbox', { name: '국가 방침' }).fill(dirtyNotice);
await page.locator('#notice-form').getByRole('button', { name: '저장' }).click();
const noticePreview = page.locator('#notice-form .message-preview');
await expect(noticePreview).toContainText('서버 정화 방침');
expect(state.noticeMutationInput).toBe(dirtyNotice);
await expect(noticePreview.locator('[data-flip="horizontal"]')).toHaveCSS('color', 'rgb(0, 255, 255)');
await expect(noticePreview.locator('script, svg, [onerror], [onload], [onclick]')).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() => (globalThis as typeof globalThis & { __nationNoticeXss?: number }).__nationNoticeXss)
)
.toBeUndefined();
const dirtyScout =
'<svg onload="globalThis.__nationScoutXss=1"></svg><a href="javascript:alert(1)" onclick="globalThis.__nationScoutXss=2">원문</a>';
await page.getByRole('button', { name: '임관 권유문 수정' }).click();
await page.getByRole('textbox', { name: '임관 권유' }).fill(dirtyScout);
await page.locator('#scout-message-form').getByRole('button', { name: '저장' }).click();
const scoutPreview = page.locator('#scout-message-form .message-preview');
await expect(scoutPreview).toContainText('서버 정화 임관문');
expect(state.scoutMutationInput).toBe(dirtyScout);
await expect(scoutPreview.locator('a', { hasText: '위험 링크' })).not.toHaveAttribute('href');
await expect(scoutPreview.locator('script, svg, [onerror], [onload], [onclick]')).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() => (globalThis as typeof globalThis & { __nationScoutXss?: number }).__nationScoutXss)
)
.toBeUndefined();
});
@@ -141,8 +141,10 @@ const saveNationMsg = async () => {
if (!editable.value) return;
errorMessage.value = null;
try {
await trpc.nation.setNotice.mutate({ msg: nationMsg.value });
originalNationMsg.value = nationMsg.value;
const result = await trpc.nation.setNotice.mutate({ msg: nationMsg.value });
nationMsg.value = result.msg;
originalNationMsg.value = result.msg;
editor.value?.commands.setContent(result.msg || '');
editingNationMsg.value = false;
editor.value?.setEditable(false);
} catch (err) {
@@ -63,15 +63,21 @@ const resolveDiplomacyEnd = (term: number | null): string => {
const formatDiplomacyTerm = (term: number | null): string => (term ? `${term}개월` : '-');
const diplomacyInfo = (nation: NationEntry) => resolveDiplomacyInfo(nation.diplomacy.state);
const mutation = async (action: () => Promise<unknown>, message: string, rollback?: () => void) => {
const mutation = async <T,>(
action: () => Promise<T>,
message: string,
rollback?: () => void
): Promise<T | undefined> => {
error.value = null;
status.value = null;
try {
await action();
const result = await action();
status.value = message;
return result;
} catch (err) {
rollback?.();
error.value = resolveErrorMessage(err);
return undefined;
}
};
@@ -85,9 +91,13 @@ const rollbackNationMsg = () => {
editingNationMsg.value = false;
};
const saveNationMsg = async () => {
await mutation(() => trpc.nation.setNotice.mutate({ msg: nationMsgDraft.value }), '국가 방침을 변경했습니다.');
if (!error.value) {
nationMsg.value = nationMsgDraft.value;
const result = await mutation(
() => trpc.nation.setNotice.mutate({ msg: nationMsgDraft.value }),
'국가 방침을 변경했습니다.'
);
if (result) {
nationMsg.value = result.msg;
nationMsgDraft.value = result.msg;
editingNationMsg.value = false;
}
};
@@ -101,9 +111,13 @@ const rollbackScoutMsg = () => {
editingScoutMsg.value = false;
};
const saveScoutMsg = async () => {
await mutation(() => trpc.nation.setScoutMsg.mutate({ msg: scoutMsgDraft.value }), '임관 권유문을 변경했습니다.');
if (!error.value) {
scoutMsg.value = scoutMsgDraft.value;
const result = await mutation(
() => trpc.nation.setScoutMsg.mutate({ msg: scoutMsgDraft.value }),
'임관 권유문을 변경했습니다.'
);
if (result) {
scoutMsg.value = result.msg;
scoutMsgDraft.value = result.msg;
editingScoutMsg.value = false;
}
};
@@ -117,8 +117,10 @@ const saveScoutMsg = async () => {
if (!editable.value) return;
errorMessage.value = null;
try {
await trpc.nation.setScoutMsg.mutate({ msg: scoutMsg.value });
originalScoutMsg.value = scoutMsg.value;
const result = await trpc.nation.setScoutMsg.mutate({ msg: scoutMsg.value });
scoutMsg.value = result.msg;
originalScoutMsg.value = result.msg;
editor.value?.commands.setContent(result.msg || '');
editing.value = false;
editor.value?.setEditable(false);
} catch (err) {