fix(gateway): purify global notice HTML

This commit is contained in:
2026-07-31 16:47:26 +00:00
parent 1168813eb3
commit 864cbfc0d0
10 changed files with 299 additions and 8 deletions
+2
View File
@@ -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"
}
+6 -4
View File
@@ -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({
+2 -1
View File
@@ -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(
@@ -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);
};
+67 -1
View File
@@ -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<string, Awaited<ReturnType<GatewayProfileRepository['createOperation']>>>();
const createdRuntimeActions: Array<Record<string, unknown>> = [];
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 =
'<b>점검</b><br><script>globalThis.__noticeXss=1</script>' +
'<img src=x onerror="globalThis.__noticeXss=2">' +
'<a href="javascript:globalThis.__noticeXss=3">링크</a>';
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('<b>점검</b><br /><a>링크</a>');
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('<b>점검</b><br /><a>링크</a>');
await expect(harness.caller.admin.system.getNotice()).resolves.toEqual({
notice: '<b>점검</b><br /><a>링크</a>',
});
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: '<b>공지</b>' })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});
});
describe('admin operation API', () => {
it('queues a start operation with the authenticated requester', async () => {
const operation = {
@@ -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 =
'<b>점검</b><br><span style="color:#ff6600;font-size:1.2em;font-weight:bold">20시</span> ' +
'<font color="yellow" size="2" face="sans-serif">안내</font> ' +
'<a href="https://example.test/notice" target="_blank">상세</a>';
const clean = purifyGatewayNoticeHtml(source);
expect(clean).toContain('<b>점검</b><br />');
expect(clean).toContain('style="color:#ff6600;font-size:1.2em;font-weight:bold"');
expect(clean).toContain('<font color="yellow" size="2" face="sans-serif">안내</font>');
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 =
'<script>globalThis.__noticeXss=1</script>' +
'<img src=x onerror="globalThis.__noticeXss=2">' +
'<svg onload="globalThis.__noticeXss=3"><text>SVG</text></svg>' +
'<span onclick="globalThis.__noticeXss=4" style="color:red;background:url(javascript:x)">문구</span>' +
'<font color="expression" style="color:blue;background:url(javascript:y)">폰트</font>' +
'<a href="javascript:globalThis.__noticeXss=5">링크</a>';
const clean = purifyGatewayNoticeHtml(source);
expect(clean).not.toMatch(/script|<img|<svg|onerror|onload|onclick|javascript:|background/i);
expect(clean).toContain('<span style="color:red">문구</span>');
expect(clean).toContain('<font style="color:blue">폰트</font>');
expect(clean).toContain('<a>링크</a>');
});
it('normalizes empty values', () => {
expect(purifyGatewayNoticeHtml(undefined)).toBe('');
expect(purifyGatewayNoticeHtml(null)).toBe('');
expect(purifyGatewayNoticeHtml('')).toBe('');
});
});