generated from ttipo/project-base
chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1
Generated
+1
@@ -26,6 +26,7 @@
|
||||
"class-validator": "^0.14.4",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"helmet": "^8.1.0",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"nest-winston": "^1.10.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"class-validator": "^0.14.4",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"helmet": "^8.1.0",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"nest-winston": "^1.10.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { isSendMessageRequest } from './app.setup';
|
||||
|
||||
describe('발송 요청 판정 (발송만 JSON 본문 4MB)', () => {
|
||||
it('POST /api/messages 는 발송 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다 (라우터와 같다)', () => {
|
||||
for (const url of [
|
||||
'/api/messages',
|
||||
'/api/messages/',
|
||||
'/API/Messages',
|
||||
'/api/messages?draft=1',
|
||||
]) {
|
||||
expect(isSendMessageRequest({ method: 'POST', url })).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('다른 메서드·다른 경로는 1MB 그대로다 — 메일 수정·첨부 업로드·로그인 포함', () => {
|
||||
for (const [method, url] of [
|
||||
['PATCH', '/api/messages/eaaaaab'],
|
||||
['GET', '/api/messages'],
|
||||
['PUT', '/api/messages'],
|
||||
['POST', '/api/messages/eaaaaab'],
|
||||
['POST', '/api/messages/eaaaaab/attachments'],
|
||||
['POST', '/api/mail-attachments'],
|
||||
['POST', '/api/auth/login'],
|
||||
['POST', '/messages'],
|
||||
['POST', '/api/messagesx'],
|
||||
] as const) {
|
||||
expect(isSendMessageRequest({ method, url })).toBe(false);
|
||||
}
|
||||
expect(isSendMessageRequest({ method: 'POST', url: undefined })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,18 +3,53 @@ import { Reflector } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import helmet from 'helmet';
|
||||
import type { IncomingMessage } from 'http';
|
||||
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
|
||||
import { requestIdMiddleware } from '@/common/middleware/request-id.middleware';
|
||||
import { MESSAGES_ROUTE } from '@/modules/mail/mail.constants';
|
||||
import { resolveTrustProxy } from '@/shared/utils/trust-proxy';
|
||||
|
||||
/** 글로벌 prefix — 모든 라우트가 /api/* (클라이언트 baseURL 과 일치) */
|
||||
export const API_PREFIX = 'api';
|
||||
|
||||
/**
|
||||
* JSON 본문 최대 크기.
|
||||
* 메일 발송 본문은 100,000자까지 받는다 — 한글은 UTF-8 로 3바이트라 약 300KB 다 (기본값 100KB 로는 잘린다).
|
||||
* JSON 본문 최대 크기 — 발송(`POST /api/messages`)을 뺀 모든 API.
|
||||
* 텍스트 본문 100,000자(한글은 UTF-8 로 3바이트라 약 300KB)도 이 안에 든다 (기본값 100KB 로는 잘린다).
|
||||
*/
|
||||
export const JSON_BODY_LIMIT = '1mb';
|
||||
|
||||
/**
|
||||
* 발송 요청(`POST /api/messages`)의 JSON 본문 최대 크기 (계약 2절) — 서식 본문(최대 500,000자: 한글이면 약 1.5MB 에
|
||||
* JSON 이스케이프가 더해진다)과 텍스트 대체 본문(최대 100,000자)이 함께 오고, 답장·전달은 원문 인용이 길다.
|
||||
* 앞단 nginx 도 이 경로만 4m 으로 둔다 (release-ops). 넘으면 413 FILE_002.
|
||||
*/
|
||||
export const SEND_MESSAGE_JSON_BODY_LIMIT = '4mb';
|
||||
|
||||
const SEND_MESSAGE_PATH = `/${API_PREFIX}/${MESSAGES_ROUTE}`;
|
||||
|
||||
/**
|
||||
* 발송 요청인가 — `POST /api/messages`.
|
||||
* 라우터가 발송 핸들러로 보내는 모양이면 모두 해당한다: Express 처럼 대소문자·끝의 '/' 를 가리지 않고 쿼리는 뺀다.
|
||||
*/
|
||||
export function isSendMessageRequest(
|
||||
req: Pick<IncomingMessage, 'method' | 'url'>,
|
||||
): boolean {
|
||||
if (req.method !== 'POST') return false;
|
||||
const path = (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase();
|
||||
return path === SEND_MESSAGE_PATH;
|
||||
}
|
||||
|
||||
/** JSON 본문인가 — body-parser 의 기본 판정('application/json', 매개변수는 보지 않는다)과 같다 */
|
||||
function isJsonBody(req: IncomingMessage): boolean {
|
||||
const mediaType = (req.headers['content-type'] ?? '')
|
||||
.split(';')[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return mediaType === 'application/json';
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 파이프라인 전역 설정 — main.ts 와 e2e 테스트가 **같은 함수**를 쓴다
|
||||
* (따로 적으면 e2e 가 실제 응답과 다른 설정으로 돌게 된다).
|
||||
@@ -32,7 +67,7 @@ export function configureApp(app: NestExpressApplication): void {
|
||||
app.set('trust proxy', resolveTrustProxy(process.env.TRUST_PROXY));
|
||||
|
||||
// 글로벌 prefix — 모든 라우트가 /api/* (클라이언트 baseURL 과 일치)
|
||||
app.setGlobalPrefix('api', {
|
||||
app.setGlobalPrefix(API_PREFIX, {
|
||||
exclude: [{ path: '/', method: RequestMethod.GET }],
|
||||
});
|
||||
|
||||
@@ -45,6 +80,16 @@ export function configureApp(app: NestExpressApplication): void {
|
||||
// JSON 만 받는다 — urlencoded 파서를 등록하지 않으면 다른 사이트의 <form> 으로 로그인·발송을 시도하는
|
||||
// 요청(CSRF)은 본문이 비어 VAL_001 이 된다. JSON 요청은 CORS 사전 확인을 거치므로 화이트리스트가 막는다.
|
||||
// inflate=false: 압축된 요청 본문을 받지 않는다 (브라우저는 보내지 않는다 — 압축 폭탄·body-parser DoS 경로 차단)
|
||||
//
|
||||
// 경로별 한도: 발송 요청만 4MB 파서가 읽고(type 판정으로 고른다), 나머지는 1MB 파서가 읽는다.
|
||||
// 같은 요청을 두 번 읽지 않는다 — body-parser 는 이미 다 읽은 요청을 건너뛴다(read.js 의 isFinished 확인).
|
||||
// 순서가 중요하다: 4MB 파서가 먼저여야 발송 본문이 1MB 파서에서 413 이 되지 않는다
|
||||
app.useBodyParser('json', {
|
||||
limit: SEND_MESSAGE_JSON_BODY_LIMIT,
|
||||
inflate: false,
|
||||
type: (req: IncomingMessage) =>
|
||||
isSendMessageRequest(req) && isJsonBody(req),
|
||||
});
|
||||
app.useBodyParser('json', { limit: JSON_BODY_LIMIT, inflate: false });
|
||||
|
||||
// 전역 DTO 검증
|
||||
|
||||
@@ -26,7 +26,7 @@ export const MAX_ATTACHMENTS_PER_MESSAGE = 20;
|
||||
* - 한도(20)보다 넉넉해야 한다. 파일을 한꺼번에 많이 고른 경우(21~100개)는 검증을 통과시켜 MAIL_006 으로 답한다 —
|
||||
* VAL_001 문구("입력하신 정보를 다시 확인해 주세요")로는 무엇을 고칠지 알 수 없다.
|
||||
* - 끝은 있어야 한다. 화면으로는 만들 수 없는 길이는 형식 남용으로 보고 서비스·메일 엔진까지 보내지 않는다.
|
||||
* (본문 해석·검증 비용 자체는 JSON 본문 한도 1MB 가 묶는다 — app.setup.ts 의 JSON_BODY_LIMIT)
|
||||
* (본문 해석·검증 비용 자체는 발송 JSON 본문 한도 4MB 가 묶는다 — app.setup.ts 의 SEND_MESSAGE_JSON_BODY_LIMIT)
|
||||
* - 같은 DTO 의 다른 목록(받는 사람·참조·숨은 참조) 상한도 100 이다 (mail.constants.ts 의 MAX_RECIPIENTS).
|
||||
*/
|
||||
export const MAX_ATTACHMENT_LIST_LENGTH = 100;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { PageInfoDto } from '@/common/dto/pagination.dto';
|
||||
import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants';
|
||||
import type {
|
||||
MailAddress,
|
||||
MailAttachment,
|
||||
@@ -230,7 +231,8 @@ export class MessageDetailDto
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'서버가 정화한 HTML — 스크립트·스타일 태그·이벤트 핸들러·폼·iframe·object·embed·svg 제거, 외부 이미지 제거. 클라이언트는 격리된 iframe 에만 그린다',
|
||||
'서버가 정화한 HTML — 스크립트·스타일 태그·이벤트 핸들러·폼·iframe·object·embed·svg 제거, 외부 이미지 제거. 클라이언트는 격리된 iframe 에만 그린다. ' +
|
||||
`HTML 이 없거나, 정화 한도(HTML 부분을 이어 붙여 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}자 초과 · 너무 깊은 중첩)를 넘으면 null — 그때는 textBody 를 보여 준다`,
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
MAX_ATTACHMENT_LIST_LENGTH,
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
} from '@/modules/mail/attachment.policy';
|
||||
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
|
||||
import { SendMessageDto } from './send-message.dto';
|
||||
|
||||
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) */
|
||||
@@ -27,6 +28,29 @@ const withAttachments = (count: number) => ({
|
||||
})),
|
||||
});
|
||||
|
||||
describe('SendMessageDto 서식 본문 (bodyHtml)', () => {
|
||||
const withHtml = (bodyHtml: unknown) => ({
|
||||
to: [{ name: null, email: 'doyun.lee@arasystem.test' }],
|
||||
subject: '제목',
|
||||
body: '본문',
|
||||
bodyHtml,
|
||||
});
|
||||
|
||||
it('선택 항목이고 500,000자까지 받는다', async () => {
|
||||
expect(await constraintsOf({ ...withHtml(undefined) })).toEqual([]);
|
||||
expect(
|
||||
await constraintsOf(withHtml('가'.repeat(MAX_BODY_HTML_LENGTH))),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('500,000자를 넘거나 문자열이 아니면 형식 검증에서 막는다 (VAL_001)', async () => {
|
||||
expect(
|
||||
await constraintsOf(withHtml('가'.repeat(MAX_BODY_HTML_LENGTH + 1))),
|
||||
).toContain('maxLength');
|
||||
expect(await constraintsOf(withHtml(['<p>x</p>']))).toContain('isString');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SendMessageDto 첨부 목록', () => {
|
||||
it('개수 한도를 넘는 목록도 형식 검증은 통과한다 — 개수 한도(MAIL_006)는 서비스가 답한다', async () => {
|
||||
for (const count of [
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@/modules/mail/attachment.policy';
|
||||
import {
|
||||
MAIL_ID_PATTERN,
|
||||
MAX_BODY_HTML_LENGTH,
|
||||
MAX_BODY_LENGTH,
|
||||
MAX_DISPLAY_NAME_LENGTH,
|
||||
MAX_EMAIL_LENGTH,
|
||||
@@ -123,7 +124,7 @@ export class SendMessageDto {
|
||||
subject!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: `일반 텍스트 본문 (${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자 이하, 빈 문자열 가능)`,
|
||||
description: `일반 텍스트 본문 (${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자 이하, 빈 문자열 가능) — 서식 본문을 보낼 때는 같은 내용의 대체 본문 (목록은 "- "·"1. ", 인용은 "> ")`,
|
||||
example: '도윤님, 확인했습니다.',
|
||||
})
|
||||
@IsString({ message: '본문 형식이 올바르지 않습니다.' })
|
||||
@@ -132,6 +133,21 @@ export class SendMessageDto {
|
||||
})
|
||||
body!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
`서식 본문 HTML (${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 이하) — 서식 편집기가 만든 HTML. ` +
|
||||
'서버가 허용 목록(문단·글자 서식·span 의 색·글꼴·크기·정렬·들여쓰기·목록·인용·가로줄·표·http/https/mailto 링크)으로 정화한 뒤 ' +
|
||||
'body 와 함께 multipart/alternative 로 보낸다. 없으면 텍스트 메일',
|
||||
example:
|
||||
'<div style="font-family:\'맑은 고딕\', sans-serif;font-size:14px"><p>도윤님, 확인했습니다.</p></div>',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '서식 본문 형식이 올바르지 않습니다.' })
|
||||
@MaxLength(MAX_BODY_HTML_LENGTH, {
|
||||
message: `서식 본문은 ${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 이하여야 합니다.`,
|
||||
})
|
||||
bodyHtml?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `첨부 (${MAX_ATTACHMENTS_PER_MESSAGE}개 이하, 넘으면 400 MAIL_006) — 업로드한 파일 또는 전달할 원본 첨부`,
|
||||
type: [OutgoingAttachmentDto],
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
import { Parser } from 'htmlparser2';
|
||||
|
||||
import {
|
||||
MAX_HTML_PARSE_COST,
|
||||
MAX_HTML_PARSE_LENGTH,
|
||||
checkHtmlParseBudget,
|
||||
measureHtmlParseCost,
|
||||
} from './html-parse-budget';
|
||||
import { sanitizeMailHtml } from './sanitize-mail-html';
|
||||
import { sanitizeOutgoingHtml } from './sanitize-outgoing-html';
|
||||
|
||||
/** 시간 단언은 넉넉하게 — 이 PC 에서 검사는 2MB 에 0.1초 안쪽이다 (느린 CI 도 견디게) */
|
||||
const GENEROUS_MS = 2_000;
|
||||
|
||||
/** 보내는 서식 본문 한도 (계약 2절 — DTO 가 막는다) */
|
||||
const OUTGOING_MAX_CHARS = 500_000;
|
||||
|
||||
/** 받은 메일 한 부분의 최대 크기와 같은 길이로 채운다 */
|
||||
const fillTo = (unit: string, chars = MAX_HTML_PARSE_LENGTH) =>
|
||||
unit.repeat(Math.floor(chars / unit.length));
|
||||
|
||||
function elapsedMs<T>(run: () => T): [number, T] {
|
||||
const started = performance.now();
|
||||
const result = run();
|
||||
return [performance.now() - started, result];
|
||||
}
|
||||
|
||||
/** htmlparser2 Parser 의 빈 요소 (12.0.0 src/Parser.ts) — 스택에 올라가지 않으므로 비교에서 뺀다 */
|
||||
const VOID_ELEMENTS = new Set([
|
||||
'area',
|
||||
'base',
|
||||
'basefont',
|
||||
'br',
|
||||
'col',
|
||||
'command',
|
||||
'embed',
|
||||
'frame',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'isindex',
|
||||
'keygen',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr',
|
||||
]);
|
||||
|
||||
/** 실제 Parser 가 스택에 넣고 빼는 순서 — sanitize-html 과 같은 옵션 */
|
||||
function parserStackEvents(html: string): string[] {
|
||||
const events: string[] = [];
|
||||
const parser = new Parser(
|
||||
{
|
||||
onopentagname: (name) => {
|
||||
if (!VOID_ELEMENTS.has(name)) events.push(`open:${name}`);
|
||||
},
|
||||
onclosetag: (name) => {
|
||||
if (!VOID_ELEMENTS.has(name)) events.push(`close:${name}`);
|
||||
},
|
||||
},
|
||||
{ decodeEntities: true },
|
||||
);
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
return events;
|
||||
}
|
||||
|
||||
/** 검사가 흉내 낸 스택의 순서 */
|
||||
function modelStackEvents(html: string): string[] {
|
||||
const events: string[] = [];
|
||||
measureHtmlParseCost(html, {
|
||||
observer: (event, name) => events.push(`${event}:${name}`),
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
// ── 정상 메일 모양 ────────────────────────────────────────────────────────────
|
||||
|
||||
/** 얕은 큰 표 — 한 줄 10칸, 2MB 를 채운다 (닫는 태그를 쓰거나 생략) */
|
||||
function bigTable(closeCells: boolean): string {
|
||||
const rows: string[] = [];
|
||||
let cell = 0;
|
||||
let length = 0;
|
||||
while (length < MAX_HTML_PARSE_LENGTH - 1_000) {
|
||||
let row = '<tr>';
|
||||
for (let index = 0; index < 10; index++) {
|
||||
row += closeCells ? `<td>${cell}</td>` : `<td>${cell}`;
|
||||
cell++;
|
||||
}
|
||||
row += closeCells ? '</tr>' : '';
|
||||
rows.push(row);
|
||||
length += row.length;
|
||||
}
|
||||
return `<table border="1">${rows.join('')}</table>`;
|
||||
}
|
||||
|
||||
/** Outlook — 조건부 주석 안의 표, MsoNormal 문단, o:p */
|
||||
function outlookMail(): string {
|
||||
const paragraph = (index: number) =>
|
||||
`<p class=MsoNormal><span lang=EN-US style='font-size:10.0pt;font-family:"맑은 고딕"'>문단 ${index} 입니다.<o:p></o:p></span></p>` +
|
||||
'<!--[if mso]><table role="presentation" width="600"><tr><td><![endif]--><div style="max-width:600px"><p class=MsoNormal>표 안</p></div><!--[if mso]></td></tr></table><![endif]-->';
|
||||
const parts: string[] = [];
|
||||
let length = 0;
|
||||
for (let index = 0; length < MAX_HTML_PARSE_LENGTH - 2_000; index++) {
|
||||
const part = paragraph(index);
|
||||
parts.push(part);
|
||||
length += part.length;
|
||||
}
|
||||
return `<html><head><!--[if gte mso 9]><xml><o:OfficeDocumentSettings><o:AllowPNG/></o:OfficeDocumentSettings></xml><![endif]--></head><body lang=KO><div class=WordSection1>${parts.join('')}</div></body></html>`;
|
||||
}
|
||||
|
||||
/** 닫지 않은 p·li·td 가 많은 HTML */
|
||||
const unclosedBlocks = () =>
|
||||
fillTo(
|
||||
'<p>문단<ul><li>항목<li>항목<li>항목</ul><table><tr><td>1<td>2<tr><td>3<td>4</table>',
|
||||
);
|
||||
|
||||
/** Gmail 식 인용 스레드 — 단마다 div.gmail_quote > blockquote > div 가 쌓인다 (300단 = 깊이 900) */
|
||||
function gmailThread(levels: number, chars: number): string {
|
||||
const quote =
|
||||
'<div class="gmail_quote"><div dir="ltr" class="gmail_attr">2026년 10월 27일 (화) 오전 10:00, 김아라 <ara.kim@arasystem.it.kr>님이 작성:<br></div>' +
|
||||
'<blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"><div dir="ltr">';
|
||||
const close = '</div></blockquote></div>';
|
||||
const line =
|
||||
'<div><span style="font-size:14px">답장 문장입니다.</span></div>';
|
||||
const linesPerLevel = Math.max(
|
||||
1,
|
||||
Math.floor((chars / levels - quote.length - close.length) / line.length),
|
||||
);
|
||||
return `${(line.repeat(linesPerLevel) + quote).repeat(levels)}<div>첫 메일</div>${close.repeat(levels)}`;
|
||||
}
|
||||
|
||||
/** 우리 웹메일의 답장 — 원본 메일 머리 표 + 회색 인용 막대가 단마다 한 겹 */
|
||||
function araThread(levels: number, chars: number): string {
|
||||
const head =
|
||||
'<div style="font-family:\'Malgun Gothic\', \'맑은 고딕\', sans-serif;font-size:14px;line-height:1.75"><p style="margin:0">답장 내용입니다.</p>' +
|
||||
'<blockquote style="margin:0;padding-left:12px;border-left:2px solid #d0d5dd;color:#475467"><table style="border-collapse:collapse"><tbody>' +
|
||||
'<tr><td style="padding:0 8px 0 0;font-weight:bold">보낸 사람</td><td>김아라</td></tr></tbody></table>';
|
||||
const line =
|
||||
'<p style="margin:0"><span style="color:#101828">인용된 본문 줄입니다.</span></p>';
|
||||
const close = '</blockquote></div>';
|
||||
const linesPerLevel = Math.max(
|
||||
1,
|
||||
Math.floor((chars / levels - head.length - close.length) / line.length),
|
||||
);
|
||||
return (
|
||||
(head + line.repeat(linesPerLevel)).repeat(levels) + close.repeat(levels)
|
||||
);
|
||||
}
|
||||
|
||||
/** 일반 뉴스레터 — 표 레이아웃 20단 안팎, 이미지·인라인 style */
|
||||
function newsletter(blocks: number): string {
|
||||
const block = (index: number) =>
|
||||
`<tr><td align="center" style="padding:20px 0"><table width="600"><tr><td style="padding:0 20px"><table width="100%"><tr>` +
|
||||
`<td width="200"><a href="https://news.example.com/item/${index}"><img src="https://cdn.example.com/${index}.jpg" width="200" alt="상품"></a></td>` +
|
||||
`<td style="font-family:Arial,sans-serif;font-size:14px"><h3 style="margin:0">제목 ${index}</h3><p>설명</p>` +
|
||||
`<table><tr><td bgcolor="#0E7C86"><a href="https://news.example.com/buy/${index}" style="color:#fff">자세히 보기</a></td></tr></table>` +
|
||||
`</td></tr></table></td></tr></table></td></tr>`;
|
||||
const rows = Array.from({ length: blocks }, (_, index) => block(index));
|
||||
return `<!DOCTYPE html><html><body><center><table width="100%"><tr><td><table width="640"><tbody>${rows.join('')}</tbody></table></td></tr></table></center></body></html>`;
|
||||
}
|
||||
|
||||
// ── 공격 모양 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const LONG_NAME = 'a'.repeat(30);
|
||||
|
||||
/** 정화(htmlparser2 의 스택 처리)가 O(n²) 이거나 깊은 스택을 끝없이 훑게 하는 입력 — 길이 `chars` 로 채운다 */
|
||||
const ATTACKS: Array<[string, (chars: number) => string]> = [
|
||||
['깊은 중첩 (<b>)', (chars) => fillTo('<b>', chars)],
|
||||
['목록 중첩 (<ul><li>)', (chars) => fillTo('<ul><li>', chars)],
|
||||
['svg 안의 중첩', (chars) => `<svg>${fillTo('<g>', chars - 5)}`],
|
||||
['math 통합 지점의 중첩', (chars) => `<math>${fillTo('<mi>', chars - 6)}`],
|
||||
[
|
||||
'깊은 스택 + 짝 없는 닫는 태그',
|
||||
(chars) => '<b>'.repeat(1_000) + fillTo('</i>', chars - 3_000),
|
||||
],
|
||||
[
|
||||
'깊은 스택 + 짝 없는 </p> (열었다 닫는다)',
|
||||
(chars) => '<b>'.repeat(1_000) + fillTo('</p>', chars - 3_000),
|
||||
],
|
||||
[
|
||||
'깊은 스택 + 두 번째 <form> (스택을 훑는다)',
|
||||
(chars) => `<form>${'<b>'.repeat(1_000)}${fillTo('<form>', chars - 3_006)}`,
|
||||
],
|
||||
[
|
||||
'svg 통합 지점 + svg 이름 보정 (스택을 훑는다)',
|
||||
(chars) =>
|
||||
`<svg><foreignObject>${'<b>'.repeat(1_000)}${fillTo('<clippath></clippath>', chars - 3_020)}`,
|
||||
],
|
||||
[
|
||||
'긴 이름의 깊은 스택 + 길이가 같은 짝 없는 닫는 태그 (글자마다 비교)',
|
||||
(chars) =>
|
||||
`<${LONG_NAME}>`.repeat(1_000) +
|
||||
fillTo(`</${'a'.repeat(29)}b>`, chars - 32_000),
|
||||
],
|
||||
// 아래는 닫는 태그를 숨겨 스택을 얕게 보이려는 모양 — 정규식으로 세면 속는다
|
||||
['주석 속 닫는 태그', (chars) => fillTo('<b><!--</b>-->', chars)],
|
||||
[
|
||||
'큰따옴표 속성 값 속 닫는 태그',
|
||||
(chars) => fillTo('<b title="</b>">', chars),
|
||||
],
|
||||
[
|
||||
'작은따옴표 속성 값 속 닫는 태그',
|
||||
(chars) => fillTo("<b title='</b>'>", chars),
|
||||
],
|
||||
['script 속 닫는 태그', (chars) => fillTo('<b><script></b></script>', chars)],
|
||||
[
|
||||
'textarea 속 닫는 태그',
|
||||
(chars) => fillTo('<b><textarea></b></textarea>', chars),
|
||||
],
|
||||
['CDATA 속 닫는 태그', (chars) => fillTo('<b><![CDATA[</b>]]>', chars)],
|
||||
[
|
||||
'섞은 것',
|
||||
(chars) =>
|
||||
fillTo(
|
||||
'<div><span title="</span>"><!--</div>--><svg><g><script></g></script></svg><b><font>',
|
||||
chars,
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
describe('checkHtmlParseBudget (정화 전 사전 검사)', () => {
|
||||
describe('공격 모양은 정화 없이 빠르게 걸린다', () => {
|
||||
it.each(ATTACKS)('받은 메일 2MB — %s', (_name, make) => {
|
||||
const html = make(MAX_HTML_PARSE_LENGTH);
|
||||
expect(html.length).toBeLessThanOrEqual(MAX_HTML_PARSE_LENGTH);
|
||||
|
||||
const [ms, result] = elapsedMs(() => checkHtmlParseBudget(html));
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'too-complex' });
|
||||
expect(ms).toBeLessThan(GENEROUS_MS);
|
||||
});
|
||||
|
||||
it.each(ATTACKS)('보내는 서식 본문 500,000자 — %s', (_name, make) => {
|
||||
const html = make(OUTGOING_MAX_CHARS);
|
||||
expect(html.length).toBeLessThanOrEqual(OUTGOING_MAX_CHARS);
|
||||
|
||||
expect(checkHtmlParseBudget(html)).toEqual({
|
||||
ok: false,
|
||||
reason: 'too-complex',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('정상 메일은 통과한다', () => {
|
||||
it.each<[string, () => string]>([
|
||||
['2MB 의 얕은 큰 표 (칸 13만 개, 닫는 태그)', () => bigTable(true)],
|
||||
['2MB 의 얕은 큰 표 (칸 20만 개, 닫는 태그 생략)', () => bigTable(false)],
|
||||
['Outlook — 조건부 주석 안의 태그 (2MB)', outlookMail],
|
||||
['닫지 않은 p·li·td 가 많은 HTML (2MB)', unclosedBlocks],
|
||||
[
|
||||
'Gmail 식 인용 300단 (깊이 900, 2MB 로 채움)',
|
||||
() => gmailThread(300, 1_990_000),
|
||||
],
|
||||
['우리 웹메일 답장 300단 (2MB 로 채움)', () => araThread(300, 1_990_000)],
|
||||
[
|
||||
'우리 웹메일 답장 300단 (보내는 한도 500,000자)',
|
||||
() => araThread(300, 490_000),
|
||||
],
|
||||
['일반 뉴스레터', () => newsletter(40)],
|
||||
['큰 뉴스레터 (블록 1,500개)', () => newsletter(1_500)],
|
||||
])('%s', (_name, make) => {
|
||||
const html = make();
|
||||
expect(html.length).toBeLessThanOrEqual(MAX_HTML_PARSE_LENGTH);
|
||||
|
||||
const [ms, result] = elapsedMs(() => checkHtmlParseBudget(html));
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(ms).toBeLessThan(GENEROUS_MS);
|
||||
});
|
||||
|
||||
it('300단 인용 스레드도 상한에 3배 넘게 여유가 있다', () => {
|
||||
const cost = measureHtmlParseCost(gmailThread(300, 1_990_000));
|
||||
|
||||
expect(cost).toBeLessThan(MAX_HTML_PARSE_COST / 3);
|
||||
});
|
||||
});
|
||||
|
||||
it('길이가 한도를 넘으면 too-long — HTML 부분 여럿을 이어 붙인 받은 메일', () => {
|
||||
expect(checkHtmlParseBudget('x'.repeat(MAX_HTML_PARSE_LENGTH + 1))).toEqual(
|
||||
{ ok: false, reason: 'too-long' },
|
||||
);
|
||||
expect(checkHtmlParseBudget('x'.repeat(MAX_HTML_PARSE_LENGTH))).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('상한 바로 아래의 공격 모양은 통과하지만 정화가 오래 걸리지 않는다 (상한이 정화 시간을 묶는다)', () => {
|
||||
// 깊이 1000 아래로 짝 없는 </i> — 닫을 때마다 스택 1000칸을 훑는다. 비용이 상한의 95% 가 되게 맞춘다
|
||||
const perClose = 1_000 * 3;
|
||||
const closes = Math.floor((MAX_HTML_PARSE_COST * 0.95) / perClose);
|
||||
const html = '<b>'.repeat(1_000) + '</i>'.repeat(closes);
|
||||
expect(checkHtmlParseBudget(html)).toEqual({ ok: true });
|
||||
|
||||
const [receivedMs] = elapsedMs(() => sanitizeMailHtml(html));
|
||||
const [outgoingMs] = elapsedMs(() => sanitizeOutgoingHtml(html));
|
||||
|
||||
// 이 PC 에서 0.1~0.2초 — 상한이 없으면 같은 모양 2MB 가 0.6초, 깊이 3000 이면 1.7초다
|
||||
expect(receivedMs).toBeLessThan(GENEROUS_MS);
|
||||
expect(outgoingMs).toBeLessThan(GENEROUS_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('measureHtmlParseCost', () => {
|
||||
it('평평한 태그는 태그 수에 비례하고, 중첩은 깊이의 제곱으로 는다', () => {
|
||||
const flat = (count: number) =>
|
||||
measureHtmlParseCost('<b>x</b>'.repeat(count));
|
||||
const nested = (depth: number) => measureHtmlParseCost('<b>'.repeat(depth));
|
||||
|
||||
expect(flat(2_000)).toBe(flat(1_000) * 2);
|
||||
expect(nested(2_000) / nested(1_000)).toBeCloseTo(4, 1);
|
||||
});
|
||||
|
||||
it('스택을 바꾸지 않는 글자·문자 참조·주석·CDATA 는 비용이 없다', () => {
|
||||
expect(
|
||||
measureHtmlParseCost(
|
||||
`글자 & ${'x'.repeat(1_000)}<!-- <b><b><b> --><![CDATA[<b>]]>`,
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('상한을 넘으면 그 자리에서 멈춘다 — 끝까지 세지 않는다', () => {
|
||||
const html = '<b>'.repeat(600_000);
|
||||
const limit = 1_000_000;
|
||||
|
||||
const [ms, cost] = elapsedMs(() => measureHtmlParseCost(html, { limit }));
|
||||
|
||||
// 깊이 1414 쯤에서 넘는다 (끝까지 세면 1.8×10¹¹)
|
||||
expect(cost).toBeGreaterThan(limit);
|
||||
expect(cost).toBeLessThan(limit + 2_000);
|
||||
expect(ms).toBeLessThan(GENEROUS_MS);
|
||||
});
|
||||
|
||||
describe('htmlparser2 Parser 와 같은 순서로 열고 닫는다 — 판을 올려 규칙이 바뀌면 여기서 걸린다', () => {
|
||||
const TRICKY_CASES = [
|
||||
'<b><i>x</b></i>',
|
||||
'<B><I></b></I>',
|
||||
'<p>a<p>b<div>c</p>',
|
||||
'</p></p><b></p>',
|
||||
'<table><tr><td>a<td>b<tr><td>c</table>',
|
||||
'<tbody><tfoot><tbody>',
|
||||
'<ul><li>a<li>b<ul><li>c</ul></ul>',
|
||||
'<a><a><b><a>',
|
||||
'<h1><h2><p><h3>',
|
||||
'<dd><dt><dd>',
|
||||
'<rt><rp><rt>',
|
||||
'<select><option><option><optgroup><option></select>',
|
||||
'<button><input><textarea>',
|
||||
'<body><head><link><script>',
|
||||
// 두 번째 form 무시
|
||||
'<form><b><form><i></form>',
|
||||
'<form></form><form><form></form>',
|
||||
// 빈 요소·</br>·image
|
||||
'<br></br></br><b></br>',
|
||||
'<img></img><image><svg><image></image></svg>',
|
||||
// svg·math 와 통합 지점, 이름 보정
|
||||
'<svg><g><path/><foreignObject><b><clippath></clippath></b></foreignObject></svg>',
|
||||
'<svg><clippath><lineargradient></lineargradient></clippath></svg>',
|
||||
'<svg><clippath><foreignObject><b></clippath></svg>',
|
||||
'<svg><textpath><foreignobject><p></TEXTPATH>',
|
||||
'<SVG><ClipPath></clippath></SVG>',
|
||||
'<math><mi><b>x</b></mi><mtext><svg><g/></svg></mtext></math>',
|
||||
'<math><annotation-xml><b/></annotation-xml></math>',
|
||||
'<desc><b><clippath></clippath></b></desc>',
|
||||
'<mi><feblend></feblend></mi>',
|
||||
'<svg><desc><feblend></feblend><svg><feblend/></svg></desc></svg>',
|
||||
'<svg><title><foo/></title><bar/></svg>',
|
||||
'<svg/><b>',
|
||||
'<p/><b/>',
|
||||
'<svg><p/></svg>',
|
||||
'<p><svg></p><p>',
|
||||
'<svg><foreignObject><p></svg>',
|
||||
// 원시 텍스트·주석·CDATA 안의 태그 (토큰이 아니다)
|
||||
'<title><b></title><b>',
|
||||
'<textarea><b></textarea><i>',
|
||||
'<script><b></script><i>',
|
||||
'<style></style><b>',
|
||||
'<xmp><b></xmp><i>',
|
||||
'<iframe><b></iframe><i>',
|
||||
'<noembed><b></noembed><i>',
|
||||
'<noframes><b></noframes><i>',
|
||||
'<plaintext><b></plaintext><i>',
|
||||
'<svg><script><b></script><i>',
|
||||
'<svg><title><b></title></svg><i>',
|
||||
'<!--<b>--><i>',
|
||||
'<!--[if mso]><table><tr><td><![endif]--><p>x',
|
||||
'<![CDATA[<b>]]><i>',
|
||||
'<svg><![CDATA[<b>]]><g></svg>',
|
||||
// 속성 값 안의 닫는 태그
|
||||
'<b title="</b>"><i>',
|
||||
"<b title='</b>'><i>",
|
||||
'<b title=</b>><i>',
|
||||
// 공백·잘못된 시작
|
||||
'<b\n><i\t>x</ i></b >',
|
||||
'< b>',
|
||||
'</ b>',
|
||||
'<1b>',
|
||||
'<b/ ><i / >',
|
||||
'<b><b>&</b>',
|
||||
];
|
||||
|
||||
it.each(TRICKY_CASES)('%j', (html) => {
|
||||
expect(modelStackEvents(html)).toEqual(parserStackEvents(html));
|
||||
});
|
||||
|
||||
it('무작위 입력 3,000개 (고정 시드)', () => {
|
||||
// 조각을 '|' 로 이어 적었다 (공백 하나짜리 조각도 있다)
|
||||
const pieces = [
|
||||
'<b>|</b>|<p>|</p>|<P>|</P>|<td>|<tr>|<table>|</table>|<li>|<ul>|</ul>|<a>|</a>',
|
||||
'<h1>|</h2>|<dd>|<dt>|<option>|<select>|<form>|</form>|<FORM>|<br>|</br>|<BR/>',
|
||||
'<img>|<image>|<b/>|/>|/|<svg>|</svg>|<math>|</math>|<mi>|</mi>|<mtext>|</mtext>',
|
||||
'<annotation-xml>|<foreignObject>|</foreignobject>|<clippath>|</clippath>|</CLIPPATH>',
|
||||
'<svg><clippath>|<lineargradient>|</linearGradient>|<feblend/>|<desc>|</desc>',
|
||||
'<title>|</title>|<script>|</script>|<style>|</style>|<textarea>|</textarea>',
|
||||
'<xmp>|</xmp>|<plaintext>|<iframe>|</iframe>|<noscript>|</noscript>|<!--|-->',
|
||||
'<![CDATA[|]]>|<!doctype html>|<?xml?>|<!|<?|</|<b title="|"|\'|=|<b a=|x| |<|>|&|&',
|
||||
]
|
||||
.join('|')
|
||||
.split('|');
|
||||
// 32비트 선형 합동 생성기 — 곱은 Math.imul 로 (그냥 곱하면 2^53 을 넘어 아래 자리가 0 이 되며 같은 입력만 나온다)
|
||||
let seed = 20_261_027;
|
||||
const next = (bound: number) => {
|
||||
seed = (Math.imul(seed, 1_103_515_245) + 12_345) & 0x7fffffff;
|
||||
return seed % bound;
|
||||
};
|
||||
|
||||
const mismatches: string[] = [];
|
||||
for (let round = 0; round < 3_000; round++) {
|
||||
let html = '';
|
||||
const length = 1 + next(80);
|
||||
for (let index = 0; index < length; index++) {
|
||||
html += pieces[next(pieces.length)];
|
||||
}
|
||||
const expected = parserStackEvents(html).join(' ');
|
||||
if (modelStackEvents(html).join(' ') !== expected)
|
||||
mismatches.push(html);
|
||||
}
|
||||
|
||||
expect(mismatches).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,454 @@
|
||||
import { Tokenizer, type TokenizerCallbacks } from 'htmlparser2';
|
||||
|
||||
import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants';
|
||||
|
||||
/**
|
||||
* 정화 전 사전 검사 (순수 함수, 선형 시간) — 정화(sanitize-html)가 이벤트 루프를 오래 붙잡을 HTML 을 미리 걸러 낸다.
|
||||
* 받은 메일 정화(sanitize-mail-html.ts)와 보내는 서식 본문 정화(sanitize-outgoing-html.ts) **앞에서** 부른다 (mail.service.ts).
|
||||
*
|
||||
* 왜: sanitize-html 이 쓰는 htmlparser2 Parser 는 열린 태그 스택을 배열의 **앞쪽**에서 다룬다
|
||||
* (unshift·shift·indexOf·includes). 태그 하나마다 그때의 깊이만큼 일하므로 중첩 깊이 n 이면 전체가 O(n²) 이다 —
|
||||
* '<b>' 66만 개(2MB)를 정화하는 데 172초, 보내는 서식 본문 50만 자('<b>' 16만 개)는 8.8초 동안 프로세스가 멈췄다.
|
||||
* 깊지 않아도 같은 일이 생긴다: 깊이 1000 의 스택 아래로 짝 없는 닫는 태그(`</i>`)를 50만 개 보내면 닫을 때마다 스택을 끝까지 훑는다.
|
||||
* sanitize-html 의 nestingLimit 은 Parser 가 이미 한 일을 줄이지 못한다.
|
||||
*
|
||||
* 어떻게: htmlparser2 가 내보내는 Tokenizer 를 sanitize-html 과 **같은 옵션**으로 돌려 Parser 와 같은 토큰을 받고,
|
||||
* Parser 의 스택 규칙을 이름별 위치 목록으로 흉내 낸다 (사건마다 O(1)). 그리고 Parser 가 할 일 — 옮기거나 훑는 칸 수 — 을
|
||||
* 사건마다 그때의 깊이로 **넉넉하게** 더해, 상한을 넘으면 그 자리에서 멈춘다.
|
||||
* 정규식으로 태그를 세지 않는 이유: 따옴표 처리가 조금만 달라도 속성 값 안의 `</x>` 를 닫는 태그로 세거나, 주석·`<script>` 같은
|
||||
* 원시 텍스트 안의 닫는 태그를 세서 실제 스택보다 얕게 본다 — 그 차이로 공격이 통과한다.
|
||||
*
|
||||
* ⚠️ htmlparser2 12.0.0 의 src/Parser.ts 규칙을 옮겨 적었다 (빈 요소, openImpliesClose 의 암묵 닫힘, 짝 없는 `</p>`·`</br>`,
|
||||
* 두 번째 `<form>` 무시, svg·math 안의 self-closing 과 이름 보정). 판을 올리면 html-parse-budget.spec.ts 의
|
||||
* "Parser 와 같은 순서로 열고 닫는다" 테스트가 어긋남을 잡는다 — 실패하면 바뀐 Parser.ts 규칙을 여기에 옮긴다.
|
||||
* ⚠️ 정화 한 번에 Parser 를 한 번만 돈다고 본다. sanitize-html 은 버린 iframe 의 대체 내용을 다시 정화하는데(재귀),
|
||||
* 우리 두 정화는 iframe 을 내용째 버려 그 경로를 타지 않는다 (NON_TEXT_TAGS — sanitize-mail-html.spec.ts 가 지킨다).
|
||||
*/
|
||||
|
||||
/**
|
||||
* 정화해도 되는 스택 비용의 상한. 1 은 Parser 가 스택 칸 하나를 옮기는 일이다.
|
||||
*
|
||||
* 측정 (2026-10-27, 이 PC 의 Node 22.20 / 개발 컨테이너의 Node 24.22 — 컨테이너가 2~3배 느렸다):
|
||||
* - 칸 하나를 옮기는 데(unshift·shift) 0.2~0.4ns. 이름으로 훑는 칸(indexOf·includes)은 길이가 같은 이름을 글자마다 비교해
|
||||
* 칸당 1ns(한 글자 이름)~10ns(30자 이름)라, 훑는 칸 하나를 (2 + 찾는 이름 길이) 로 센다 → 어느 연산이든 단위당 0.4~0.5ns.
|
||||
* 상한의 95% 인 공격 모양(깊이 1000 아래 짝 없는 `</i>` 8만 개)을 정화하면 0.1~0.15초(컨테이너 0.22초)다.
|
||||
* - 정상 메일의 비용: 2MB 표(칸 13만~20만 개, 닫는 태그 생략 포함) 1.4×10⁶ · Outlook 2MB 7.7×10⁵ ·
|
||||
* 닫지 않은 p·li·td 2MB 1.4×10⁶ · 뉴스레터 1MB 6.9×10⁵ · 우리 웹메일 답장 300단 2MB 3.3×10⁷ ·
|
||||
* Gmail 식 인용 300단(깊이 900)을 2MB 로 채운 스레드 5.7×10⁷ · 같은 모양 600단 1.1×10⁸ → 300단 스레드에 4배 남짓 여유.
|
||||
* - 공격 모양은 모두 넘는다: '<b>' 2MB 2.2×10¹¹ · 깊이 1000 + 짝 없는 `</i>` 2MB 1.5×10⁹ · 깊이 1000 + `</p>` 2MB 2.5×10⁹
|
||||
* (모양별 목록은 html-parse-budget.spec.ts). 넘는 입력은 검사가 수십 ms 안에 멈춘다.
|
||||
*/
|
||||
export const MAX_HTML_PARSE_COST = 250_000_000;
|
||||
|
||||
/**
|
||||
* 정화하는 HTML 의 최대 길이 (글자) — 본문 한 부분을 읽는 한도(MAX_BODY_VALUE_BYTES)와 같은 수.
|
||||
* 받은 메일은 HTML 부분이 여럿이면 이어 붙이므로(jmap.mapper.ts) 이보다 길 수 있다 — 정화 비용은 길이에도 비례하므로 막는다.
|
||||
*/
|
||||
export const MAX_HTML_PARSE_LENGTH = MAX_BODY_VALUE_BYTES;
|
||||
|
||||
/** 훑는 칸 하나의 기본 비용 — 여기에 찾는 이름의 길이를 더한다 (같은 길이의 이름은 글자마다 비교한다) */
|
||||
const SCAN_STEP_BASE_COST = 2;
|
||||
|
||||
/**
|
||||
* Tokenizer 옵션 — sanitize-html 이 Parser 에 넘기는 옵션과 같아야 같은 토큰이 나온다.
|
||||
* 우리 두 정화는 `parser` 옵션을 주지 않으므로 sanitize-html 기본값(`decodeEntities: true`)만 쓰고,
|
||||
* xmlMode·recognizeSelfClosing 은 꺼져 있다 (HTML 모드).
|
||||
*/
|
||||
const TOKENIZER_OPTIONS = { decodeEntities: true } as const;
|
||||
|
||||
// ── htmlparser2 12.0.0 src/Parser.ts 의 표 ────────────────────────────────────
|
||||
|
||||
const P_TAG = new Set(['p']);
|
||||
const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p']);
|
||||
const TABLE_SECTION_TAGS = new Set(['thead', 'tbody']);
|
||||
const DDT_TAGS = new Set(['dd', 'dt']);
|
||||
const RTP_TAGS = new Set(['rt', 'rp']);
|
||||
const FORM_TAGS = new Set([
|
||||
'input',
|
||||
'option',
|
||||
'optgroup',
|
||||
'select',
|
||||
'button',
|
||||
'datalist',
|
||||
'textarea',
|
||||
]);
|
||||
|
||||
/** 이 태그를 열면 스택 맨 위의 이 태그들을 (있는 만큼) 먼저 닫는다 */
|
||||
const OPEN_IMPLIES_CLOSE = new Map<string, ReadonlySet<string>>([
|
||||
['tr', new Set(['tr', 'th', 'td'])],
|
||||
['th', new Set(['th'])],
|
||||
['td', new Set(['thead', 'th', 'td'])],
|
||||
['body', new Set(['head', 'link', 'script'])],
|
||||
['a', new Set(['a'])],
|
||||
['li', new Set(['li'])],
|
||||
['p', P_TAG],
|
||||
['h1', HEADING_TAGS],
|
||||
['h2', HEADING_TAGS],
|
||||
['h3', HEADING_TAGS],
|
||||
['h4', HEADING_TAGS],
|
||||
['h5', HEADING_TAGS],
|
||||
['h6', HEADING_TAGS],
|
||||
['select', FORM_TAGS],
|
||||
['input', FORM_TAGS],
|
||||
['output', FORM_TAGS],
|
||||
['button', FORM_TAGS],
|
||||
['datalist', FORM_TAGS],
|
||||
['textarea', FORM_TAGS],
|
||||
['option', new Set(['option'])],
|
||||
['optgroup', new Set(['optgroup', 'option'])],
|
||||
['dd', DDT_TAGS],
|
||||
['dt', DDT_TAGS],
|
||||
['address', P_TAG],
|
||||
['article', P_TAG],
|
||||
['aside', P_TAG],
|
||||
['blockquote', P_TAG],
|
||||
['details', P_TAG],
|
||||
['div', P_TAG],
|
||||
['dl', P_TAG],
|
||||
['fieldset', P_TAG],
|
||||
['figcaption', P_TAG],
|
||||
['figure', P_TAG],
|
||||
['footer', P_TAG],
|
||||
['form', P_TAG],
|
||||
['header', P_TAG],
|
||||
['hr', P_TAG],
|
||||
['main', P_TAG],
|
||||
['nav', P_TAG],
|
||||
['ol', P_TAG],
|
||||
['pre', P_TAG],
|
||||
['section', P_TAG],
|
||||
['table', P_TAG],
|
||||
['ul', P_TAG],
|
||||
['rt', RTP_TAGS],
|
||||
['rp', RTP_TAGS],
|
||||
['tbody', TABLE_SECTION_TAGS],
|
||||
['tfoot', TABLE_SECTION_TAGS],
|
||||
]);
|
||||
|
||||
/** 빈 요소 — 스택에 올라가지 않는다 */
|
||||
const VOID_ELEMENTS = new Set([
|
||||
'area',
|
||||
'base',
|
||||
'basefont',
|
||||
'br',
|
||||
'col',
|
||||
'command',
|
||||
'embed',
|
||||
'frame',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'isindex',
|
||||
'keygen',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr',
|
||||
]);
|
||||
|
||||
/** svg·math 안에서 HTML 로 돌아가는 요소 (이름은 svg 보정을 거친 모양) */
|
||||
const HTML_INTEGRATION_ELEMENTS = new Set([
|
||||
'mi',
|
||||
'mo',
|
||||
'mn',
|
||||
'ms',
|
||||
'mtext',
|
||||
'annotation-xml',
|
||||
'foreignObject',
|
||||
'desc',
|
||||
'title',
|
||||
]);
|
||||
|
||||
/** svg 안의 태그 이름 대소문자 보정 */
|
||||
const SVG_TAG_NAME_ADJUSTMENTS = new Map([
|
||||
['altglyph', 'altGlyph'],
|
||||
['altglyphdef', 'altGlyphDef'],
|
||||
['altglyphitem', 'altGlyphItem'],
|
||||
['animatecolor', 'animateColor'],
|
||||
['animatemotion', 'animateMotion'],
|
||||
['animatetransform', 'animateTransform'],
|
||||
['clippath', 'clipPath'],
|
||||
['feblend', 'feBlend'],
|
||||
['fecolormatrix', 'feColorMatrix'],
|
||||
['fecomponenttransfer', 'feComponentTransfer'],
|
||||
['fecomposite', 'feComposite'],
|
||||
['feconvolvematrix', 'feConvolveMatrix'],
|
||||
['fediffuselighting', 'feDiffuseLighting'],
|
||||
['fedisplacementmap', 'feDisplacementMap'],
|
||||
['fedistantlight', 'feDistantLight'],
|
||||
['fedropshadow', 'feDropShadow'],
|
||||
['feflood', 'feFlood'],
|
||||
['fefunca', 'feFuncA'],
|
||||
['fefuncb', 'feFuncB'],
|
||||
['fefuncg', 'feFuncG'],
|
||||
['fefuncr', 'feFuncR'],
|
||||
['fegaussianblur', 'feGaussianBlur'],
|
||||
['feimage', 'feImage'],
|
||||
['femerge', 'feMerge'],
|
||||
['femergenode', 'feMergeNode'],
|
||||
['femorphology', 'feMorphology'],
|
||||
['feoffset', 'feOffset'],
|
||||
['fepointlight', 'fePointLight'],
|
||||
['fespecularlighting', 'feSpecularLighting'],
|
||||
['fespotlight', 'feSpotLight'],
|
||||
['fetile', 'feTile'],
|
||||
['feturbulence', 'feTurbulence'],
|
||||
['foreignobject', 'foreignObject'],
|
||||
['glyphref', 'glyphRef'],
|
||||
['lineargradient', 'linearGradient'],
|
||||
['radialgradient', 'radialGradient'],
|
||||
['textpath', 'textPath'],
|
||||
]);
|
||||
|
||||
/** Parser 의 foreignContext 값 — 지금 svg·math 안인가 */
|
||||
type ForeignContext = 'none' | 'svg' | 'math';
|
||||
|
||||
/** 이 요소를 열고 닫을 때 foreignContext 도 함께 넣고 뺀다 */
|
||||
function foreignContextOf(name: string): ForeignContext | null {
|
||||
if (name === 'svg') return 'svg';
|
||||
if (name === 'math') return 'math';
|
||||
return HTML_INTEGRATION_ELEMENTS.has(name) ? 'none' : null;
|
||||
}
|
||||
|
||||
/** 스택이 바뀔 때마다 알린다 — Parser 와 같은 순서인지 테스트가 비교한다 */
|
||||
export type ParserStackObserver = (
|
||||
event: 'open' | 'close',
|
||||
name: string,
|
||||
) => void;
|
||||
|
||||
export interface HtmlParseCostOptions {
|
||||
/** 비용이 이 값을 넘으면 더 읽지 않고 멈춘다 (돌려주는 값은 limit 보다 크다) */
|
||||
readonly limit?: number;
|
||||
readonly observer?: ParserStackObserver;
|
||||
}
|
||||
|
||||
/**
|
||||
* htmlparser2 Parser 의 스택을 흉내 내는 Tokenizer 콜백.
|
||||
* Parser 는 `stack[0]` 이 맨 위인 배열을 앞쪽에서 넣고 빼지만, 여기는 끝에서 넣고 빼고(O(1))
|
||||
* 이름별 위치 목록으로 "맨 위에서 가장 가까운 같은 이름"을 O(1) 에 찾는다.
|
||||
*/
|
||||
class ParserStackModel implements TokenizerCallbacks {
|
||||
/** 열린 태그 이름 — 아래가 0, 맨 위가 끝 */
|
||||
private readonly names: string[] = [];
|
||||
/** 이름 → names 안의 위치들 (오름차순 — 마지막이 맨 위에 가장 가까운 것). 빈 목록은 두지 않는다 */
|
||||
private readonly positions = new Map<string, number[]>();
|
||||
/** Parser 의 foreignContext — 아래가 0, 처음에 'none' 하나 */
|
||||
private readonly foreignContext: ForeignContext[] = ['none'];
|
||||
/** 지금 여는 중인 태그 (Parser 의 tagname) — self-closing 으로 닫을 때 쓴다 */
|
||||
private tagname = '';
|
||||
|
||||
cost = 0;
|
||||
/** 상한을 넘었을 때 부른다 (토크나이저를 멈춘다) */
|
||||
onLimitExceeded: () => void = () => undefined;
|
||||
|
||||
constructor(
|
||||
private readonly html: string,
|
||||
private readonly limit: number,
|
||||
private readonly observer?: ParserStackObserver,
|
||||
) {}
|
||||
|
||||
// ── 스택을 바꾸지 않는 토큰 (Parser 도 스택을 건드리지 않는다) ──────────────
|
||||
ontext(): void {}
|
||||
ontextentity(): void {}
|
||||
onattribname(): void {}
|
||||
onattribdata(): void {}
|
||||
onattribentity(): void {}
|
||||
onattribend(): void {}
|
||||
oncomment(): void {}
|
||||
oncdata(): void {}
|
||||
ondeclaration(): void {}
|
||||
onprocessinginstruction(): void {}
|
||||
|
||||
// ── 스택을 바꾸는 토큰 ─────────────────────────────────────────────────────
|
||||
|
||||
onopentagname(start: number, endIndex: number): void {
|
||||
this.emitOpenTag(this.readTagName(start, endIndex));
|
||||
}
|
||||
|
||||
onopentagend(): void {
|
||||
this.tagname = '';
|
||||
}
|
||||
|
||||
/** `/>` — HTML 에서는 무시하고, svg·math 안에서만 방금 연 태그를 닫는다 */
|
||||
onselfclosingtag(): void {
|
||||
if (this.isInForeignContext()) this.closeCurrentTag();
|
||||
else this.tagname = '';
|
||||
}
|
||||
|
||||
onclosetag(start: number, endIndex: number): void {
|
||||
const name = this.readTagName(start, endIndex);
|
||||
// 빈 요소의 닫는 태그(</br> 등)는 스택을 바꾸지 않는다
|
||||
if (VOID_ELEMENTS.has(name)) return;
|
||||
|
||||
const fromTop = this.scan(name);
|
||||
if (fromTop !== -1) {
|
||||
// 그 위에 열린 것을 모두 닫고 그것까지 닫는다
|
||||
for (let index = 0; index <= fromTop; index++) this.pop();
|
||||
} else if (name === 'p') {
|
||||
// 짝 없는 </p> — 빈 p 를 열었다 닫는다
|
||||
this.emitOpenTag('p');
|
||||
this.closeCurrentTag();
|
||||
}
|
||||
}
|
||||
|
||||
/** 끝 — Parser 는 남은 태그를 위에서부터 모두 닫는다 */
|
||||
onend(): void {
|
||||
this.add(this.names.length);
|
||||
for (let index = this.names.length - 1; index >= 0; index--) {
|
||||
this.observer?.('close', this.names[index]);
|
||||
}
|
||||
}
|
||||
|
||||
isInForeignContext(): boolean {
|
||||
return this.foreignContext[this.foreignContext.length - 1] !== 'none';
|
||||
}
|
||||
|
||||
// ── Parser 의 규칙 ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Parser.readTagName — 소문자로, svg 안에서는 대소문자 보정, HTML 에서는 image → img */
|
||||
private readTagName(start: number, endIndex: number): string {
|
||||
const name = this.html.slice(start, endIndex).toLowerCase();
|
||||
const context = this.foreignContext[this.foreignContext.length - 1];
|
||||
if (context === 'svg') return SVG_TAG_NAME_ADJUSTMENTS.get(name) ?? name;
|
||||
|
||||
// svg 의 HTML 통합 지점(foreignObject 등) 안에서 svg 요소를 닫는 경우 — Parser 는 스택을 훑어 본다(includes)
|
||||
if (this.foreignContext.length > 1) {
|
||||
const adjusted = SVG_TAG_NAME_ADJUSTMENTS.get(name);
|
||||
if (adjusted !== undefined && this.scan(adjusted) !== -1) return adjusted;
|
||||
}
|
||||
|
||||
if (context === 'none') return name === 'image' ? 'img' : name;
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Parser.emitOpenTag */
|
||||
private emitOpenTag(name: string): void {
|
||||
this.tagname = name;
|
||||
|
||||
// 두 번째 <form> 은 무시한다 — 열린 form 이 있는지 스택을 훑는다(includes)
|
||||
if (name === 'form' && this.scan('form') !== -1) {
|
||||
this.tagname = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const impliesClose = OPEN_IMPLIES_CLOSE.get(name);
|
||||
if (impliesClose) {
|
||||
while (this.names.length > 0 && impliesClose.has(this.top())) {
|
||||
this.pop();
|
||||
}
|
||||
}
|
||||
if (!VOID_ELEMENTS.has(name)) this.push(name);
|
||||
}
|
||||
|
||||
/** Parser.closeCurrentTag — 방금 연 태그가 맨 위에 있으면 닫는다 (빈 요소·무시한 form 은 올라가지 않았다) */
|
||||
private closeCurrentTag(): void {
|
||||
const name = this.tagname;
|
||||
this.tagname = '';
|
||||
if (this.names.length > 0 && this.top() === name) this.pop();
|
||||
}
|
||||
|
||||
private top(): string {
|
||||
return this.names[this.names.length - 1];
|
||||
}
|
||||
|
||||
// ── 스택 연산과 비용 ───────────────────────────────────────────────────────
|
||||
|
||||
/** Parser 의 stack.unshift — 칸을 모두 한 칸씩 옮긴다 */
|
||||
private push(name: string): void {
|
||||
this.add(this.names.length + 1);
|
||||
const list = this.positions.get(name);
|
||||
if (list) list.push(this.names.length);
|
||||
else this.positions.set(name, [this.names.length]);
|
||||
this.names.push(name);
|
||||
|
||||
const context = foreignContextOf(name);
|
||||
if (context !== null) {
|
||||
this.add(this.foreignContext.length + 1);
|
||||
this.foreignContext.push(context);
|
||||
}
|
||||
this.observer?.('open', name);
|
||||
}
|
||||
|
||||
/** Parser 의 stack.shift (popElement) — 칸을 모두 한 칸씩 옮긴다고 본다 */
|
||||
private pop(): void {
|
||||
this.add(this.names.length);
|
||||
const name = this.names.pop();
|
||||
if (name === undefined) return;
|
||||
// 맨 위의 것이 그 이름의 마지막 위치다. 빈 목록은 지운다 (이름이 매번 다른 입력에서 표가 커지지 않게)
|
||||
const list = this.positions.get(name);
|
||||
list?.pop();
|
||||
if (list?.length === 0) this.positions.delete(name);
|
||||
|
||||
if (foreignContextOf(name) !== null) {
|
||||
this.add(this.foreignContext.length);
|
||||
this.foreignContext.pop();
|
||||
}
|
||||
this.observer?.('close', name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parser 의 stack.indexOf·includes — 맨 위에서부터 같은 이름을 찾아 위에서 몇 번째인지(0 부터), 없으면 -1.
|
||||
* 훑는 칸 하나를 (2 + 찾는 이름 길이) 로 센다.
|
||||
*/
|
||||
private scan(name: string): number {
|
||||
const list = this.positions.get(name);
|
||||
const depth = this.names.length;
|
||||
const index = list ? list[list.length - 1] : -1;
|
||||
const fromTop = index === -1 ? -1 : depth - 1 - index;
|
||||
|
||||
const scanned = fromTop === -1 ? depth : fromTop + 1;
|
||||
this.add(scanned * (SCAN_STEP_BASE_COST + name.length));
|
||||
return fromTop;
|
||||
}
|
||||
|
||||
private add(amount: number): void {
|
||||
this.cost += amount;
|
||||
if (this.cost > this.limit) this.onLimitExceeded();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* htmlparser2 Parser 가 이 HTML 의 스택에 쓸 비용을 센다 (선형 시간).
|
||||
* `limit` 을 주면 넘는 순간 멈추고, 그때까지 센 값(limit 보다 크다)을 돌려준다.
|
||||
*/
|
||||
export function measureHtmlParseCost(
|
||||
html: string,
|
||||
options: HtmlParseCostOptions = {},
|
||||
): number {
|
||||
const model = new ParserStackModel(
|
||||
html,
|
||||
options.limit ?? Number.POSITIVE_INFINITY,
|
||||
options.observer,
|
||||
);
|
||||
const tokenizer = new Tokenizer(TOKENIZER_OPTIONS, model);
|
||||
model.onLimitExceeded = () => tokenizer.pause();
|
||||
|
||||
tokenizer.write(html);
|
||||
// 멈췄으면 끝을 처리하지 않는다 — 이미 상한을 넘었다
|
||||
if (tokenizer.running) tokenizer.end();
|
||||
return model.cost;
|
||||
}
|
||||
|
||||
export type HtmlParseBudgetCheck =
|
||||
| { readonly ok: true }
|
||||
| {
|
||||
readonly ok: false;
|
||||
/** too-long: 길이 한도 초과 · too-complex: 스택 비용 한도 초과 (깊은 중첩, 깊은 스택 아래의 짝 없는 닫는 태그 등) */
|
||||
readonly reason: 'too-long' | 'too-complex';
|
||||
};
|
||||
|
||||
/**
|
||||
* 이 HTML 을 정화해도 되는가 — 길이와 스택 비용이 한도 안인지 본다 (선형 시간).
|
||||
* 넘으면 정화하지 않는다: 받은 메일은 htmlBody 없이(텍스트 본문으로), 보내는 서식 본문은 VAL_001 (mail.service.ts).
|
||||
*/
|
||||
export function checkHtmlParseBudget(html: string): HtmlParseBudgetCheck {
|
||||
if (html.length > MAX_HTML_PARSE_LENGTH) {
|
||||
return { ok: false, reason: 'too-long' };
|
||||
}
|
||||
const cost = measureHtmlParseCost(html, { limit: MAX_HTML_PARSE_COST });
|
||||
return cost > MAX_HTML_PARSE_COST
|
||||
? { ok: false, reason: 'too-complex' }
|
||||
: { ok: true };
|
||||
}
|
||||
@@ -98,6 +98,23 @@ describe('sanitizeMailHtml', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('url( 뒤 공백이 아주 길어도 한 번만 훑는다 (예전: 공백 4만 개에 0.6초 — 공백 수의 제곱)', () => {
|
||||
const started = performance.now();
|
||||
|
||||
const result = sanitizeMailHtml(
|
||||
`<b style="background:url(${' '.repeat(1_900_000)}x)">y</b>`,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ html: '<b>y</b>', hasBlockedImages: false });
|
||||
expect(performance.now() - started).toBeLessThan(2_000);
|
||||
// 따옴표 앞뒤의 공백도 그대로 외부 주소로 알아본다
|
||||
expect(
|
||||
sanitizeMailHtml(
|
||||
`<b style="background:url( ' https://t.example/p.gif')">y</b>`,
|
||||
).hasBlockedImages,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('expression()·behavior 같은 스크립트성 CSS 를 지운다', () => {
|
||||
expect(clean('<p style="width:expression(alert(1))">a</p>')).toBe(
|
||||
'<p>a</p>',
|
||||
@@ -185,6 +202,36 @@ describe('sanitizeMailHtml', () => {
|
||||
it('외부 이미지가 없으면 hasBlockedImages 는 false 다', () => {
|
||||
expect(sanitizeMailHtml('<p>텍스트만</p>').hasBlockedImages).toBe(false);
|
||||
});
|
||||
|
||||
it('주소가 남지 않은 이미지를 지워도 뒤따르는 형제의 닫는 태그는 그대로다', () => {
|
||||
// 지운 이미지의 바뀐 이름이 같은 깊이의 다음 형제를 닫을 때 쓰이면 </p> 가 엉뚱한 이름으로 닫힌다
|
||||
expect(
|
||||
clean(
|
||||
'<div><img src="https://x.example/a.png"><p>글</p><span>a</span></div>',
|
||||
),
|
||||
).toBe('<div><p>글</p><span>a</span></div>');
|
||||
expect(
|
||||
clean(
|
||||
'<table><tr><td><img><b>굵게</b></td></tr></table><p><img><i>a</i><img alt="x"><u>b</u></p>',
|
||||
),
|
||||
).toBe(
|
||||
'<table><tr><td><b>굵게</b></td></tr></table><p><i>a</i><u>b</u></p>',
|
||||
);
|
||||
expect(clean('<p>a<img src="cid:none@x"><em>b</em></p>')).toBe(
|
||||
'<p>a<em>b</em></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('주소가 남지 않은 이미지가 아주 많아도 입력 길이에 비례해 끝난다 (예전: 27만 개에 15초)', () => {
|
||||
const count = 333_333; // 2MB
|
||||
const started = performance.now();
|
||||
|
||||
const result = sanitizeMailHtml('x<img>'.repeat(count));
|
||||
|
||||
expect(result.html).toBe('x'.repeat(count));
|
||||
// 이 PC 에서 0.5초 안쪽 — 넉넉하게
|
||||
expect(performance.now() - started).toBeLessThan(5_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('위험한 요소', () => {
|
||||
|
||||
@@ -15,6 +15,10 @@ import { normalizeContentId } from './inline-images';
|
||||
* - 외부(http·https·//) 이미지는 지우고 `hasBlockedImages` 를 켠다 (style 의 url() 포함 — 추적 픽셀 차단)
|
||||
* - `cid:` 이미지는 미리 만든 data: URI 로 바꾸고, 없으면 지운다
|
||||
*
|
||||
* ⚠️ 부르기 전에 html-parse-budget.ts 의 `checkHtmlParseBudget` 으로 길이·중첩 비용을 본다 (mail.service.ts) —
|
||||
* 깊게 중첩된 HTML 은 정화(htmlparser2 의 스택 처리)가 O(n²) 이라 이벤트 루프를 멈춘다.
|
||||
* 여기의 정규식·변환도 입력 길이에 비례하게 둔다 (같은 공백을 여러 번 훑는 정규식, 결과를 다시 자르는 필터를 쓰지 않는다).
|
||||
*
|
||||
* sanitize-html 2.18 (런타임 Node 24) — 2.17.5 고정을 풀면서 보안 권고 4건(GHSA-jxwj-j7wr-gfrw·GHSA-g8qq-57p8-ggw5·
|
||||
* GHSA-cv27-6wvh-8x7j·GHSA-x3q4-9hxx-gx8m)이 해결됐다. 그래도 svg·math·textarea·xmp·option·meta·noscript 는
|
||||
* 허용 목록에 넣지 않는다 — 우회 기법이 반복해서 나온 태그들이다 (sanitize-mail-html.spec.ts 가 지킨다).
|
||||
@@ -115,8 +119,9 @@ const ALLOWED_TAGS = [
|
||||
* 태그를 버릴 때 **내용까지** 버리는 태그.
|
||||
* 여기에 없는 비허용 태그는 태그만 버리고 안의 글자는 (정화해서) 남긴다.
|
||||
* sanitize-html 기본값(script·style·textarea·option·xmp)을 모두 포함해야 한다.
|
||||
* 보내는 서식 본문 정화(sanitize-outgoing-html.ts)도 같은 목록을 쓴다.
|
||||
*/
|
||||
const NON_TEXT_TAGS = [
|
||||
export const NON_TEXT_TAGS = [
|
||||
'script',
|
||||
'style',
|
||||
'textarea',
|
||||
@@ -250,8 +255,8 @@ const ALLOWED_STYLES = {
|
||||
),
|
||||
};
|
||||
|
||||
/** 링크로 남길 수 있는 주소 */
|
||||
const SAFE_LINK = /^(?:https?:\/\/|mailto:)/i;
|
||||
/** 링크로 남길 수 있는 주소 — 보내는 서식 본문도 같은 규칙이다 (계약 2절) */
|
||||
export const SAFE_LINK = /^(?:https?:\/\/|mailto:)/i;
|
||||
|
||||
/** 외부에서 불러오는 주소 (프로토콜 상대 `//host` 포함) */
|
||||
const EXTERNAL_URL = /^\s*(?:https?:)?\/\//i;
|
||||
@@ -259,8 +264,12 @@ const EXTERNAL_URL = /^\s*(?:https?:)?\/\//i;
|
||||
/** srcset·style 안의 외부 주소 */
|
||||
const EMBEDDED_EXTERNAL_URL = /(?:https?:)?\/\//i;
|
||||
|
||||
/** style 의 url() 이 외부 주소를 가리키는지 */
|
||||
const CSS_EXTERNAL_URL = /url\s*\(\s*['"]?\s*(?:https?:)?\/\//i;
|
||||
/**
|
||||
* style 의 url() 이 외부 주소를 가리키는지.
|
||||
* 따옴표 앞뒤 공백을 `\s*['"]?\s*` 로 쓰면 따옴표가 없을 때 두 `\s*` 가 공백을 나눠 갖는 방법을 모두 시도해
|
||||
* 공백 수의 제곱만큼 걸린다 (`url(` 뒤 공백 4만 개에 0.6초, 2MB 면 수십 분) — 따옴표가 있을 때만 그 뒤 공백을 본다 (같은 뜻).
|
||||
*/
|
||||
const CSS_EXTERNAL_URL = /url\s*\(\s*(?:['"]\s*)?(?:https?:)?\/\//i;
|
||||
|
||||
/** 원문에 이미 들어 있던 data: 이미지 중 남겨도 되는 형식 */
|
||||
const SAFE_DATA_IMAGE =
|
||||
@@ -342,17 +351,23 @@ export function sanitizeMailHtml(
|
||||
disallowedTagsMode: 'discard',
|
||||
transformTags: {
|
||||
a: (tagName, attribs) => ({ tagName, attribs: secureLink(attribs) }),
|
||||
img: (tagName, attribs) => ({
|
||||
tagName,
|
||||
attribs: resolveImage(attribs, inlineImages, markBlocked),
|
||||
}),
|
||||
img: (tagName, attribs) => {
|
||||
const next = resolveImage(attribs, inlineImages, markBlocked);
|
||||
// 주소가 남지 않은 이미지는 태그째 지운다 (깨진 이미지 아이콘 방지) — 이름을 '' 로 바꾸면 허용 목록에 없어
|
||||
// sanitize-html 이 태그를 버린다 (img 는 빈 요소라 버릴 내용도 없다).
|
||||
// - exclusiveFilter 로 지우지 않는 이유: sanitize-html 이 그때까지의 결과 문자열을 잘라 다시 만들어(substring)
|
||||
// 이미지마다 결과 길이만큼 일한다 — 주소 없는 이미지 27만 개(1.6MB)에 15초가 걸렸다.
|
||||
// - 다른 이름이 아니라 '' 인 이유: sanitize-html 은 버린 태그의 바뀐 이름(transformMap)을 지우지 않아, 같은 깊이의
|
||||
// 다음 형제를 닫을 때 그 이름으로 닫는다. '' 는 거짓 값이라 그 자리에서 쓰이지 않는다 (sanitize-mail-html.spec.ts)
|
||||
return next.src
|
||||
? { tagName, attribs: next }
|
||||
: { tagName: '', attribs: next };
|
||||
},
|
||||
'*': (tagName, attribs) => {
|
||||
if (loadsExternalImage(attribs)) markBlocked();
|
||||
return { tagName, attribs };
|
||||
},
|
||||
},
|
||||
// 주소가 남지 않은 이미지는 태그째 지운다 (깨진 이미지 아이콘 방지)
|
||||
exclusiveFilter: (frame) => frame.tag === 'img' && !frame.attribs.src,
|
||||
});
|
||||
|
||||
return { html: output, hasBlockedImages };
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
import { sanitizeMailHtml } from './sanitize-mail-html';
|
||||
import { sanitizeOutgoingHtml } from './sanitize-outgoing-html';
|
||||
|
||||
const clean = sanitizeOutgoingHtml;
|
||||
|
||||
/** 기본 글꼴 스택 (디자인 확정본 "메일 쓰기") */
|
||||
const DEFAULT_FONT = `'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif`;
|
||||
|
||||
/**
|
||||
* 편집기(TipTap)가 만드는 서식을 고루 담은 본문 — 쓰기 화면이 보내는 모양이다.
|
||||
* 기본 글꼴 div 로 감싸고, 문단마다 여백 0 을 인라인으로 적고(받는 쪽의 p 기본 여백을 없앤다),
|
||||
* 표는 colgroup 으로 칸 폭을 적는다. 답장 인용은 "원본 메일" 머리와 항목 표를 회색 인용 막대 안에 둔다.
|
||||
*/
|
||||
const EDITOR_HTML = [
|
||||
`<div style="font-family: ${DEFAULT_FONT}; font-size: 14px; line-height: 1.75">`,
|
||||
'<p style="margin: 0; text-align: center">가운데 <strong>굵게</strong> <b>굵게2</b> <em>기울임</em> <i>기울임2</i> <u>밑줄</u> <s>취소선</s></p>',
|
||||
'<p style="margin: 0"><span style="color: #B42318">빨강</span> <span style="background-color: #FFF1A8">노랑 배경</span> ',
|
||||
`<span style="color: rgb(23, 92, 211)">파랑(브라우저가 되읽은 형식)</span> <span style="font-family: 'Times New Roman', Times, serif; font-size: 18px">Times 18</span> `,
|
||||
'<span style="font-family: 굴림, Gulim, sans-serif; font-size: 12px">굴림 12</span></p>',
|
||||
'<p style="margin: 0 0 0 40px">들여쓰기</p><p style="margin: 0; padding-left: 80px">들여쓰기2</p>',
|
||||
'<ul><li><p style="margin: 0">항목 1</p></li><li><p style="margin: 0">항목 2</p></li></ul>',
|
||||
'<ol start="3"><li><p style="margin: 0">셋</p></li></ol>',
|
||||
'<blockquote style="margin: 24px 0 0; border-left: 2px solid #D0D5DD; padding-left: 14px; color: #4A5363">',
|
||||
'<p style="margin: 0; font-size: 12.5px"><strong>원본 메일</strong></p>',
|
||||
'<table style="border-collapse: collapse; margin: 4px 0 12px"><tbody>',
|
||||
'<tr><td style="padding: 0 12px 0 0; color: #667085; vertical-align: top; white-space: nowrap">보낸 사람</td><td style="padding: 0">이도윤 <doyun.lee@arasystem.it.kr></td></tr>',
|
||||
'<tr><td style="padding: 0 12px 0 0; color: #667085; vertical-align: top; white-space: nowrap">제목</td><td style="padding: 0">메일 서버 이전 일정</td></tr>',
|
||||
'</tbody></table>',
|
||||
'<p style="margin: 0">원문 본문</p></blockquote>',
|
||||
'<hr>',
|
||||
'<table style="border-collapse: collapse; min-width: 75px"><colgroup><col style="width: 120px"><col style="min-width: 25px"></colgroup>',
|
||||
'<tbody><tr><th colspan="1" rowspan="1" style="border: 1px solid #D0D5DD; padding: 4px 8px"><p style="margin: 0">머리</p></th>',
|
||||
'<td colspan="2" rowspan="1" style="border: 1px solid rgb(208, 213, 219); text-align: right; width: 50%"><p style="margin: 0">칸</p></td></tr></tbody></table>',
|
||||
'<p style="margin: 0"><a href="https://arasystem.it.kr/notice">링크</a> <a href="mailto:ara.kim@arasystem.it.kr">메일</a><br>줄바꿈</p>',
|
||||
'</div>',
|
||||
].join('');
|
||||
|
||||
describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => {
|
||||
describe('편집기 서식은 남긴다', () => {
|
||||
it('글자 서식 — 굵게·기울임·밑줄·취소선, 팔레트 색·배경 색, 목록의 글꼴, px 글자 크기', () => {
|
||||
const html = clean(EDITOR_HTML);
|
||||
|
||||
for (const kept of [
|
||||
'<strong>굵게</strong>',
|
||||
'<b>굵게2</b>',
|
||||
'<em>기울임</em>',
|
||||
'<i>기울임2</i>',
|
||||
'<u>밑줄</u>',
|
||||
'<s>취소선</s>',
|
||||
'<span style="color:#B42318">빨강</span>',
|
||||
'<span style="background-color:#FFF1A8">노랑 배경</span>',
|
||||
'<span style="color:rgb(23, 92, 211)">파랑(브라우저가 되읽은 형식)</span>',
|
||||
`<span style="font-family:'Times New Roman', Times, serif;font-size:18px">Times 18</span>`,
|
||||
'<span style="font-family:굴림, Gulim, sans-serif;font-size:12px">굴림 12</span>',
|
||||
]) {
|
||||
expect(html).toContain(kept);
|
||||
}
|
||||
});
|
||||
|
||||
it('기본 글꼴로 감싼 div · 문단 여백 0 · 정렬 · 들여쓰기(margin·padding-left, px)', () => {
|
||||
const html = clean(EDITOR_HTML);
|
||||
|
||||
expect(
|
||||
html.startsWith(
|
||||
`<div style="font-family:${DEFAULT_FONT};font-size:14px;line-height:1.75">`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(html).toContain('<p style="margin:0;text-align:center">가운데');
|
||||
expect(html).toContain('<p style="margin:0 0 0 40px">들여쓰기</p>');
|
||||
expect(html).toContain(
|
||||
'<p style="margin:0;padding-left:80px">들여쓰기2</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('목록(시작 번호 포함) · 가로줄', () => {
|
||||
const html = clean(EDITOR_HTML);
|
||||
|
||||
expect(html).toContain(
|
||||
'<ul><li><p style="margin:0">항목 1</p></li><li><p style="margin:0">항목 2</p></li></ul>',
|
||||
);
|
||||
expect(html).toContain(
|
||||
'<ol start="3"><li><p style="margin:0">셋</p></li></ol>',
|
||||
);
|
||||
expect(html).toContain('<hr />');
|
||||
});
|
||||
|
||||
it('답장 인용 — 회색 인용 막대(border-left·padding-left·margin·color)와 "원본 메일" 머리 표', () => {
|
||||
const html = clean(EDITOR_HTML);
|
||||
|
||||
expect(html).toContain(
|
||||
'<blockquote style="margin:24px 0 0;border-left:2px solid #D0D5DD;padding-left:14px;color:#4A5363">',
|
||||
);
|
||||
expect(html).toContain(
|
||||
'<p style="margin:0;font-size:12.5px"><strong>원본 메일</strong></p>',
|
||||
);
|
||||
expect(html).toContain(
|
||||
'<table style="border-collapse:collapse;margin:4px 0 12px"><tbody><tr><td style="padding:0 12px 0 0;color:#667085;vertical-align:top;white-space:nowrap">보낸 사람</td><td style="padding:0">이도윤 <doyun.lee@arasystem.it.kr></td></tr>',
|
||||
);
|
||||
});
|
||||
|
||||
it('표 — 칸 합치기·칸 폭(colgroup·%)·테두리·칸 여백·정렬', () => {
|
||||
const html = clean(EDITOR_HTML);
|
||||
|
||||
expect(html).toContain(
|
||||
'<table style="border-collapse:collapse;min-width:75px"><colgroup><col style="width:120px" /><col style="min-width:25px" /></colgroup>',
|
||||
);
|
||||
expect(html).toContain(
|
||||
'<th colspan="1" rowspan="1" style="border:1px solid #D0D5DD;padding:4px 8px"><p style="margin:0">머리</p></th>',
|
||||
);
|
||||
expect(html).toContain(
|
||||
'<td colspan="2" rowspan="1" style="border:1px solid rgb(208, 213, 219);text-align:right;width:50%"><p style="margin:0">칸</p></td>',
|
||||
);
|
||||
});
|
||||
|
||||
it('링크는 http·https·mailto 만 주소째 남긴다 (target·rel 은 받는 쪽이 정한다)', () => {
|
||||
expect(
|
||||
clean(
|
||||
'<a target="_blank" rel="noopener noreferrer nofollow" class="link" href="https://arasystem.it.kr">링크</a>',
|
||||
),
|
||||
).toBe('<a href="https://arasystem.it.kr">링크</a>');
|
||||
expect(clean('<a href="mailto:ara.kim@arasystem.it.kr">메일</a>')).toBe(
|
||||
'<a href="mailto:ara.kim@arasystem.it.kr">메일</a>',
|
||||
);
|
||||
});
|
||||
|
||||
it('같은 입력이면 늘 같은 결과이고, 한 번 더 정화해도 그대로다 (발송 지문이 이 결과를 쓴다)', () => {
|
||||
const once = clean(EDITOR_HTML);
|
||||
|
||||
expect(clean(EDITOR_HTML)).toBe(once);
|
||||
expect(clean(once)).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe('값의 형식 — 단위·표기는 엄격하게, 범위는 붙여 넣은 글의 모양까지', () => {
|
||||
it('줄 간격은 단위 없는 숫자 1~3 (소수 둘째 자리까지) — px·%·em·normal 은 지운다', () => {
|
||||
for (const value of ['1', '1.5', '1.75', '2.25', '3', '3.0', '3.00']) {
|
||||
expect(clean(`<div style="line-height: ${value}">a</div>`)).toBe(
|
||||
`<div style="line-height:${value}">a</div>`,
|
||||
);
|
||||
}
|
||||
for (const value of [
|
||||
'24px',
|
||||
'150%',
|
||||
'1.5em',
|
||||
'normal',
|
||||
'0.9',
|
||||
'3.5',
|
||||
'3.01',
|
||||
'1.755',
|
||||
'-1',
|
||||
'1.',
|
||||
]) {
|
||||
expect(clean(`<div style="line-height: ${value}">a</div>`)).toBe(
|
||||
'<div>a</div>',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('글자 크기는 px 만 (em·rem·%·낱말·100px 이상은 지운다)', () => {
|
||||
for (const size of ['1.5em', '1rem', '120%', 'large', '100px', '-12px']) {
|
||||
expect(clean(`<span style="font-size: ${size}">a</span>`)).toBe(
|
||||
'<span>a</span>',
|
||||
);
|
||||
}
|
||||
expect(clean('<span style="font-size: 32px">a</span>')).toBe(
|
||||
'<span style="font-size:32px">a</span>',
|
||||
);
|
||||
});
|
||||
|
||||
it('글꼴은 목록의 글꼴로만 이뤄진 경우에만 남긴다 — 목록 밖 이름이 하나라도 섞이면 지운다', () => {
|
||||
for (const family of [
|
||||
"'Comic Sans MS'",
|
||||
"Arial, 'Comic Sans MS'",
|
||||
'Papyrus, serif',
|
||||
"'Arial Black'",
|
||||
]) {
|
||||
expect(clean(`<span style="font-family: ${family}">a</span>`)).toBe(
|
||||
'<span>a</span>',
|
||||
);
|
||||
}
|
||||
for (const family of [
|
||||
'Arial',
|
||||
'"Courier New", Courier, monospace',
|
||||
'Verdana, sans-serif',
|
||||
'Georgia, serif',
|
||||
'바탕, Batang, AppleMyungjo, serif',
|
||||
'돋움, Dotum, AppleGothic, sans-serif',
|
||||
]) {
|
||||
expect(clean(`<span style='font-family: ${family}'>a</span>`)).toMatch(
|
||||
/^<span style="font-family:[^"]+">a<\/span>$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('여백·들여쓰기는 0 이나 px 만 (em·%·auto·음수는 지운다)', () => {
|
||||
for (const style of [
|
||||
'margin-left: 2em',
|
||||
'margin: 0 auto',
|
||||
'margin: -10px 0',
|
||||
'padding-left: 5%',
|
||||
'margin-top: -1px',
|
||||
]) {
|
||||
expect(clean(`<p style="${style}">a</p>`)).toBe('<p>a</p>');
|
||||
}
|
||||
});
|
||||
|
||||
it('색은 #rgb·#rrggbb·rgb()/rgba() 만 (색 이름·hsl()·8자리 hex 는 지운다)', () => {
|
||||
for (const color of [
|
||||
'red',
|
||||
'hsl(0, 100%, 50%)',
|
||||
'#12345678',
|
||||
'currentcolor',
|
||||
]) {
|
||||
expect(clean(`<span style="color: ${color}">a</span>`)).toBe(
|
||||
'<span>a</span>',
|
||||
);
|
||||
}
|
||||
expect(
|
||||
clean(
|
||||
'<span style="color: #abc; background-color: rgba(0, 0, 0, 0.5)">a</span>',
|
||||
),
|
||||
).toBe(
|
||||
'<span style="color:#abc;background-color:rgba(0, 0, 0, 0.5)">a</span>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 값 형식 표본 — frontend/src/test-utils/outgoing-style-samples.ts 의 STYLE_SAMPLES 와 **같은 표**다 (행과 순서까지 그대로).
|
||||
* 프론트 편집기는 같은 값 형식(frontend/src/shared/utils/outgoing-html-rules.ts)으로 붙여 넣은 글·원문 인용을 거르고,
|
||||
* 서버는 이 파일이 시험하는 STYLE_RULES 로 거른다 — 두 테스트가 같은 표를 쓰므로 한쪽 규칙만 바뀌면 그쪽 테스트가 실패한다.
|
||||
* ⚠️ 값 형식을 바꾸면 두 표(이 표·outgoing-style-samples.ts)와 두 규칙 파일(sanitize-outgoing-html.ts·outgoing-html-rules.ts)을
|
||||
* 함께 고친다. 값에 큰따옴표는 쓰지 않는다 (style="…" 안에 그대로 넣는다).
|
||||
*/
|
||||
describe('값 형식 표본 — 프론트 편집기와 같은 표', () => {
|
||||
const STYLE_SAMPLES: readonly (readonly [
|
||||
property: string,
|
||||
value: string,
|
||||
kept: boolean,
|
||||
])[] = [
|
||||
// 색 — #rgb · #rrggbb · rgb()/rgba() 만
|
||||
['color', '#B42318', true],
|
||||
['color', '#abc', true],
|
||||
['color', 'rgb(23, 92, 211)', true],
|
||||
['color', 'RGB(1,2,3)', true],
|
||||
['color', 'rgba(0, 0, 0, 0.5)', true],
|
||||
['color', 'red', false],
|
||||
['color', 'transparent', false],
|
||||
['color', 'currentcolor', false],
|
||||
['color', 'hsl(0, 100%, 50%)', false],
|
||||
['color', '#12345678', false],
|
||||
['color', 'rgb(10 20 30)', false],
|
||||
['color', 'rgba(0, 0, 0, 0.12345)', false],
|
||||
['color', 'url(https://evil.example/t.png)', false],
|
||||
['color', 'expression(alert(1))', false],
|
||||
['color', 'var(--text)', false],
|
||||
['background-color', '#FFF1A8', true],
|
||||
['background-color', 'yellow', false],
|
||||
['background-color', 'transparent', false],
|
||||
// 글자 크기 — px 만, 1~99.99px
|
||||
['font-size', '14px', true],
|
||||
['font-size', '12.5px', true],
|
||||
['font-size', '13.33px', true],
|
||||
['font-size', '1px', true],
|
||||
['font-size', '99.99px', true],
|
||||
['font-size', '0', false],
|
||||
['font-size', '0px', false],
|
||||
['font-size', '0.5px', false],
|
||||
['font-size', '13.3333px', false],
|
||||
['font-size', '100px', false],
|
||||
['font-size', '1.2em', false],
|
||||
['font-size', '1rem', false],
|
||||
['font-size', '120%', false],
|
||||
['font-size', 'large', false],
|
||||
['font-size', '-12px', false],
|
||||
['font-size', '12pt', false],
|
||||
// 글꼴 — 목록의 이름으로만 이뤄진 목록
|
||||
['font-family', 'Arial', true],
|
||||
['font-family', "'Times New Roman', Times, serif", true],
|
||||
['font-family', '굴림, Gulim, sans-serif', true],
|
||||
[
|
||||
'font-family',
|
||||
"'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif",
|
||||
true,
|
||||
],
|
||||
['font-family', 'monospace', true],
|
||||
['font-family', 'Calibri, sans-serif', false],
|
||||
['font-family', "'Comic Sans MS'", false],
|
||||
['font-family', "Arial, 'Comic Sans MS'", false],
|
||||
['font-family', 'Roboto', false],
|
||||
['font-family', "'Arial Black'", false],
|
||||
// 줄 간격 — 단위 없는 1~3
|
||||
['line-height', '1', true],
|
||||
['line-height', '1.75', true],
|
||||
['line-height', '3', true],
|
||||
['line-height', '3.00', true],
|
||||
['line-height', '30px', false],
|
||||
['line-height', '150%', false],
|
||||
['line-height', '1.5em', false],
|
||||
['line-height', 'normal', false],
|
||||
['line-height', '0.9', false],
|
||||
['line-height', '3.5', false],
|
||||
['line-height', '1.755', false],
|
||||
// 정렬 · 굵기
|
||||
['text-align', 'left', true],
|
||||
['text-align', 'center', true],
|
||||
['text-align', 'justify', true],
|
||||
['text-align', 'middle', false],
|
||||
['text-align', 'start', false],
|
||||
['font-weight', 'bold', true],
|
||||
['font-weight', '700', true],
|
||||
['font-weight', 'bolder', false],
|
||||
// 여백 · 들여쓰기 — 0 또는 px (음수 없음)
|
||||
['margin-left', '0', true],
|
||||
['margin-left', '40px', true],
|
||||
['margin-left', '320px', true],
|
||||
['margin-left', '-10px', false],
|
||||
['margin-left', '2em', false],
|
||||
['margin-left', '5%', false],
|
||||
['margin-left', '1000px', false],
|
||||
['margin', '0', true],
|
||||
['margin', '24px 0 0', true],
|
||||
['margin', '0 auto', false],
|
||||
['padding-left', '14px', true],
|
||||
['padding-left', '5%', false],
|
||||
// 표 — 칸 폭 · 테두리 · 세로 정렬 · 줄바꿈 막기
|
||||
['width', '120px', true],
|
||||
['width', '50%', true],
|
||||
['width', 'auto', true],
|
||||
['width', '1000px', false],
|
||||
['width', '-5px', false],
|
||||
['min-width', '25px', true],
|
||||
['border', '1px solid #D0D5DD', true],
|
||||
['border', '1px solid rgb(208, 213, 219)', true],
|
||||
['border', '0', true],
|
||||
['border', '1px dotted red', false],
|
||||
['border-left', '2px solid #D0D5DD', true],
|
||||
['border-collapse', 'collapse', true],
|
||||
['border-collapse', 'inherit', false],
|
||||
['vertical-align', 'top', true],
|
||||
['vertical-align', 'sub', false],
|
||||
['white-space', 'nowrap', true],
|
||||
['white-space', 'normal', true],
|
||||
['white-space', 'pre-wrap', false],
|
||||
['white-space', 'pre', false],
|
||||
// 목록에 없는 속성 — 값과 관계없이 지운다
|
||||
['display', 'none', false],
|
||||
['visibility', 'hidden', false],
|
||||
['opacity', '0', false],
|
||||
['position', 'absolute', false],
|
||||
['text-decoration', 'underline', false],
|
||||
];
|
||||
|
||||
it.each(STYLE_SAMPLES)(
|
||||
'%s: %s — 서버가 남기는가 %s',
|
||||
(property, value, kept) => {
|
||||
// 문단 안의 span 에 선언 하나 — 남으면 그 선언 그대로(정규화한 "속성:값"), 아니면 style 이 통째로 빠진다
|
||||
const output = clean(
|
||||
`<p><span style="${property}: ${value}">글</span></p>`,
|
||||
);
|
||||
|
||||
expect(output).toBe(
|
||||
kept
|
||||
? `<p><span style="${property}:${value}">글</span></p>`
|
||||
: '<p><span>글</span></p>',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('그 밖은 지운다', () => {
|
||||
it('script·style 태그는 내용까지 지운다', () => {
|
||||
expect(
|
||||
clean(
|
||||
'<p>본문</p><script>alert(1)</script><style>p{color:red}</style>',
|
||||
),
|
||||
).toBe('<p>본문</p>');
|
||||
});
|
||||
|
||||
it('이벤트 속성·id·class·data-* 는 남기지 않는다', () => {
|
||||
expect(
|
||||
clean(
|
||||
'<p id="x" class="ProseMirror" data-type="paragraph" onclick="steal()" onmouseover="x()" style="margin:0">a</p>',
|
||||
),
|
||||
).toBe('<p style="margin:0">a</p>');
|
||||
});
|
||||
|
||||
it('javascript: 등 http·https·mailto 가 아닌 링크는 글자만 남긴다', () => {
|
||||
for (const href of [
|
||||
'javascript:alert(1)',
|
||||
'JaVaScRiPt:alert(1)',
|
||||
'java	script:alert(1)',
|
||||
' javascript:alert(1)',
|
||||
'vbscript:msgbox(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'//evil.example/x',
|
||||
'/relative',
|
||||
'#anchor',
|
||||
]) {
|
||||
expect(clean(`<a href="${href}">눌러</a>`)).toBe('<span>눌러</span>');
|
||||
}
|
||||
});
|
||||
|
||||
it('style 의 url()·expression()·CSS 이스케이프는 속성째 지운다', () => {
|
||||
for (const style of [
|
||||
'background-color: url(https://evil.example/t.png)',
|
||||
"color: url('x')",
|
||||
'font-family: url(x)',
|
||||
'color: expression(alert(1))',
|
||||
'width: expression(alert(1))',
|
||||
'color: \\72 ed',
|
||||
'background-image: url(https://evil.example/t.png)',
|
||||
'background: url(https://evil.example/t.png)',
|
||||
'behavior: url(x.htc)',
|
||||
'-moz-binding: url(x.xml#a)',
|
||||
]) {
|
||||
expect(clean(`<span style="${style}">a</span>`)).toBe('<span>a</span>');
|
||||
}
|
||||
});
|
||||
|
||||
it('style 안의 주석은 지우고, 닫는 괄호로 다른 규칙을 끼워 넣어도 첫 선언만 남는다', () => {
|
||||
expect(clean('<span style="color: #b42318/* x */">a</span>')).toBe(
|
||||
'<span style="color:#b42318">a</span>',
|
||||
);
|
||||
expect(
|
||||
clean('<span style="color: #b42318} a {color: #000">a</span>'),
|
||||
).toBe('<span style="color:#b42318">a</span>');
|
||||
});
|
||||
|
||||
it('목록에 없는 속성은 지운다 — 위치 이동·겹침·임의 정렬 낱말', () => {
|
||||
expect(
|
||||
clean(
|
||||
'<p style="position: absolute; top: 0; z-index: 9; display: none; text-align: middle-ish; font-size: 14px">a</p>',
|
||||
),
|
||||
).toBe('<p style="font-size:14px">a</p>');
|
||||
});
|
||||
|
||||
it('외부 이미지·인라인 이미지는 태그째 사라진다 (본문 이미지는 첨부로 보낸다)', () => {
|
||||
expect(
|
||||
clean(
|
||||
'<p>앞<img src="https://evil.example/pixel.gif" width="1" height="1">뒤</p><img src="cid:logo@x"><img src="data:image/png;base64,iVBORw0KGgo=">',
|
||||
),
|
||||
).toBe('<p>앞뒤</p>');
|
||||
});
|
||||
|
||||
it('폼·iframe·svg·object 는 내용까지 지우고, 모르는 태그는 글자만 남긴다', () => {
|
||||
expect(
|
||||
clean(
|
||||
'<form action="https://evil.example"><input name="pw"><button>보내기</button></form><iframe src="https://evil.example"></iframe><svg><script>alert(1)</script></svg><object data="x.swf"></object><h1>제목</h1><mark>강조</mark>',
|
||||
),
|
||||
).toBe('제목강조');
|
||||
});
|
||||
|
||||
it('칸 합치기·시작 번호는 숫자만 남긴다', () => {
|
||||
expect(
|
||||
clean(
|
||||
'<ol start="3x"><li>a</li></ol><table><tbody><tr><td colspan="2;" rowspan="1">b</td></tr></tbody></table>',
|
||||
),
|
||||
).toBe(
|
||||
'<ol><li>a</li></ol><table><tbody><tr><td rowspan="1">b</td></tr></tbody></table>',
|
||||
);
|
||||
});
|
||||
|
||||
it('서식이 모두 지워지면 빈 문자열 — 서비스는 텍스트 메일로 보낸다', () => {
|
||||
expect(clean('<script>alert(1)</script>')).toBe('');
|
||||
expect(clean(' ')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 실제 편집기가 보내는 모양 — 브라우저가 style 을 정규화해 쓴다(값 끝의 `;`·쉼표 뒤 공백·따옴표 없는 한글 글꼴 이름).
|
||||
* 보내는 정화와 받은 메일 정화를 모두 지나도 남아야 한다.
|
||||
*/
|
||||
describe('실제 편집기 표본 (브라우저가 정규화한 style)', () => {
|
||||
const samples: [string, string][] = [
|
||||
[
|
||||
'<span style="color: rgb(180, 35, 24);">빨강</span>',
|
||||
'<span style="color:rgb(180, 35, 24)">빨강</span>',
|
||||
],
|
||||
[
|
||||
'<span style="font-family: Gulim, 굴림, sans-serif;">굴림</span>',
|
||||
'<span style="font-family:Gulim, 굴림, sans-serif">굴림</span>',
|
||||
],
|
||||
[
|
||||
'<table style="min-width: 75px;"><colgroup><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1"><p style="margin: 0">칸</p></td></tr></tbody></table>',
|
||||
'<table style="min-width:75px"><colgroup><col style="min-width:25px" /></colgroup><tbody><tr><td colspan="1" rowspan="1"><p style="margin:0">칸</p></td></tr></tbody></table>',
|
||||
],
|
||||
['<p style="margin: 0">문단</p>', '<p style="margin:0">문단</p>'],
|
||||
// 빈 문단 — Enter 만 친 줄. 줄바꿈이 남아야 받는 쪽에서도 한 줄이 비어 보인다
|
||||
['<p style="margin: 0"><br></p>', '<p style="margin:0"><br /></p>'],
|
||||
[
|
||||
`<div style="font-family: ${DEFAULT_FONT}; font-size: 14px; line-height: 1.75;"><p style="margin: 0">본문</p></div>`,
|
||||
`<div style="font-family:${DEFAULT_FONT};font-size:14px;line-height:1.75"><p style="margin:0">본문</p></div>`,
|
||||
],
|
||||
];
|
||||
|
||||
it.each(samples)('보낼 때 남는다: %s', (input, expected) => {
|
||||
expect(clean(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(samples)(
|
||||
'다시 읽어도 그대로다 (받은 메일 정화): %s',
|
||||
(input, expected) => {
|
||||
expect(sanitizeMailHtml(clean(input)).html).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('다시 읽기 (보낸편지함·받는 사람)', () => {
|
||||
it('받은 메일 정화를 거쳐도 서식이 그대로 남는다 — 링크에 새 창 속성만 더해진다', () => {
|
||||
const sent = clean(EDITOR_HTML);
|
||||
const reRead = sanitizeMailHtml(sent);
|
||||
|
||||
expect(reRead.hasBlockedImages).toBe(false);
|
||||
expect(reRead.html).toBe(
|
||||
sent.replace(
|
||||
/<a href="([^"]+)">/g,
|
||||
'<a href="$1" target="_blank" rel="noopener noreferrer">',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('색·크기·글꼴·정렬·문단 여백·목록·인용·표가 다시 읽은 본문에 있다', () => {
|
||||
const { html } = sanitizeMailHtml(clean(EDITOR_HTML));
|
||||
|
||||
for (const kept of [
|
||||
'color:#B42318',
|
||||
'background-color:#FFF1A8',
|
||||
'font-size:18px',
|
||||
`font-family:${DEFAULT_FONT}`,
|
||||
'text-align:center',
|
||||
'<p style="margin:0">',
|
||||
'margin:0 0 0 40px',
|
||||
'<ol start="3">',
|
||||
'border-left:2px solid #D0D5DD;padding-left:14px;color:#4A5363',
|
||||
'vertical-align:top;white-space:nowrap',
|
||||
'line-height:1.75',
|
||||
'<td colspan="2" rowspan="1"',
|
||||
'<col style="width:120px" />',
|
||||
]) {
|
||||
expect(html).toContain(kept);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
import { NON_TEXT_TAGS, SAFE_LINK } from './sanitize-mail-html';
|
||||
|
||||
/**
|
||||
* 보내는 메일의 서식 본문 정화 (순수 함수) — 계약 2절 "서식 본문 (bodyHtml)".
|
||||
*
|
||||
* 받은 메일 정화(sanitize-mail-html.ts)와 **허용 목록을 따로** 둔다.
|
||||
* 받은 메일은 남이 만든 HTML 을 되도록 그대로 보여 주는 것이 목적이라 목록이 넓지만,
|
||||
* 보내는 메일은 우리 편집기(TipTap)가 만드는 서식만 있으면 된다 — 좁을수록 우리 서버가 남에게 보내는 HTML 에
|
||||
* 끼워 넣을 수 있는 것이 줄어든다.
|
||||
*
|
||||
* - 태그: 문단·줄바꿈, 굵게·기울임·밑줄·취소선, span(글자 서식), 링크, 목록, 인용, 가로줄, 표, 감싸는 div
|
||||
* - style: **속성마다 값의 형식까지** 검사한다 (색·길이·글꼴 이름·정렬 낱말). 괄호는 rgb()·rgba() 색에만 쓸 수 있어
|
||||
* url()·expression()·CSS 이스케이프가 들어갈 자리가 없다
|
||||
* - 링크: http·https·mailto 만. 그 밖의 링크는 글자만 남긴다
|
||||
* - 이미지·폼·스크립트·이벤트 속성·id·class·data-* 는 남기지 않는다 (본문 이미지는 이번 단계에서 넣지 않는다 — 첨부로)
|
||||
*
|
||||
* 같은 입력이면 늘 같은 결과다 — 발송 중복 방지의 내용 지문이 이 결과를 쓴다 (send-idempotency.ts).
|
||||
* 우리 웹메일이 다시 읽을 때는 받은 메일 정화를 한 번 더 거친다 — 여기서 남긴 서식은 그쪽 목록에도 모두 있다
|
||||
* (sanitize-outgoing-html.spec.ts 의 "다시 읽기" 테스트가 지킨다).
|
||||
*
|
||||
* ⚠️ 부르기 전에 html-parse-budget.ts 의 `checkHtmlParseBudget` 으로 중첩 비용을 보고, 부른 뒤에는 결과의 바이트 수를 본다
|
||||
* (mail.service.ts 의 sanitizeBodyHtml — 둘 다 넘으면 VAL_001).
|
||||
*
|
||||
* ⚠️ 프론트에 짝이 있다 — frontend/src/shared/utils/outgoing-html-rules.ts 가 아래 STYLE_RULES · OUTGOING_FONT_NAMES ·
|
||||
* SMALL_NUMBER 와 **같은 값 형식**을 둔다. 쓰기 화면의 편집기가 붙여 넣은 글·원문 인용의 HTML 을 읽어 들일 때 그 형식에 맞는
|
||||
* 값만 받으므로, 편집기에 보이는 모양 = 서버가 남기는 모양이다.
|
||||
* 두 쪽이 어긋나지 않게 같은 표본 표를 양쪽 테스트에 둔다 — frontend/src/test-utils/outgoing-style-samples.ts 의 STYLE_SAMPLES 와
|
||||
* sanitize-outgoing-html.spec.ts 의 "값 형식 표본" (행과 순서까지 같다). 값 형식을 바꾸면 두 규칙 파일과 두 표를 함께 고친다.
|
||||
*/
|
||||
|
||||
const ALLOWED_TAGS = [
|
||||
// 감싸는 div(기본 글꼴) · 문단 · 줄바꿈
|
||||
'div',
|
||||
'p',
|
||||
'br',
|
||||
// 글자 서식
|
||||
'span',
|
||||
'strong',
|
||||
'b',
|
||||
'em',
|
||||
'i',
|
||||
'u',
|
||||
's',
|
||||
// 링크
|
||||
'a',
|
||||
// 목록
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
// 인용 · 가로줄
|
||||
'blockquote',
|
||||
'hr',
|
||||
// 표 — 편집기는 칸 폭을 colgroup/col 의 style 로 적는다
|
||||
'table',
|
||||
'colgroup',
|
||||
'col',
|
||||
'thead',
|
||||
'tbody',
|
||||
'tr',
|
||||
'th',
|
||||
'td',
|
||||
];
|
||||
|
||||
const ALLOWED_ATTRIBUTES: Record<string, string[]> = {
|
||||
'*': ['style'],
|
||||
a: ['href'],
|
||||
ol: ['start'],
|
||||
td: ['colspan', 'rowspan'],
|
||||
th: ['colspan', 'rowspan'],
|
||||
};
|
||||
|
||||
// ── style 값 형식 ───────────────────────────────────────────────────────────
|
||||
// 모두 처음부터 끝까지(^…$) 맞아야 한다. 값에 쓸 수 있는 글자가 정해져 있어 함수·주석·이스케이프가 끼어들 수 없다.
|
||||
// 형식(단위·글꼴 목록·색 표기)은 엄격하지만 값의 범위는 쓰기 화면의 메뉴(docs/design/webmail/README.md "메일 쓰기")보다
|
||||
// **넓다** — 붙여 넣거나 답장·전달로 인용한 글의 모양도 지키기 위해서다:
|
||||
// 글자 크기 1~99.99px(메뉴는 12~32px), 여백·들여쓰기·테두리 두께 0~999.99px(음수 없음),
|
||||
// 색 #rgb·#rrggbb·rgb()·rgba()(알파 0 포함 — 브라우저가 색을 되읽으면 rgb()/rgba() 로 바꾼다), 글꼴은 아래 목록의 이름만.
|
||||
// 쓰기 화면의 편집기도 같은 규칙에 맞는 값만 남긴다 (편집기에 보이는 모양 = 보내는 모양) — 한쪽을 바꾸면 다른 쪽도 함께 바꾼다.
|
||||
|
||||
/** px 길이 — 0 또는 0~999.99px. 음수는 받지 않는다 (음수 여백으로 다른 내용을 덮는 화면 위장 방지) */
|
||||
const PX = String.raw`(?:0|\d{1,3}(?:\.\d{1,2})?px)`;
|
||||
/** 칸 폭 — px · % · auto */
|
||||
const WIDTH = String.raw`(?:${PX}|\d{1,3}(?:\.\d{1,2})?%|auto)`;
|
||||
/** 색 — #rgb · #rrggbb · rgb()/rgba() (괄호 안은 숫자만) */
|
||||
const COLOR = String.raw`(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d{1,3})\s*)?\))`;
|
||||
const BORDER_STYLE = String.raw`(?:none|hidden|dotted|dashed|solid|double)`;
|
||||
const BORDER_WIDTH = String.raw`(?:${PX}|thin|medium|thick)`;
|
||||
const BORDER_PART = String.raw`(?:${BORDER_WIDTH}|${BORDER_STYLE}|${COLOR})`;
|
||||
/** 글자 크기 — px 만, 1~99.99px (메뉴는 12·13·14·16·18·20·24·32, 인용 머리 12.5 — 붙여 넣은 글의 크기도 남긴다) */
|
||||
const FONT_SIZE = String.raw`(?:[1-9]\d?(?:\.\d{1,2})?px)`;
|
||||
/** 줄 간격 — 단위 없는 숫자 1~3, 소수 둘째 자리까지 (편집기 기본 1.75). px·%·em·normal 은 받지 않는다 */
|
||||
const LINE_HEIGHT = String.raw`(?:[12](?:\.\d{1,2})?|3(?:\.0{1,2})?)`;
|
||||
|
||||
/**
|
||||
* 받는 글꼴 이름 — 디자인의 글꼴 목록(맑은 고딕(기본) · 굴림 · 돋움 · 바탕 | Arial · Verdana · Georgia ·
|
||||
* Times New Roman · Courier New)과 기본 스택(`'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif`),
|
||||
* 그리고 흔히 함께 적는 대체 글꼴·일반 글꼴 이름. 목록 밖 이름이 하나라도 섞인 font-family 는 지운다.
|
||||
*/
|
||||
export const OUTGOING_FONT_NAMES = [
|
||||
'Malgun Gothic',
|
||||
'맑은 고딕',
|
||||
'Apple SD Gothic Neo',
|
||||
'Gulim',
|
||||
'굴림',
|
||||
'Dotum',
|
||||
'돋움',
|
||||
'Batang',
|
||||
'바탕',
|
||||
'AppleGothic',
|
||||
'AppleMyungjo',
|
||||
'Arial',
|
||||
'Helvetica',
|
||||
'Verdana',
|
||||
'Georgia',
|
||||
'Times New Roman',
|
||||
'Times',
|
||||
'Courier New',
|
||||
'Courier',
|
||||
'sans-serif',
|
||||
'serif',
|
||||
'monospace',
|
||||
] as const;
|
||||
|
||||
/** 정규식 특수 문자를 글자 그대로 쓰게 한다 */
|
||||
const escapeRegExp = (text: string) =>
|
||||
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
/** 글꼴 이름 하나 — 그대로 · 작은따옴표 · 큰따옴표 (긴 이름부터 맞춰 본다) */
|
||||
const FONT_NAME = (() => {
|
||||
const names = [...OUTGOING_FONT_NAMES]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.map(escapeRegExp)
|
||||
.join('|');
|
||||
return `(?:(?:${names})|'(?:${names})'|"(?:${names})")`;
|
||||
})();
|
||||
|
||||
const exactly = (pattern: string, flags = 'i') =>
|
||||
new RegExp(`^${pattern}$`, flags);
|
||||
|
||||
const COLOR_VALUE = exactly(COLOR);
|
||||
const PX_VALUE = exactly(PX);
|
||||
const WIDTH_VALUE = exactly(WIDTH);
|
||||
/** margin·padding 줄임형 — px 길이 1~4개 */
|
||||
const BOX_VALUE = exactly(String.raw`${PX}(?:\s+${PX}){0,3}`);
|
||||
const BORDER_VALUE = exactly(
|
||||
String.raw`${BORDER_PART}(?:\s+${BORDER_PART}){0,2}`,
|
||||
);
|
||||
const FONT_FAMILY_VALUE = exactly(
|
||||
String.raw`${FONT_NAME}(?:\s*,\s*${FONT_NAME}){0,9}`,
|
||||
);
|
||||
const FONT_SIZE_VALUE = exactly(FONT_SIZE);
|
||||
const LINE_HEIGHT_VALUE = exactly(LINE_HEIGHT);
|
||||
|
||||
/**
|
||||
* 남기는 style 속성과 값 형식:
|
||||
* - 글자: 색·배경 색·글꼴·글자 크기·굵기(인용 머리 "원본 메일")
|
||||
* - 문단: 정렬, 여백(margin — 문단 사이 여백 0 을 인라인으로 보낸다), 들여쓰기(margin-left·padding-left),
|
||||
* 줄 간격(line-height — 감싸는 div 에 편집기와 같은 1.75 를 적는다)
|
||||
* - 인용 막대: border-left·padding-left·margin·color
|
||||
* - 표(원본 메일 머리 표 포함): 테두리·칸 합치기·칸 폭·칸 여백·세로 정렬·줄바꿈 막기
|
||||
* 어느 태그에 오든 같은 규칙이다 (편집기가 서식을 문단·칸·목록 어디에 붙이든 남도록).
|
||||
*/
|
||||
const STYLE_RULES: Record<string, RegExp[]> = {
|
||||
color: [COLOR_VALUE],
|
||||
'background-color': [COLOR_VALUE],
|
||||
'font-family': [FONT_FAMILY_VALUE],
|
||||
'font-size': [FONT_SIZE_VALUE],
|
||||
'font-weight': [exactly('(?:normal|bold|[1-9]00)')],
|
||||
'text-align': [exactly('(?:left|right|center|justify)')],
|
||||
margin: [BOX_VALUE],
|
||||
'margin-top': [PX_VALUE],
|
||||
'margin-right': [PX_VALUE],
|
||||
'margin-bottom': [PX_VALUE],
|
||||
'margin-left': [PX_VALUE],
|
||||
padding: [BOX_VALUE],
|
||||
'padding-top': [PX_VALUE],
|
||||
'padding-right': [PX_VALUE],
|
||||
'padding-bottom': [PX_VALUE],
|
||||
'padding-left': [PX_VALUE],
|
||||
border: [BORDER_VALUE],
|
||||
'border-top': [BORDER_VALUE],
|
||||
'border-right': [BORDER_VALUE],
|
||||
'border-bottom': [BORDER_VALUE],
|
||||
'border-left': [BORDER_VALUE],
|
||||
'border-color': [COLOR_VALUE],
|
||||
'border-style': [exactly(BORDER_STYLE)],
|
||||
'border-width': [exactly(BORDER_WIDTH)],
|
||||
'border-collapse': [exactly('(?:collapse|separate)')],
|
||||
width: [WIDTH_VALUE],
|
||||
'min-width': [WIDTH_VALUE],
|
||||
'vertical-align': [exactly('(?:top|middle|bottom|baseline)')],
|
||||
'white-space': [exactly('(?:normal|nowrap)')],
|
||||
'line-height': [LINE_HEIGHT_VALUE],
|
||||
};
|
||||
|
||||
/** 목록 시작 번호·칸 합치기 값 — 숫자만 */
|
||||
const SMALL_NUMBER = /^\d{1,4}$/;
|
||||
|
||||
function keepNumbers(
|
||||
attribs: sanitizeHtml.Attributes,
|
||||
names: readonly string[],
|
||||
): sanitizeHtml.Attributes {
|
||||
const next: sanitizeHtml.Attributes = { ...attribs };
|
||||
for (const name of names) {
|
||||
const value = next[name]?.trim();
|
||||
if (value === undefined) continue;
|
||||
if (SMALL_NUMBER.test(value)) next[name] = value;
|
||||
else delete next[name];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 링크는 http·https·mailto 만 — 그 밖이면 글자만 남긴다 (링크 모양의 빈 a 를 남기지 않는다) */
|
||||
function keepSafeLink(attribs: sanitizeHtml.Attributes): sanitizeHtml.Tag {
|
||||
const href = attribs.href?.trim();
|
||||
return href && SAFE_LINK.test(href)
|
||||
? { tagName: 'a', attribs: { href } }
|
||||
: { tagName: 'span', attribs: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* 서식 본문을 정화한다. 결과가 비면(서식 없는 빈 입력·모두 지워진 경우) 빈 문자열이다 —
|
||||
* 그때 서비스는 텍스트 메일로 보낸다.
|
||||
*/
|
||||
export function sanitizeOutgoingHtml(html: string): string {
|
||||
return sanitizeHtml(html, {
|
||||
allowedTags: ALLOWED_TAGS,
|
||||
allowedAttributes: ALLOWED_ATTRIBUTES,
|
||||
allowedSchemes: [],
|
||||
allowedSchemesByTag: { a: ['http', 'https', 'mailto'] },
|
||||
allowProtocolRelative: false,
|
||||
allowedStyles: { '*': STYLE_RULES },
|
||||
nonTextTags: NON_TEXT_TAGS,
|
||||
disallowedTagsMode: 'discard',
|
||||
transformTags: {
|
||||
a: (_tagName, attribs) => keepSafeLink(attribs),
|
||||
ol: (tagName, attribs) => ({
|
||||
tagName,
|
||||
attribs: keepNumbers(attribs, ['start']),
|
||||
}),
|
||||
td: (tagName, attribs) => ({
|
||||
tagName,
|
||||
attribs: keepNumbers(attribs, ['colspan', 'rowspan']),
|
||||
}),
|
||||
th: (tagName, attribs) => ({
|
||||
tagName,
|
||||
attribs: keepNumbers(attribs, ['colspan', 'rowspan']),
|
||||
}),
|
||||
},
|
||||
}).trim();
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { join } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants';
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type { OutgoingMessage } from '@/modules/mail/mail.types';
|
||||
import { JMAP_SEND_TIMEOUT_MS } from './jmap.client';
|
||||
@@ -584,6 +585,8 @@ describe('JmapMailProvider', () => {
|
||||
ids: ['m1'],
|
||||
fetchHTMLBodyValues: true,
|
||||
fetchTextBodyValues: true,
|
||||
// 발송 때 정화한 서식 본문을 막는 바이트 수와 같은 상수 — 우리가 보낸 메일은 다시 읽어도 잘리지 않는다
|
||||
maxBodyValueBytes: MAX_BODY_VALUE_BYTES,
|
||||
});
|
||||
expect(recorded[0].calls.map(([name]) => name)).toEqual(['Email/get']);
|
||||
expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' });
|
||||
@@ -877,6 +880,38 @@ describe('JmapMailProvider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('서식 본문이 있으면 textBody + htmlBody 로 만든다 — 엔진이 multipart/alternative 로 보낸다', async () => {
|
||||
const { provider, recorded } = fakeClient([prepared, fits, sent]);
|
||||
const html =
|
||||
'<div style="font-size:14px"><p><strong>확인</strong>했습니다.</p></div>';
|
||||
|
||||
await provider.sendMessage(ARA, { ...outgoing, htmlBody: html });
|
||||
|
||||
const draft = (argsOf(recorded[2], 0).create as Record<string, unknown>)
|
||||
.draft as Record<string, unknown>;
|
||||
expect(draft.bodyValues).toEqual({
|
||||
body: { value: '도윤님, 확인했습니다.' },
|
||||
html: { value: html },
|
||||
});
|
||||
expect(draft.textBody).toEqual([{ partId: 'body', type: 'text/plain' }]);
|
||||
expect(draft.htmlBody).toEqual([{ partId: 'html', type: 'text/html' }]);
|
||||
// 첨부는 그대로 — 엔진이 multipart/mixed 안에 alternative 를 넣는다
|
||||
expect(draft.attachments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('서식 본문이 없으면 htmlBody 를 만들지 않는다 (텍스트 메일)', async () => {
|
||||
const { provider, recorded } = fakeClient([prepared, fits, sent]);
|
||||
|
||||
await provider.sendMessage(ARA, outgoing);
|
||||
|
||||
const draft = (argsOf(recorded[2], 0).create as Record<string, unknown>)
|
||||
.draft as Record<string, unknown>;
|
||||
expect(draft).not.toHaveProperty('htmlBody');
|
||||
expect(draft.bodyValues).toEqual({
|
||||
body: { value: '도윤님, 확인했습니다.' },
|
||||
});
|
||||
});
|
||||
|
||||
it('중복 방지 Message-ID 와 내용 지문을 초안에 싣는다 (지문은 사용자 헤더)', async () => {
|
||||
const { provider, recorded } = fakeClient([prepared, fits, sent]);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { openAsBlob } from 'fs';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { resolveAttachmentsTotalLimit } from '@/modules/mail/attachment.policy';
|
||||
import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants';
|
||||
import type { MailProvider } from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type {
|
||||
@@ -87,9 +88,6 @@ const USAGE_SCAN_PAGE_SIZE = 500;
|
||||
const USAGE_SCAN_MAX_EMAILS = 50_000;
|
||||
const USAGE_CACHE_TTL_MS = 5 * 60_000;
|
||||
|
||||
/** 상세 조회 때 본문 값을 읽어 오는 상한 — 넘으면 엔진이 잘라서 준다 */
|
||||
const MAX_BODY_VALUE_BYTES = 1_000_000;
|
||||
|
||||
/** References 헤더에 이어 붙이는 Message-ID 개수 상한 (긴 스레드에서 헤더가 끝없이 커지지 않게) */
|
||||
const MAX_REFERENCES = 20;
|
||||
|
||||
@@ -332,6 +330,8 @@ export class JmapMailProvider implements MailProvider {
|
||||
bodyProperties: BODY_PART_PROPERTIES,
|
||||
fetchHTMLBodyValues: true,
|
||||
fetchTextBodyValues: true,
|
||||
// 본문 한 부분을 이 바이트까지 읽는다 — 넘는 부분은 엔진이 잘라서 준다.
|
||||
// 우리가 보낸 서식 본문은 발송 때 정화 결과를 같은 바이트 수로 막았으므로 잘리지 않는다 (mail.constants.ts)
|
||||
maxBodyValueBytes: MAX_BODY_VALUE_BYTES,
|
||||
},
|
||||
'g',
|
||||
@@ -514,8 +514,15 @@ export class JmapMailProvider implements MailProvider {
|
||||
...(message.bcc.length ? { bcc: message.bcc.map(toJmapAddress) } : {}),
|
||||
subject: message.subject,
|
||||
...(thread ?? {}),
|
||||
bodyValues: { body: { value: message.body } },
|
||||
// 서식 본문이 있으면 textBody + htmlBody → 엔진이 multipart/alternative 로 만든다 (RFC 8621 §4.6)
|
||||
bodyValues: {
|
||||
body: { value: message.body },
|
||||
...(message.htmlBody ? { html: { value: message.htmlBody } } : {}),
|
||||
},
|
||||
textBody: [{ partId: 'body', type: 'text/plain' }],
|
||||
...(message.htmlBody
|
||||
? { htmlBody: [{ partId: 'html', type: 'text/html' }] }
|
||||
: {}),
|
||||
...(message.attachments.length
|
||||
? {
|
||||
attachments: message.attachments.map((attachment) => ({
|
||||
|
||||
@@ -14,9 +14,25 @@ export const MAX_RECIPIENTS = 100;
|
||||
/** 제목 최대 길이 */
|
||||
export const MAX_SUBJECT_LENGTH = 255;
|
||||
|
||||
/** 본문(일반 텍스트) 최대 길이 */
|
||||
/** 본문(일반 텍스트) 최대 길이 — 서식 본문이 있으면 그 대체 본문이다 */
|
||||
export const MAX_BODY_LENGTH = 100_000;
|
||||
|
||||
/** 서식 본문(bodyHtml) 최대 길이 — 서식 편집기가 만든 HTML (계약 2절) */
|
||||
export const MAX_BODY_HTML_LENGTH = 500_000;
|
||||
|
||||
/**
|
||||
* 본문 한 부분을 읽는 상한 (UTF-8 바이트) — 상세 조회는 이 크기까지 읽고, 넘는 부분은 엔진이 잘라서 준다.
|
||||
* 보내는 서식 본문은 **정화한 결과**가 이 크기를 넘으면 보내지 않는다(VAL_001) — 우리 웹메일이 다시 읽을 때
|
||||
* (보낸편지함·받는 사람) 표시 없이 잘리지 않게. 500,000자 한도만으로는 부족하다: 정화가 `&` 를 `&` 로 늘리고
|
||||
* 한글·이모지는 글자당 3~4바이트라 같은 글자 수가 2MB 를 넘을 수 있다.
|
||||
*/
|
||||
export const MAX_BODY_VALUE_BYTES = 2_000_000;
|
||||
|
||||
/**
|
||||
* 메일 라우트 경로 (`/api/messages`) — 컨트롤러와, 발송 요청만 본문 한도를 늘리는 파서(app.setup.ts)가 같이 쓴다.
|
||||
*/
|
||||
export const MESSAGES_ROUTE = 'messages';
|
||||
|
||||
/** 검색어 최대 길이 */
|
||||
export const MAX_SEARCH_LENGTH = 200;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { SendMessageDto } from './dto/send-message.dto';
|
||||
import { MAX_HTML_PARSE_LENGTH } from './html/html-parse-budget';
|
||||
import { MAX_BODY_VALUE_BYTES } from './mail.constants';
|
||||
import { MailService } from './mail.service';
|
||||
import type { MailProvider } from './mail.provider';
|
||||
import type { MailSettings } from './mail.settings';
|
||||
@@ -108,6 +110,7 @@ describe('MailService', () => {
|
||||
let provider: ProviderMocks;
|
||||
let service: MailService;
|
||||
let logSpy: jest.SpyInstance;
|
||||
let warnSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = fakeProvider();
|
||||
@@ -115,7 +118,9 @@ describe('MailService', () => {
|
||||
logSpy = jest
|
||||
.spyOn(Logger.prototype, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
@@ -310,6 +315,48 @@ describe('MailService', () => {
|
||||
htmlBody: '<p>본문</p>',
|
||||
});
|
||||
});
|
||||
|
||||
it('정화 한도를 넘는 HTML(깊은 중첩·이어 붙여 너무 긴 본문)은 정화하지 않고 텍스트 본문만 내려준다 — 내장 이미지도 읽지 않는다', async () => {
|
||||
const logo = {
|
||||
attachmentId: 'blob-logo',
|
||||
cid: 'logo@x',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
};
|
||||
const image = '<img src="cid:logo@x">';
|
||||
for (const rawHtmlBody of [
|
||||
// 깊은 중첩 2MB — 정화하면 3분 가까이 이벤트 루프가 멈춘다
|
||||
image +
|
||||
'<b>'.repeat(Math.floor((MAX_HTML_PARSE_LENGTH - image.length) / 3)),
|
||||
// HTML 부분 여럿을 이어 붙여 길이 한도를 넘은 본문 (2,250,000자 남짓)
|
||||
image + '<p>본문</p>'.repeat(MAX_HTML_PARSE_LENGTH / 8),
|
||||
]) {
|
||||
provider.getMessage.mockResolvedValue(
|
||||
source({ rawHtmlBody, textBody: '텍스트 본문', inlineParts: [logo] }),
|
||||
);
|
||||
|
||||
const started = performance.now();
|
||||
const detail = await service.getMessage(user, 'm1');
|
||||
|
||||
expect(detail).toMatchObject({
|
||||
htmlBody: null,
|
||||
textBody: '텍스트 본문',
|
||||
hasBlockedImages: false,
|
||||
});
|
||||
expect(performance.now() - started).toBeLessThan(2_000);
|
||||
}
|
||||
expect(provider.readAttachmentBytes).not.toHaveBeenCalled();
|
||||
|
||||
// 로그에는 사유와 길이만 — 본문 내용은 남기지 않는다
|
||||
const messages = warnSpy.mock.calls.map(([message]) => String(message));
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toMatch(/사유=too-complex, 길이=\d+자/);
|
||||
expect(messages[1]).toMatch(/사유=too-long/);
|
||||
for (const message of messages) {
|
||||
expect(message).not.toMatch(/<b>|<p>|본문<|cid:/);
|
||||
expect(message).not.toContain(user.email);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('PATCH 는 하나 이상 지정해야 한다', async () => {
|
||||
@@ -380,6 +427,78 @@ describe('MailService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('서식 본문은 보내는 메일 허용 목록으로 정화해서 넘긴다 (스크립트·이벤트 속성·외부 이미지 제거)', async () => {
|
||||
await service.sendMessage(
|
||||
user,
|
||||
sendDto({
|
||||
body: '확인했습니다.',
|
||||
bodyHtml:
|
||||
'<div style="font-size: 14px"><p onclick="x()">확인<script>alert(1)</script><img src="https://evil.example/p.gif">했습니다.</p></div>',
|
||||
}),
|
||||
);
|
||||
|
||||
const [, outgoing] = provider.sendMessage.mock.calls[0];
|
||||
expect(outgoing.htmlBody).toBe(
|
||||
'<div style="font-size:14px"><p>확인했습니다.</p></div>',
|
||||
);
|
||||
expect(outgoing.body).toBe('확인했습니다.');
|
||||
});
|
||||
|
||||
it('서식 본문이 없거나 정화해서 비면 텍스트 메일로 보낸다', async () => {
|
||||
for (const bodyHtml of [undefined, '', '<script>alert(1)</script>']) {
|
||||
provider.sendMessage.mockClear();
|
||||
await service.sendMessage(user, sendDto({ bodyHtml }));
|
||||
|
||||
expect(provider.sendMessage.mock.calls[0][1]).not.toHaveProperty(
|
||||
'htmlBody',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('서식 본문이 너무 깊게 중첩되면 정화하지 않고 400 VAL_001 — 엔진·첨부를 찾기 전에 (키가 있어도)', async () => {
|
||||
// 500,000자 안의 '<b>' 16만 개 — 정화하면 9초 가까이 이벤트 루프가 멈춘다
|
||||
const bodyHtml = '<b>'.repeat(166_666);
|
||||
const attachments = [{ id: 'blob-1', name: '체크리스트.xlsx' }];
|
||||
|
||||
for (const key of [undefined, IDEMPOTENCY_KEY]) {
|
||||
const started = performance.now();
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.sendMessage(user, sendDto({ bodyHtml, attachments }), key),
|
||||
),
|
||||
).toBe('VAL_001');
|
||||
expect(performance.now() - started).toBeLessThan(2_000);
|
||||
}
|
||||
expect(provider.findSentMessage).not.toHaveBeenCalled();
|
||||
expect(provider.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('정화한 서식 본문이 UTF-8 2,000,000바이트를 넘으면 400 VAL_001 — 다시 읽을 때 잘리지 않게 (넘지 않으면 보낸다)', async () => {
|
||||
// '&' 는 정화하면 '&'(5바이트) — 400,000개면 정확히 한도
|
||||
await service.sendMessage(
|
||||
user,
|
||||
sendDto({ bodyHtml: '&'.repeat(400_000) }),
|
||||
);
|
||||
const [, sent] = provider.sendMessage.mock.calls[0];
|
||||
expect(Buffer.byteLength(sent.htmlBody ?? '', 'utf8')).toBe(
|
||||
MAX_BODY_VALUE_BYTES,
|
||||
);
|
||||
|
||||
for (const bodyHtml of [
|
||||
'&'.repeat(400_001),
|
||||
// 글자 + 이모지 표시 선택자(VS16) — 형식 검증은 1자로 세지만(500,000자 안) UTF-8 로 6바이트씩 3MB
|
||||
'❤️'.repeat(500_000),
|
||||
]) {
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.sendMessage(user, sendDto({ bodyHtml }), IDEMPOTENCY_KEY),
|
||||
),
|
||||
).toBe('VAL_001');
|
||||
}
|
||||
expect(provider.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(provider.findSentMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('첨부 형식은 클라이언트 값이 아니라 이름의 확장자로 정한다', async () => {
|
||||
await service.sendMessage(
|
||||
user,
|
||||
@@ -532,6 +651,30 @@ describe('MailService', () => {
|
||||
expect(provider.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('지문에는 정화한 서식 본문이 들어간다 — 정화 뒤가 같으면 같은 내용, 서식이 바뀌면 MAIL_005', async () => {
|
||||
const html = '<p><strong>확인</strong>했습니다.</p>';
|
||||
const fingerprint = await fingerprintOf(sendDto({ bodyHtml: html }));
|
||||
const [, first] = provider.sendMessage.mock.calls[0];
|
||||
expect(first.htmlBody).toBe(html);
|
||||
expect(fingerprint).toBe(payloadFingerprint(first, IDEMPOTENCY_KEY));
|
||||
|
||||
provider.sendMessage.mockClear();
|
||||
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
|
||||
|
||||
// 정화로 지워지는 부분만 다르면 같은 메일이다
|
||||
await expect(
|
||||
send(sendDto({ bodyHtml: `${html}<script>x()</script>` })),
|
||||
).resolves.toEqual({ id: 'sent-1' });
|
||||
// 서식이 바뀌거나(굵게 → 기울임) 서식 본문을 뺀 텍스트 메일은 다른 내용이다
|
||||
for (const changed of [
|
||||
sendDto({ bodyHtml: '<p><em>확인</em>했습니다.</p>' }),
|
||||
sendDto(),
|
||||
]) {
|
||||
expect(await codeOf(() => send(changed))).toBe('MAIL_005');
|
||||
}
|
||||
expect(provider.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('같은 키인데 내용(받는 사람·제목·본문·첨부)이 다르면 보내지 않고 409 MAIL_005', async () => {
|
||||
const fingerprint = await fingerprintOf(sendDto());
|
||||
provider.sendMessage.mockClear();
|
||||
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
selectInlineImages,
|
||||
toDataUri,
|
||||
} from './html/inline-images';
|
||||
import { checkHtmlParseBudget } from './html/html-parse-budget';
|
||||
import { sanitizeMailHtml } from './html/sanitize-mail-html';
|
||||
import { MAX_RECIPIENTS } from './mail.constants';
|
||||
import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html';
|
||||
import { MAX_BODY_VALUE_BYTES, MAX_RECIPIENTS } from './mail.constants';
|
||||
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
|
||||
import type {
|
||||
@@ -64,6 +66,33 @@ function toAddress(recipient: RecipientDto): MailAddress {
|
||||
return { name: name || null, email: recipient.email.trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* 서식 본문 정화 — 앞뒤로 한도를 본다. 넘으면 400 VAL_001 (엔진·첨부 조회 전이다).
|
||||
* - 정화 전: 깊게 중첩된 HTML 은 정화 자체가 이벤트 루프를 멈춘다 (html/html-parse-budget.ts)
|
||||
* - 정화 뒤: 결과가 본문 읽기 한도(바이트)를 넘으면 보낸편지함·받는 사람 화면에서 표시 없이 잘린다 (mail.constants.ts)
|
||||
*/
|
||||
function sanitizeBodyHtml(bodyHtml: string): string {
|
||||
const budget = checkHtmlParseBudget(bodyHtml);
|
||||
if (!budget.ok) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'body-html-too-complex',
|
||||
detail: budget.reason,
|
||||
length: bodyHtml.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const html = sanitizeOutgoingHtml(bodyHtml);
|
||||
const bytes = Buffer.byteLength(html, 'utf8');
|
||||
if (bytes > MAX_BODY_VALUE_BYTES) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'body-html-too-large', bytes },
|
||||
});
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 비즈니스 로직 — 메일 엔진은 `MailProvider` 포트로만 다룬다.
|
||||
* 엔진과 무관한 규칙(표시 이름·정렬, HTML 정화, 받는 사람 수, 첨부 형식)은 여기서 지킨다.
|
||||
@@ -133,7 +162,11 @@ export class MailService {
|
||||
});
|
||||
}
|
||||
|
||||
/** 상세 — 조회만 한다(읽음 처리 없음). HTML 은 정화한 뒤에만 내보낸다 */
|
||||
/**
|
||||
* 상세 — 조회만 한다(읽음 처리 없음). HTML 은 정화한 뒤에만 내보낸다.
|
||||
* 정화가 이벤트 루프를 오래 붙잡을 HTML(너무 길거나 깊게 중첩된 것)은 정화하지 않고 htmlBody 를 비운다 —
|
||||
* 클라이언트는 textBody 를 보여 준다 (html/html-parse-budget.ts).
|
||||
*/
|
||||
async getMessage(
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
@@ -145,6 +178,16 @@ export class MailService {
|
||||
return { ...detail, htmlBody: null, hasBlockedImages: false };
|
||||
}
|
||||
|
||||
// 내장 이미지를 읽기 전에 본다 — 보여 주지 않을 본문의 이미지를 엔진에서 받아 오지 않게
|
||||
const budget = checkHtmlParseBudget(rawHtmlBody);
|
||||
if (!budget.ok) {
|
||||
// 본문 내용은 남기지 않는다 (.claude/rules/logging.md)
|
||||
this.logger.warn(
|
||||
`${traceTag()} 받은 메일 HTML 이 정화 한도를 넘어 텍스트 본문만 내려줍니다: user=${maskEmail(user.email)}, id=${id}, 사유=${budget.reason}, 길이=${rawHtmlBody.length}자`,
|
||||
);
|
||||
return { ...detail, htmlBody: null, hasBlockedImages: false };
|
||||
}
|
||||
|
||||
const inlineImages = await this.loadInlineImages(
|
||||
user.email,
|
||||
rawHtmlBody,
|
||||
@@ -271,7 +314,7 @@ export class MailService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 엔진과 무관한 발송 규칙 — 받는 사람 수(MAIL_002), 첨부 형식(FILE_001), 헤더 값 줄바꿈 */
|
||||
/** 엔진과 무관한 발송 규칙 — 받는 사람 수(MAIL_002), 첨부 개수(MAIL_006)·형식(FILE_001), 헤더 값 줄바꿈, 서식 본문 정화(한도 VAL_001) */
|
||||
private prepareOutgoing(dto: SendMessageDto): OutgoingMessage {
|
||||
const to = dto.to.map(toAddress);
|
||||
const cc = (dto.cc ?? []).map(toAddress);
|
||||
@@ -304,12 +347,16 @@ export class MailService {
|
||||
return { id: attachment.id, name, type };
|
||||
});
|
||||
|
||||
// 서식 본문 — 편집기가 만드는 서식만 남긴다 (보내는 메일 전용 허용 목록). 정화해서 비면 텍스트 메일로 보낸다
|
||||
const htmlBody = dto.bodyHtml ? sanitizeBodyHtml(dto.bodyHtml) : '';
|
||||
|
||||
return {
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject: singleLine(dto.subject),
|
||||
body: dto.body,
|
||||
...(htmlBody ? { htmlBody } : {}),
|
||||
attachments,
|
||||
inReplyTo: dto.inReplyTo,
|
||||
};
|
||||
@@ -325,7 +372,7 @@ export class MailService {
|
||||
const recipientCount =
|
||||
message.to.length + message.cc.length + message.bcc.length;
|
||||
this.logger.log(
|
||||
`메일 발송: from=${maskEmail(user.email)}, 받는 사람 ${recipientCount}명, 첨부 ${message.attachments.length}개${message.inReplyTo ? ', 답장' : ''}${message.messageId ? ', 중복 방지 키' : ''}`,
|
||||
`메일 발송: from=${maskEmail(user.email)}, 받는 사람 ${recipientCount}명, 첨부 ${message.attachments.length}개${message.htmlBody ? ', 서식 본문' : ''}${message.inReplyTo ? ', 답장' : ''}${message.messageId ? ', 중복 방지 키' : ''}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -129,8 +129,13 @@ export interface OutgoingMessage {
|
||||
readonly cc: MailAddress[];
|
||||
readonly bcc: MailAddress[];
|
||||
readonly subject: string;
|
||||
/** 일반 텍스트 본문 */
|
||||
/** 일반 텍스트 본문 — 서식 본문이 있으면 그 대체 본문(받는 쪽이 텍스트만 볼 때) */
|
||||
readonly body: string;
|
||||
/**
|
||||
* 서식 본문 — 서버가 정화한 HTML (html/sanitize-outgoing-html.ts). 없으면 텍스트 메일이다.
|
||||
* 있으면 어댑터는 body 와 함께 multipart/alternative 로 보낸다.
|
||||
*/
|
||||
readonly htmlBody?: string;
|
||||
readonly attachments: OutgoingAttachment[];
|
||||
/** 답장할 메일의 id — 어댑터가 스레드 헤더(In-Reply-To·References)를 채운다 */
|
||||
readonly inReplyTo?: string;
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
SentMessageDto,
|
||||
} from './dto/mail-response.dto';
|
||||
import { MAX_ATTACHMENTS_PER_MESSAGE } from './attachment.policy';
|
||||
import { MAX_BODY_VALUE_BYTES, MESSAGES_ROUTE } from './mail.constants';
|
||||
import { MessageListQueryDto } from './dto/message-list-query.dto';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
import { UpdateMessageDto } from './dto/update-message.dto';
|
||||
@@ -61,7 +62,7 @@ const ATTACHMENT_CONTENT_TYPE = 'application/octet-stream';
|
||||
'AUTH_001',
|
||||
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
|
||||
)
|
||||
@Controller('messages')
|
||||
@Controller(MESSAGES_ROUTE)
|
||||
export class MessageController {
|
||||
private readonly logger = new Logger(MessageController.name);
|
||||
|
||||
@@ -91,7 +92,8 @@ export class MessageController {
|
||||
@ApiOperation({
|
||||
summary: '메일 상세 — 조회만으로 읽음 처리하지 않는다',
|
||||
description:
|
||||
'htmlBody 는 서버가 정화한 HTML 이다. 클라이언트는 격리된 iframe 에만 그린다.',
|
||||
'htmlBody 는 서버가 정화한 HTML 이다. 클라이언트는 격리된 iframe 에만 그린다. ' +
|
||||
'정화 한도(너무 길거나 깊게 중첩된 HTML)를 넘으면 htmlBody 는 null 이다 — textBody 를 보여 준다.',
|
||||
})
|
||||
@ApiStandardResponse(MessageDetailDto, { description: '메일 상세' })
|
||||
@ApiErrorResponse('RES_001', '없는 메일')
|
||||
@@ -140,7 +142,10 @@ export class MessageController {
|
||||
'메일 발송 (답장: inReplyTo · 전달: 원본 첨부 id 를 attachments 에)',
|
||||
description:
|
||||
'Idempotency-Key 가 같으면 한 번만 보낸다 — 이미 보냈으면 그 결과(201 { id })를 그대로 돌려준다. ' +
|
||||
'같은 키인데 내용이 다르면 409 MAIL_005, 같은 키가 처리 중이면 429 REQ_001.',
|
||||
'같은 키인데 내용이 다르면 409 MAIL_005, 같은 키가 처리 중이면 429 REQ_001. ' +
|
||||
'서식 본문(bodyHtml)이 있으면 정화한 뒤 body(텍스트)와 함께 multipart/alternative 로 보낸다 — ' +
|
||||
`너무 깊게 중첩됐거나(정화 전 검사) 정화한 결과가 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트를 넘으면 400 VAL_001 (엔진에 묻기 전). ` +
|
||||
'이 요청만 JSON 본문 4MB 까지 받는다 (다른 API 는 1MB, 넘으면 413 FILE_002).',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'Idempotency-Key',
|
||||
@@ -155,13 +160,16 @@ export class MessageController {
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'MAIL_002',
|
||||
`MAIL_002 받는 사람 주소 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음(만료된 업로드는 다시 올리고, 지워진 원본 첨부는 뺀다) · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · VAL_001 형식·Idempotency-Key 형식 · FILE_001 첨부 형식 (모두 400)`,
|
||||
`MAIL_002 받는 사람 주소 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음(만료된 업로드는 다시 올리고, 지워진 원본 첨부는 뺀다) · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · VAL_001 형식·Idempotency-Key 형식·서식 본문 한도(중첩·정화 뒤 크기) · FILE_001 첨부 형식 (모두 400)`,
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_005',
|
||||
'같은 Idempotency-Key 로 이미 보낸 메일과 내용이 다르다 — 보내지 않았다 (보낸편지함 확인 후 새 키로)',
|
||||
)
|
||||
@ApiErrorResponse('FILE_002', '첨부 합계 크기 초과')
|
||||
@ApiErrorResponse(
|
||||
'FILE_002',
|
||||
'첨부 합계 크기 초과 · JSON 본문 4MB 초과 (앞단 nginx 도 이 경로만 4MB — 넘으면 백엔드에 닿기 전에 같은 413 FILE_002)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'REQ_001',
|
||||
'같은 Idempotency-Key 의 발송이 처리 중(또는 엔진 장애 직후 30초) · 메일 엔진 발송 한도',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, createHmac } from 'crypto';
|
||||
import { createHash, createHmac, hkdfSync } from 'crypto';
|
||||
|
||||
import {
|
||||
idempotentMessageId,
|
||||
@@ -108,6 +108,7 @@ describe('payloadFingerprint (v2 — Idempotency-Key 로 만든 HMAC)', () => {
|
||||
['본문', { body: '고친 본문' }],
|
||||
['첨부', { attachments: [message.attachments[0]] }],
|
||||
['답장 대상', { inReplyTo: 'eaaaaab' }],
|
||||
['서식 본문(없던 것이 생김)', { htmlBody: '<p>확인했습니다.</p>' }],
|
||||
];
|
||||
|
||||
it.each(changes)('%s 이 바뀌면 지문이 달라진다', (_label, change) => {
|
||||
@@ -116,6 +117,56 @@ describe('payloadFingerprint (v2 — Idempotency-Key 로 만든 HMAC)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('서식 본문이 바뀌면 지문이 달라지고, 줄바꿈 형식만 다르면 같다', () => {
|
||||
const withHtml = {
|
||||
...message,
|
||||
htmlBody: '<p>확인했습니다.</p>\n<p>감사합니다.</p>',
|
||||
};
|
||||
|
||||
expect(
|
||||
payloadFingerprint(
|
||||
{ ...withHtml, htmlBody: '<p>확인했습니다.</p>\n<p>고맙습니다.</p>' },
|
||||
KEY,
|
||||
),
|
||||
).not.toBe(payloadFingerprint(withHtml, KEY));
|
||||
expect(
|
||||
payloadFingerprint(
|
||||
{ ...withHtml, htmlBody: '<p>확인했습니다.</p>\r\n<p>감사합니다.</p>' },
|
||||
KEY,
|
||||
),
|
||||
).toBe(payloadFingerprint(withHtml, KEY));
|
||||
});
|
||||
|
||||
it('서식 본문이 없으면 지문 입력은 서식 본문이 생기기 전과 같다 — 배포 전에 보낸 텍스트 메일의 재시도도 같은 지문', () => {
|
||||
// v2 정의를 그대로 다시 계산한다: HMAC-SHA256(HKDF(키, 용도 라벨), 8항목 JSON)
|
||||
const hmacKey = Buffer.from(
|
||||
hkdfSync(
|
||||
'sha256',
|
||||
KEY,
|
||||
Buffer.alloc(0),
|
||||
'arasystem/mail-payload-fingerprint/v2',
|
||||
32,
|
||||
),
|
||||
);
|
||||
const canonical = JSON.stringify([
|
||||
'v2',
|
||||
['doyun.lee@arasystem.test', 'seojun.park@arasystem.test'],
|
||||
[],
|
||||
['secret.boss@arasystem.test'],
|
||||
message.subject,
|
||||
message.body,
|
||||
['blob-a', 'blob-b'],
|
||||
null,
|
||||
]);
|
||||
|
||||
expect(payloadFingerprint(message, KEY)).toBe(
|
||||
`v2:${createHmac('sha256', hmacKey).update(canonical).digest('hex')}`,
|
||||
);
|
||||
expect(payloadFingerprint({ ...message, htmlBody: '' }, KEY)).toBe(
|
||||
payloadFingerprint(message, KEY),
|
||||
);
|
||||
});
|
||||
|
||||
it('형식은 v2:<HMAC-SHA256 16진 64자>', () => {
|
||||
expect(payloadFingerprint(message, KEY)).toMatch(/^v2:[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
@@ -63,12 +63,24 @@ export function idempotentMessageId(user: string, key: string): string {
|
||||
return `ara.${token}@${domain}`;
|
||||
}
|
||||
|
||||
/** 지문에 넣는 내용 — 받는 사람·참조·숨은 참조·제목·본문·첨부 id·답장 대상 */
|
||||
/** 지문에 넣는 내용 — 받는 사람·참조·숨은 참조·제목·본문·서식 본문(정화한 것)·첨부 id·답장 대상 */
|
||||
export type FingerprintSource = Pick<
|
||||
OutgoingMessage,
|
||||
'to' | 'cc' | 'bcc' | 'subject' | 'body' | 'attachments' | 'inReplyTo'
|
||||
| 'to'
|
||||
| 'cc'
|
||||
| 'bcc'
|
||||
| 'subject'
|
||||
| 'body'
|
||||
| 'htmlBody'
|
||||
| 'attachments'
|
||||
| 'inReplyTo'
|
||||
>;
|
||||
|
||||
/** 줄바꿈을 LF 로 맞춘다 — 같은 내용을 다른 줄바꿈 형식으로 다시 보낸 재시도도 같은 지문이 되게 */
|
||||
function normalizeNewlines(text: string): string {
|
||||
return text.replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
function addresses(list: readonly MailAddress[]): string[] {
|
||||
return list.map((address) => address.email.trim().toLowerCase()).sort();
|
||||
}
|
||||
@@ -91,6 +103,8 @@ function fingerprintKey(idempotencyKey: string): Buffer {
|
||||
* 그냥 해시를 쓰면 받은 사람이 숨은 참조 후보를 넣어 맞춰 볼 수 있다 — 키를 모르면 맞춰 볼 수 없다 (파일 머리말).
|
||||
*
|
||||
* 정규화: 주소는 소문자·정렬(순서만 바꾼 것은 같은 메일), 표시 이름은 넣지 않는다, 본문 줄바꿈은 LF 로, 첨부는 id 만 정렬.
|
||||
* 서식 본문(정화한 HTML)은 **있을 때만** 끝에 붙인다 — 텍스트 메일의 지문 입력은 서식 본문이 생기기 전과 같아서,
|
||||
* 배포 전에 보낸 메일을 같은 키로 다시 보내도 MAIL_005 로 오판하지 않는다 (그래서 버전을 올리지 않았다).
|
||||
*/
|
||||
export function payloadFingerprint(
|
||||
message: FingerprintSource,
|
||||
@@ -102,9 +116,10 @@ export function payloadFingerprint(
|
||||
addresses(message.cc),
|
||||
addresses(message.bcc),
|
||||
message.subject,
|
||||
message.body.replace(/\r\n?/g, '\n'),
|
||||
normalizeNewlines(message.body),
|
||||
message.attachments.map((attachment) => attachment.id).sort(),
|
||||
message.inReplyTo ?? null,
|
||||
...(message.htmlBody ? [normalizeNewlines(message.htmlBody)] : []),
|
||||
]);
|
||||
const digest = createHmac('sha256', fingerprintKey(idempotencyKey))
|
||||
.update(canonical)
|
||||
|
||||
@@ -250,6 +250,44 @@ describe('응답 계약 (e2e)', () => {
|
||||
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
|
||||
});
|
||||
|
||||
it('발송(POST /api/messages)만 JSON 본문 4MB 까지 받는다 — 2MB 발송 본문은 파서를 지나 인증 검사에 닿고, 4MB 를 넘으면 413 FILE_002', async () => {
|
||||
const parsed = await request(server)
|
||||
.post('/api/messages')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(JSON.stringify({ body: '', bodyHtml: 'x'.repeat(2_000_000) }))
|
||||
.expect(401);
|
||||
expect(body(parsed).error?.code).toBe('AUTH_001');
|
||||
|
||||
const tooLarge = await request(server)
|
||||
.post('/api/messages')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(JSON.stringify({ body: '', bodyHtml: 'x'.repeat(4_300_000) }))
|
||||
.expect(413);
|
||||
expect(body(tooLarge).error?.code).toBe('FILE_002');
|
||||
expect(body(tooLarge).error?.traceId).toBe(
|
||||
tooLarge.headers['x-request-id'],
|
||||
);
|
||||
});
|
||||
|
||||
it('다른 API 는 그대로 1MB — 같은 2MB 본문이 로그인·메일 수정(PATCH /api/messages/:id)에서는 413 FILE_002', async () => {
|
||||
const twoMegabytes = JSON.stringify({
|
||||
email: 'x',
|
||||
password: 'y'.repeat(2_000_000),
|
||||
});
|
||||
|
||||
for (const [method, url] of [
|
||||
['post', '/api/auth/login'],
|
||||
['patch', '/api/messages/eaaaaab'],
|
||||
] as const) {
|
||||
const res = await request(server)
|
||||
[method](url)
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(twoMegabytes)
|
||||
.expect(413);
|
||||
expect(body(res).error?.code).toBe('FILE_002');
|
||||
}
|
||||
});
|
||||
|
||||
it('형식이 맞는 x-request-id 는 이어받고, 그 밖의 값은 새로 발급한다', async () => {
|
||||
const kept = await request(server)
|
||||
.get('/api/users/999999')
|
||||
|
||||
Reference in New Issue
Block a user