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
+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();
});