generated from ttipo/project-base
feat(backend): 메일 서식 본문 발송과 정화 전 비용 검사
메일 쓰기에 서식 편집기가 들어오면서 서버가 HTML 본문을 받아 보낸다. 편집기의 출력은 믿지 않고 서버가 다시 정화한다. - 서식 본문(bodyHtml)은 보내는 메일 전용 허용 목록으로 다시 정화해 텍스트 대체 본문과 multipart/alternative 로 보낸다 (style 은 속성마다 값 형식을 처음부터 끝까지 맞추고, 링크는 http·https·mailto 만). 정화해서 비면 텍스트 메일로 보낸다 - 받은 메일 정화가 편집기 서식을 모두 남기도록 넓혔다 — 보낸편지함·받는 사람 화면에서 서식이 사라지지 않게 - 발송 POST /api/messages 만 JSON 4MB (서식 본문·긴 인용). 중복 방지 지문에 정화한 서식 본문을 넣는다 - htmlparser2 는 중첩 깊이의 제곱으로 느려져, 깊게 중첩한 2MB 메일 한 통에 3분 가까이 서버가 멈췄다. 정화 전에 선형 시간으로 파서 스택 비용을 재고, 넘으면 받은 메일은 htmlBody 없이(텍스트로), 서식 본문은 VAL_001 - 정화한 서식 본문이 2,000,000바이트(본문 읽기 한도)를 넘으면 VAL_001 — 다시 읽을 때 표시 없이 잘리지 않게 - 받은 메일 정화의 다른 제곱 시간 경로 2개(style 의 url() 정규식, 주소 없는 img 를 지우는 필터)도 고쳤다 - htmlparser2 를 직접 의존성으로 선언한다 — sanitize-html 이 쓰는 12.0.0 과 같은 판이라 lock 은 루트 한 줄만 바뀐다 계약: docs/mail/api-contract.md 2절 "서식 본문"·"읽기 한도" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 검증
|
||||
|
||||
Reference in New Issue
Block a user