From 864cbfc0d020c6a33ea7882470e7c042a5cce480 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 16:47:26 +0000 Subject: [PATCH] fix(gateway): purify global notice HTML --- app/gateway-api/package.json | 2 + app/gateway-api/src/adminRouter.ts | 10 +- app/gateway-api/src/router.ts | 3 +- .../src/security/gatewayNoticeHtml.ts | 66 +++++++++++ app/gateway-api/test/adminOperations.test.ts | 68 ++++++++++- .../test/gatewayNoticeHtml.test.ts | 41 +++++++ .../e2e/gateway-notice-html.spec.ts | 106 ++++++++++++++++++ .../e2e/playwright.config.mjs | 1 + app/gateway-frontend/src/views/LobbyView.vue | 4 +- pnpm-lock.yaml | 6 + 10 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 app/gateway-api/src/security/gatewayNoticeHtml.ts create mode 100644 app/gateway-api/test/gatewayNoticeHtml.test.ts create mode 100644 app/gateway-frontend/e2e/gateway-notice-html.spec.ts diff --git a/app/gateway-api/package.json b/app/gateway-api/package.json index b513b93..184fdd4 100644 --- a/app/gateway-api/package.json +++ b/app/gateway-api/package.json @@ -24,6 +24,7 @@ "typecheck": "tsc -b" }, "devDependencies": { + "@types/sanitize-html": "2.16.1", "tsdown": "^0.18.4", "vite-tsconfig-paths": "^6.0.3", "vitest": "^4.0.16" @@ -42,6 +43,7 @@ "fastify": "^5.6.2", "pm2": "^5.4.3", "redis": "^5.10.0", + "sanitize-html": "2.17.6", "sharp": "^0.34.4", "zod": "^4.3.5" } diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index b5268a0..43c21c2 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -12,6 +12,7 @@ import { toPublicUser } from './auth/userRepository.js'; import type { AdminAuthContext } from './adminAuth.js'; import type { GatewayApiContext } from './context.js'; import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js'; +import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js'; const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES); const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES); @@ -399,7 +400,7 @@ export const adminRouter = router({ const setting = await ctx.prisma.systemSetting.findUnique({ where: { id: 1 }, }); - return { notice: setting?.notice ?? '' }; + return { notice: purifyGatewayNoticeHtml(setting?.notice) }; }), setNotice: noticeAdminProcedure .input( @@ -408,17 +409,18 @@ export const adminRouter = router({ }) ) .mutation(async ({ ctx, input }) => { + const notice = purifyGatewayNoticeHtml(input.notice); const setting = await ctx.prisma.systemSetting.upsert({ where: { id: 1 }, create: { id: 1, - notice: input.notice, + notice, }, update: { - notice: input.notice, + notice, }, }); - return { notice: setting.notice }; + return { notice: purifyGatewayNoticeHtml(setting.notice) }; }), }), users: router({ diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index 262286d..14dea5a 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -15,6 +15,7 @@ import { accountRouter } from './account/router.js'; import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js'; import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js'; import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js'; +import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js'; const zUsername = z .string() @@ -51,7 +52,7 @@ export const appRouter = router({ const setting = await ctx.prisma.systemSetting.findUnique({ where: { id: 1 }, }); - return setting?.notice ?? ''; + return purifyGatewayNoticeHtml(setting?.notice); }), profiles: procedure .input( diff --git a/app/gateway-api/src/security/gatewayNoticeHtml.ts b/app/gateway-api/src/security/gatewayNoticeHtml.ts new file mode 100644 index 0000000..ce640f1 --- /dev/null +++ b/app/gateway-api/src/security/gatewayNoticeHtml.ts @@ -0,0 +1,66 @@ +import sanitizeHtml from 'sanitize-html'; + +const safeNamedColor = + /^(?:black|silver|gray|white|maroon|red|purple|fuchsia|green|lime|olive|yellow|navy|blue|teal|aqua|orange|cyan|magenta|skyblue|orangered|limegreen|darkorange)$/i; +const safeFontColor = (value: string): boolean => /^#[0-9a-f]{3,8}$/i.test(value) || safeNamedColor.test(value); +const safeFontFace = /^[\p{L}\p{N}\s,'".-]+$/u; + +const options: sanitizeHtml.IOptions = { + allowedTags: ['br', 'b', 'strong', 'em', 'i', 'u', 's', 'strike', 'small', 'sup', 'sub', 'span', 'a', 'font'], + allowedAttributes: { + '*': ['style', 'title'], + a: ['href', 'target', 'rel', 'title', 'style'], + font: ['color', 'size', 'face', 'style', 'title'], + }, + allowedSchemes: ['http', 'https', 'mailto', 'tel'], + allowProtocolRelative: false, + allowedStyles: { + '*': { + color: [/^#[0-9a-f]{3,8}$/i, /^rgba?\([\d\s.,%]+\)$/i, safeNamedColor], + 'font-size': [ + /^\d+(?:\.\d+)?(?:px|pt|em|rem|%)$/i, + /^(?:xx-small|x-small|small|medium|large|x-large|xx-large)$/i, + ], + 'font-style': [/^(?:normal|italic|oblique)$/i], + 'font-weight': [/^(?:normal|bold|bolder|lighter|[1-9]00)$/i], + 'text-decoration': [ + /^(?:none|underline|line-through|overline)(?:\s+(?:underline|line-through|overline))*$/i, + ], + }, + }, + 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, + }; + }, + font: (tagName, attribs) => ({ + tagName, + attribs: { + ...(attribs.color && safeFontColor(attribs.color) ? { color: attribs.color } : {}), + ...(attribs.size && /^[1-7]$/.test(attribs.size) ? { size: attribs.size } : {}), + ...(attribs.face && safeFontFace.test(attribs.face) ? { face: attribs.face } : {}), + ...(attribs.style ? { style: attribs.style } : {}), + ...(attribs.title ? { title: attribs.title } : {}), + }, + }), + }, +}; + +/** + * Canonicalizes Ref-compatible gateway notice markup on both writes and reads. + * Reads are purified again so pre-existing rows cannot execute active content. + */ +export const purifyGatewayNoticeHtml = (value: string | null | undefined): string => { + if (!value) return ''; + return sanitizeHtml(value, options); +}; diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index ffa889e..674d393 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -16,6 +16,7 @@ const buildCaller = async ( adminRoles?: string[]; firstUserIsAdmin?: boolean; runtimeActionCreateError?: unknown; + initialNotice?: string; } = {} ) => { const users = createInMemoryUserRepository(); @@ -35,6 +36,7 @@ const buildCaller = async ( const operationRecords = new Map>>(); const createdRuntimeActions: Array> = []; const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = []; + let storedNotice = options.initialNotice ?? ''; const profile = { profileName: 'che:2', profile: 'che', @@ -139,12 +141,76 @@ const buildCaller = async ( }; }, }, + systemSetting: { + findUnique: async () => ({ id: 1, notice: storedNotice }), + upsert: async ({ create, update }: { create: { notice: string }; update: { notice: string } }) => { + storedNotice = update.notice ?? create.notice; + return { id: 1, notice: storedNotice }; + }, + }, } as unknown as GatewayPrismaClient, }) ); - return { caller, createdInputs, createdRuntimeActions, users, admin, flushes }; + return { + caller, + createdInputs, + createdRuntimeActions, + users, + admin, + flushes, + getStoredNotice: () => storedNotice, + setStoredNotice: (notice: string) => { + storedNotice = notice; + }, + }; }; +describe('gateway notice API', () => { + const dirtyNotice = + '점검
' + + '' + + '링크'; + + it('purifies notices before persistence and returns the canonical value', async () => { + const harness = await buildCaller(async () => { + throw new Error('not used'); + }); + + const result = await harness.caller.admin.system.setNotice({ notice: dirtyNotice }); + + expect(result.notice).toBe('점검
링크'); + expect(harness.getStoredNotice()).toBe(result.notice); + await expect(harness.caller.admin.system.getNotice()).resolves.toEqual(result); + await expect(harness.caller.lobby.notice()).resolves.toBe(result.notice); + }); + + it('purifies pre-existing rows again on public and admin reads', async () => { + const harness = await buildCaller(async () => { + throw new Error('not used'); + }); + harness.setStoredNotice(dirtyNotice); + + await expect(harness.caller.lobby.notice()).resolves.toBe('점검
링크'); + await expect(harness.caller.admin.system.getNotice()).resolves.toEqual({ + notice: '점검
링크', + }); + expect(harness.getStoredNotice()).toBe(dirtyNotice); + }); + + it('keeps notice writes behind the dedicated admin role', async () => { + const harness = await buildCaller( + async () => { + throw new Error('not used'); + }, + { adminRoles: [], firstUserIsAdmin: false } + ); + + await expect(harness.caller.admin.system.setNotice({ notice: '공지' })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + }); +}); + describe('admin operation API', () => { it('queues a start operation with the authenticated requester', async () => { const operation = { diff --git a/app/gateway-api/test/gatewayNoticeHtml.test.ts b/app/gateway-api/test/gatewayNoticeHtml.test.ts new file mode 100644 index 0000000..dfab9e5 --- /dev/null +++ b/app/gateway-api/test/gatewayNoticeHtml.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { purifyGatewayNoticeHtml } from '../src/security/gatewayNoticeHtml.js'; + +describe('purifyGatewayNoticeHtml', () => { + it('keeps the small inline formatting set used by Ref gateway notices', () => { + const source = + '점검
20시 ' + + '안내 ' + + '상세'; + const clean = purifyGatewayNoticeHtml(source); + + expect(clean).toContain('점검
'); + expect(clean).toContain('style="color:#ff6600;font-size:1.2em;font-weight:bold"'); + expect(clean).toContain('안내'); + expect(clean).toContain('rel="noopener noreferrer nofollow"'); + expect(purifyGatewayNoticeHtml(clean)).toBe(clean); + }); + + it('removes executable tags, handlers, unsafe URLs and CSS while keeping their plain text', () => { + const source = + '' + + '' + + 'SVG' + + '문구' + + '폰트' + + '링크'; + const clean = purifyGatewayNoticeHtml(source); + + expect(clean).not.toMatch(/script|문구'); + expect(clean).toContain('폰트'); + expect(clean).toContain('링크'); + }); + + it('normalizes empty values', () => { + expect(purifyGatewayNoticeHtml(undefined)).toBe(''); + expect(purifyGatewayNoticeHtml(null)).toBe(''); + expect(purifyGatewayNoticeHtml('')).toBe(''); + }); +}); diff --git a/app/gateway-frontend/e2e/gateway-notice-html.spec.ts b/app/gateway-frontend/e2e/gateway-notice-html.spec.ts new file mode 100644 index 0000000..6722222 --- /dev/null +++ b/app/gateway-frontend/e2e/gateway-notice-html.spec.ts @@ -0,0 +1,106 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { expect, test, type Page, type Route } from '@playwright/test'; + +const artifactRoot = process.env.GATEWAY_NOTICE_ARTIFACT_DIR ? resolve(process.env.GATEWAY_NOTICE_ARTIFACT_DIR) : null; +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const safeNotice = + '서버 점검
20시 재개 ' + + '상세'; + +const installFixture = async (page: Page) => { + await page.addInitScript(() => { + window.localStorage.setItem('sammo-session-token', 'playwright-notice-session'); + delete (globalThis as Record).__noticeXss; + }); + await page.route('**/gateway/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'me') { + return response({ + id: 'notice-user', + username: 'notice-user', + displayName: '공지 확인 사용자', + roles: [], + kakaoVerified: true, + createdAt: '2026-07-31T00:00:00.000Z', + }); + } + if (operation === 'lobby.notice') return response(safeNotice); + if (operation === 'lobby.profiles') return response([]); + throw new Error(`Unhandled gateway notice fixture operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); +}; + +for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, +] as const) { + test(`renders the purified gateway notice on ${viewport.name}`, async ({ page }) => { + await installFixture(page); + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await page.goto('/gateway/lobby'); + + const notice = page.getByTestId('gateway-notice'); + await expect(notice).toBeVisible(); + await expect(notice.locator('b')).toHaveText('서버 점검'); + await expect(notice.locator('br')).toHaveCount(1); + await expect(notice.locator('span')).toHaveText('20시 재개'); + await expect(notice.locator('script, img, svg, iframe, [onerror], [onload], [onclick]')).toHaveCount(0); + expect(await page.evaluate(() => (globalThis as Record).__noticeXss)).toBeUndefined(); + + const link = notice.getByRole('link', { name: '상세' }); + await expect(link).toHaveAttribute('href', 'https://example.test/notice'); + await expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow'); + await link.focus(); + await expect(link).toBeFocused(); + await link.hover(); + + const geometry = await notice.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + const emphasis = element.querySelector('span'); + const emphasisStyle = emphasis ? getComputedStyle(emphasis) : null; + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + color: style.color, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + emphasisColor: emphasisStyle?.color ?? null, + emphasisFontSize: emphasisStyle?.fontSize ?? null, + }; + }); + expect(geometry.width).toBeGreaterThan(0); + expect(geometry.y).toBe(96); + expect(geometry.color).toBe('oklch(0.705 0.213 47.604)'); + expect(geometry.fontSize).toBe('30px'); + expect(Number(geometry.fontWeight)).toBeGreaterThanOrEqual(700); + expect(geometry.emphasisColor).toBe('rgb(0, 255, 0)'); + expect(geometry.emphasisFontSize).toBe('36px'); + + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + const name = `gateway-notice-${viewport.name}`; + await writeFile( + resolve(artifactRoot, `${name}.json`), + `${JSON.stringify({ viewport, geometry }, null, 2)}\n`, + 'utf8' + ); + await page.screenshot({ path: resolve(artifactRoot, `${name}.png`), fullPage: true }); + } + }); +} diff --git a/app/gateway-frontend/e2e/playwright.config.mjs b/app/gateway-frontend/e2e/playwright.config.mjs index 523e48e..061afa9 100644 --- a/app/gateway-frontend/e2e/playwright.config.mjs +++ b/app/gateway-frontend/e2e/playwright.config.mjs @@ -14,6 +14,7 @@ export default defineConfig({ 'logout.spec.ts', 'account-icon-sync.spec.ts', 'legacy-log-html.spec.ts', + 'gateway-notice-html.spec.ts', ], fullyParallel: false, workers: 1, diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index 6618434..8423d5b 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -217,11 +217,11 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {