diff --git a/backend/package-lock.json b/backend/package-lock.json index d6a5489..573f22c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index f5dcc51..3741abb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/app.setup.spec.ts b/backend/src/app.setup.spec.ts new file mode 100644 index 0000000..40e93c0 --- /dev/null +++ b/backend/src/app.setup.spec.ts @@ -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, + ); + }); +}); diff --git a/backend/src/app.setup.ts b/backend/src/app.setup.ts index 14844ff..360cdce 100644 --- a/backend/src/app.setup.ts +++ b/backend/src/app.setup.ts @@ -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, +): 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 파서를 등록하지 않으면 다른 사이트의
으로 로그인·발송을 시도하는 // 요청(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 검증 diff --git a/backend/src/modules/mail/attachment.policy.ts b/backend/src/modules/mail/attachment.policy.ts index 956ec26..1dca1ff 100644 --- a/backend/src/modules/mail/attachment.policy.ts +++ b/backend/src/modules/mail/attachment.policy.ts @@ -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; diff --git a/backend/src/modules/mail/dto/mail-response.dto.ts b/backend/src/modules/mail/dto/mail-response.dto.ts index 4cf5a0b..784e769 100644 --- a/backend/src/modules/mail/dto/mail-response.dto.ts +++ b/backend/src/modules/mail/dto/mail-response.dto.ts @@ -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, }) diff --git a/backend/src/modules/mail/dto/send-message.dto.spec.ts b/backend/src/modules/mail/dto/send-message.dto.spec.ts index 1a0fb75..617ce4a 100644 --- a/backend/src/modules/mail/dto/send-message.dto.spec.ts +++ b/backend/src/modules/mail/dto/send-message.dto.spec.ts @@ -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(['

x

']))).toContain('isString'); + }); +}); + describe('SendMessageDto 첨부 목록', () => { it('개수 한도를 넘는 목록도 형식 검증은 통과한다 — 개수 한도(MAIL_006)는 서비스가 답한다', async () => { for (const count of [ diff --git a/backend/src/modules/mail/dto/send-message.dto.ts b/backend/src/modules/mail/dto/send-message.dto.ts index 3b2e2e9..ab9ff84 100644 --- a/backend/src/modules/mail/dto/send-message.dto.ts +++ b/backend/src/modules/mail/dto/send-message.dto.ts @@ -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: + '

도윤님, 확인했습니다.

', + }) + @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], diff --git a/backend/src/modules/mail/html/html-parse-budget.spec.ts b/backend/src/modules/mail/html/html-parse-budget.spec.ts new file mode 100644 index 0000000..0b70a0d --- /dev/null +++ b/backend/src/modules/mail/html/html-parse-budget.spec.ts @@ -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(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 = ''; + for (let index = 0; index < 10; index++) { + row += closeCells ? `${cell}` : `${cell}`; + cell++; + } + row += closeCells ? '' : ''; + rows.push(row); + length += row.length; + } + return `${rows.join('')}
`; +} + +/** Outlook — 조건부 주석 안의 표, MsoNormal 문단, o:p */ +function outlookMail(): string { + const paragraph = (index: number) => + `

문단 ${index} 입니다.

` + + '

표 안

'; + 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 `
${parts.join('')}
`; +} + +/** 닫지 않은 p·li·td 가 많은 HTML */ +const unclosedBlocks = () => + fillTo( + '

문단

  • 항목
  • 항목
  • 항목
12
34
', + ); + +/** Gmail 식 인용 스레드 — 단마다 div.gmail_quote > blockquote > div 가 쌓인다 (300단 = 깊이 900) */ +function gmailThread(levels: number, chars: number): string { + const quote = + '
2026년 10월 27일 (화) 오전 10:00, 김아라 <ara.kim@arasystem.it.kr>님이 작성:
' + + '
'; + const close = '
'; + const line = + '
답장 문장입니다.
'; + const linesPerLevel = Math.max( + 1, + Math.floor((chars / levels - quote.length - close.length) / line.length), + ); + return `${(line.repeat(linesPerLevel) + quote).repeat(levels)}
첫 메일
${close.repeat(levels)}`; +} + +/** 우리 웹메일의 답장 — 원본 메일 머리 표 + 회색 인용 막대가 단마다 한 겹 */ +function araThread(levels: number, chars: number): string { + const head = + '

답장 내용입니다.

' + + '
' + + '
보낸 사람김아라
'; + const line = + '

인용된 본문 줄입니다.

'; + const close = '
'; + 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) => + `
` + + `` + + `
상품

제목 ${index}

설명

` + + `
자세히 보기
` + + `
`; + const rows = Array.from({ length: blocks }, (_, index) => block(index)); + return `
${rows.join('')}
`; +} + +// ── 공격 모양 ──────────────────────────────────────────────────────────────── + +const LONG_NAME = 'a'.repeat(30); + +/** 정화(htmlparser2 의 스택 처리)가 O(n²) 이거나 깊은 스택을 끝없이 훑게 하는 입력 — 길이 `chars` 로 채운다 */ +const ATTACKS: Array<[string, (chars: number) => string]> = [ + ['깊은 중첩 ()', (chars) => fillTo('', chars)], + ['목록 중첩 (