fix: purify nation-authored HTML at server boundaries
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user