import { RequestMethod, ValidationPipe } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { ExpressAdapter, type NestExpressApplication, } from '@nestjs/platform-express'; import cookieParser from 'cookie-parser'; import express, { json, type Express, type NextFunction, type Request, type Response, } from 'express'; import helmet from 'helmet'; import type { IncomingMessage, ServerResponse } from 'http'; import { BusinessException } from '@/common/errors/business.exception'; import { HttpExceptionFilter } from '@/common/filters/http-exception.filter'; import { TransformInterceptor } from '@/common/interceptors/transform.interceptor'; import { requestIdMiddleware } from '@/common/middleware/request-id.middleware'; import { requestTargetMiddleware } from '@/common/middleware/request-target.middleware'; import { MAIL_ENGINE_EVENTS_SHAPE_LIMITS } from '@/modules/account-security/account-security.constants'; import { ACCOUNT_SECURITY_SETTINGS, type AccountSecuritySettings, } from '@/modules/account-security/account-security.settings'; import { createMailEngineEventsBodyParser, isMailEngineEventsRequest, } from '@/modules/account-security/mail-engine-events.parser'; import { FILE_SHARING_SETTINGS, type FileSharingSettings, } from '@/modules/file-sharing/file-sharing.settings'; import { createPublicHostMiddleware } from '@/modules/file-sharing/public-host'; import { DRAFTS_ROUTE, MESSAGES_ROUTE } from '@/modules/mail/mail.constants'; import { findJsonShapeProblem, findJsonTextShapeProblem, type JsonShapeLimits, type JsonShapeProblem, } from '@/shared/utils/json-shape'; import { resolveTrustProxy } from '@/shared/utils/trust-proxy'; /** 글로벌 prefix — 모든 라우트가 /api/* (클라이언트 baseURL 과 일치) */ export const API_PREFIX = 'api'; /** * JSON 본문 최대 크기 — 발송(`POST /api/messages`)·임시 저장(`POST /api/drafts`·`PUT /api/drafts/{id}`)을 뺀 모든 API. * 텍스트 본문 100,000자(한글은 UTF-8 로 3바이트라 약 300KB)도 이 안에 든다 (기본값 100KB 로는 잘린다). */ export const JSON_BODY_LIMIT = '1mb'; /** * 발송·임시 저장 요청의 JSON 본문 최대 크기 (계약 2절) — 서식 본문(최대 500,000자: 한글이면 약 1.5MB 에 * JSON 이스케이프가 더해진다)과 텍스트 대체 본문(최대 100,000자)이 함께 오고, 답장·전달은 원문 인용이 길다. * 임시 저장 본문은 발송 본문과 같은 필드·한도라 같은 파서를 쓴다. * 앞단 nginx 도 이 경로들을 같은 값(4m)으로 둬야 한다 (release-ops — frontend/nginx.conf). 넘으면 413 FILE_002. */ export const SEND_MESSAGE_JSON_BODY_LIMIT = '4mb'; /** * JSON 본문의 모양 한도 — 두 본문 파서(1MB · 4MB)의 본문 모두에, 읽기 전(verifyJsonBody)과 읽은 뒤(rejectOversizedJsonBody) * DTO 변환 · 검사 **전에** 건다 (shared/utils/json-shape.ts 머리말). * 지금 계약의 어떤 본문보다 넉넉히 둔다: 가장 긴 배열은 받는 사람 · id 100(발송 본문의 첨부 목록 상한도 100), 가장 깊은 본문은 5단 * (필터 — 본문 › rules › 규칙 › conditions › 조건), 값이 가장 많은 본문은 필터 50개를 모두 채운 저장(약 1,600개). * 넘으면 400 VAL_001 — 이 한도를 넘는 계약을 새로 만들면 여기부터 올린다. * 한도까지 채운 본문도 DTO 변환 · 검사가 0.1초 안쪽에 끝난다 (app.setup.spec.ts 가 CPU 시간으로 본다 — 부하를 견디게 한도는 2초) */ export const JSON_SHAPE_LIMITS: JsonShapeLimits = { maxArrayLength: 1_000, maxValues: 10_000, maxDepth: 32, }; /** Express 설정 이름 — 라우팅이 대소문자를 가린다 (createHttpAdapter) */ const CASE_SENSITIVE_ROUTING = 'case sensitive routing'; /** * 앱을 만들 HTTP 어댑터 — main.ts 와 e2e 가 **같은 함수**를 쓴다(NestFactory.create · createNestApplication 의 어댑터 인자). * * 라우팅은 **대소문자를 가린다** — `/api/Messages/…` · `…/LARGE-FILES/…` · `/api/public-links/…/FILES/…` · `/API/…` 는 처리기에 닿지 않고 404 RES_001. * 대소문자를 가리지 않으면 앞단 nginx 의 받기 location(대소문자를 가리는 정규식 · prefix)이 놓친 변형이 받기 처리기까지 와서 * 동시 연결 상한(limit_conn)과 버퍼링 끄기를 비켜 갔다(2026-10-30 인프라 리뷰 High — nginx 도 고친다). 클라이언트(웹 · 앱)는 모두 소문자 경로다. * * Express 5 는 라우터를 처음 꺼낼 때 만들며 그때 이 설정을 읽는데, Nest 의 ExpressAdapter 는 만들어질 때 이미 `app.use` 를 부른다 * (express-adapter.js 의 생성자) — 그래서 configureApp() 에서 켜면 늦다(2026-10-30 확인: express 5.2.1 · router 2.2.0 · @nestjs/platform-express 11.2.5). * Express 인스턴스를 직접 만들어 어댑터보다 먼저 켠다. configureApp 이 맨 앞에서 라우터가 정말 대소문자를 가리는지 확인한다 */ export function createHttpAdapter(): ExpressAdapter { const instance = express(); instance.enable(CASE_SENSITIVE_ROUTING); return new ExpressAdapter(instance); } /** * 이 앱의 라우터가 대소문자를 가리는가 — Express 5 의 `app.router` 가 만들어질 때 받은 값(router 패키지가 `caseSensitive` 로 둔다). * 설정 값(`app.enabled`)이 아니라 라우터를 본다 — 앱을 만든 뒤에 설정을 켜면 값은 켜져도 라우터는 그대로라서다. * router 가 이 이름을 바꾸면 여기서 기동이 멈춘다 — 그때는 e2e 의 대소문자 테스트로 확인하고 고친다 */ function routesAreCaseSensitive(app: NestExpressApplication): boolean { const instance: unknown = app.getHttpAdapter().getInstance(); const router: unknown = (instance as Partial | null)?.router; return ( typeof router === 'function' && (router as { caseSensitive?: unknown }).caseSensitive === true ); } const SEND_MESSAGE_PATH = `/${API_PREFIX}/${MESSAGES_ROUTE}`; const DRAFTS_PATH = `/${API_PREFIX}/${DRAFTS_ROUTE}`; /** * 본문 한도를 고를 경로 — 쿼리를 빼고, 라우터처럼 끝의 '/' 는 가리지 않는다. * 대소문자는 가리지 않는다 — 라우터는 가리지만(createHttpAdapter) 본문 한도는 넓게 고른다: 발송 · 임시 저장 경로의 대소문자 변형도 같은 4MB 파서로 읽은 뒤 * 라우터에서 404 RES_001 이 된다(본문 크기 때문에 413 이 되지 않고 다른 대소문자 변형처럼 404 로 같게) */ function routedPath(req: Pick): string { return (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase(); } /** * 발송 요청인가 — `POST /api/messages`. * 라우터가 발송 핸들러로 보내는 모양(끝의 '/' · 쿼리)이면 모두 해당하고, 대소문자 변형도 넓게 해당한다 — 그 변형은 라우터에서 404 (위 routedPath). */ export function isSendMessageRequest( req: Pick, ): boolean { return req.method === 'POST' && routedPath(req) === SEND_MESSAGE_PATH; } /** * 임시 저장 요청인가 — `POST /api/drafts`(처음 저장) · `PUT /api/drafts/{id}`(바꿔 저장). 조회(GET)는 본문이 없다. */ export function isDraftSaveRequest( req: Pick, ): boolean { const path = routedPath(req); if (req.method === 'POST') return path === DRAFTS_PATH; if (req.method !== 'PUT' || !path.startsWith(`${DRAFTS_PATH}/`)) { return false; } const id = path.slice(DRAFTS_PATH.length + 1); return id !== '' && !id.includes('/'); } /** 모양 한도를 넘은 본문의 실패 — 400 VAL_001. 로그에는 넘은 한도의 이름과 본 자리(읽기 전 · 뒤)만 남는다 */ function jsonShapeFailure( problem: JsonShapeProblem, stage: 'before-parse' | 'after-parse', ): BusinessException { return new BusinessException('VAL_001', { context: { reason: 'json-shape-limit', problem, stage }, }); } /** 글자 인코딩 이름의 비교용 모양 — body-parser 가 본문을 글자로 바꿀 때(iconv-lite)와 같게 소문자 영숫자만 남긴다 */ function canonicalCharset(label: string): string { return label.toLowerCase().replace(/:\d{4}$|[^0-9a-z]/g, ''); } /** UTF-8 의 비교용 이름 — `utf-8` · `UTF-8` · `utf-_8` 이 모두 이것이다 (body-parser 는 이 본문을 모두 UTF-8 로 읽는다) */ const UTF8_CHARSET = 'utf8'; /** * 본문 파서의 읽기 전 확인(body-parser 의 verify) — UTF-8 본문을 JSON.parse 하기 **전에** 한 번 훑어 모양 한도를 넘으면 멈춘다. * JSON.parse 도 작은 객체가 아주 많으면 무겁다 (4MB 에 `{}` 139만 개를 읽는 데만 CPU 0.67초 — 2026-10-28 측정). * * JSON 본문은 UTF-8 만 받는다 (RFC 8259 8.1 — 웹 · 앱 모두 UTF-8 로 보낸다). body-parser 는 `utf-` 로 시작하는 charset 을 * 모두 받지만(utf-16 · utf-32 · utf-7) 그 본문은 바이트로 셀 수 없어 JSON.parse 전에 끊지 못한다 — 그래서 400 VAL_001 로 거절한다. * 이름은 견줄 모양으로 바꿔 본다: 글자 그대로 `utf-8` 과 견주면 `utf-_8` 처럼 UTF-8 로 읽히는 다른 이름이 확인을 건너뛴다. * * 여기서 던진 오류는 body-parser 가 http-errors 로 감싸며 상태를 오류에서 가져와 400 이 되고(BusinessException 의 status), * 전역 필터가 VAL_001 로 답한다 (app.e2e-spec.ts 가 실제 응답으로 본다). 본문 파서는 가드보다 앞이라 로그인 전의 요청도 같다 */ export function verifyJsonBody( _req: IncomingMessage, _res: ServerResponse, buffer: Buffer, encoding: string, ): void { if (canonicalCharset(encoding) !== UTF8_CHARSET) { throw new BusinessException('VAL_001', { context: { reason: 'json-not-utf8' }, }); } const problem = findJsonTextShapeProblem(buffer, JSON_SHAPE_LIMITS); if (problem !== null) throw jsonShapeFailure(problem, 'before-parse'); } /** * 본문 파서가 읽은 JSON 본문의 모양을 본다 (JSON_SHAPE_LIMITS) — 넘으면 DTO 변환 · 검사로 넘기지 않고 400 VAL_001. * 지금은 읽기 전 확인을 지난 UTF-8 본문만 오므로 같은 판정을 한 번 더 하는 셈이다(값 10,000개 안이라 가볍다) — * 본문 파서를 새로 붙이거나 읽기 전 확인이 빠져도 DTO 변환 · 검사 전에 끊도록 남겨 둔다. * 엔진 웹훅(12단계 — `POST /api/internal/mail-engine-events`)만 그 경로의 한도(MAIL_ENGINE_EVENTS_SHAPE_LIMITS)로 본다 */ export function rejectOversizedJsonBody( req: Request, _res: Response, next: NextFunction, ): void { const body: unknown = req.body; const limits = isMailEngineEventsRequest(req) ? MAIL_ENGINE_EVENTS_SHAPE_LIMITS : JSON_SHAPE_LIMITS; const problem = findJsonShapeProblem(body, limits); if (problem === null) { next(); return; } next(jsonShapeFailure(problem, 'after-parse')); } /** * 전역 DTO 검증 — 첫 실패에서 그 칸의 남은 검사를 멈춘다(stopAtFirstError). * 배열 길이 한도에 걸린 칸은 항목마다의 중첩 검사를 하지 않는다 — 실패 응답은 늘 VAL_001 이라 어느 제약이 먼저 걸렸는지는 쓰지 않는다 * (도메인 코드 — 받는 사람 합계 MAIL_002 · 첨부 수 MAIL_006 · 본문 길이 MAIL_007 등 — 는 검증을 지난 뒤 서비스가 고른다) */ export function createValidationPipe(): ValidationPipe { return new ValidationPipe({ whitelist: true, // DTO 에 없는 값은 버린다 forbidNonWhitelisted: true, // DTO 에 없는 필드가 오면 400 transform: true, // DTO·기본 타입으로 변환 stopAtFirstError: true, }); } /** 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 가 실제 응답과 다른 설정으로 돌게 된다). * * 앱은 `createHttpAdapter()` 와 `{ bodyParser: false }` 로 만들어야 한다 — 라우팅의 대소문자 구분은 어댑터를 만들 때 켜고(여기서는 늦다), * 본문 파서는 여기서 직접 등록한다. 대소문자를 가리지 않는 라우터로 만든 앱이면 기동을 멈춘다. * CORS·Swagger·종료 훅은 배포 환경에 묶인 설정이라 main.ts 에 둔다. */ export function configureApp(app: NestExpressApplication): void { if (!routesAreCaseSensitive(app)) { throw new Error( '라우팅이 대소문자를 가리지 않습니다 — 앱은 createHttpAdapter() 로 만든 어댑터로 만들어야 합니다(app.setup.ts). ' + '예: NestFactory.create(AppModule, createHttpAdapter(), { bodyParser: false })', ); } // 요청 추적 ID — **가장 먼저** 붙인다. 본문 파서·helmet 보다 앞이라 그 단계의 실패(413·400·415)에도 // 응답 traceId 와 로그가 남고, 이후 모든 처리가 요청 컨텍스트(traceTag) 안에서 돈다 app.use(requestIdMiddleware); // 요청 대상 원문 검사 — 날 `\` · `#` · 제어 문자 · 0x80 이상 글자가 있으면 라우팅 전에 400 VAL_001 (common/middleware/request-target.middleware.ts). // Express 의 parseurl 이 그런 대상을 옛 url.parse 로 다시 읽으며 `\` 를 `/` 로 바꿔, 앞단 nginx 가 정규식으로 닫은 경로가 여기서 열렸다 (2026-10-31) app.use(requestTargetMiddleware); // 공개 호스트(대용량 첨부의 내려받기 주소 — 11단계)를 포털과 따로 두면 그 호스트에서는 공개 링크 API 만, 포털에서는 공개 링크 API 를 닫는다. // 본문 파서 앞이라 막은 요청의 본문을 읽지 않는다. 같은 주소면(개발) 아무것도 막지 않는다 (modules/file-sharing/public-host.ts) const sharing = app.get(FILE_SHARING_SETTINGS, { strict: false, }); app.use(createPublicHostMiddleware(sharing.dedicatedPublicHost)); // 믿을 프록시 뒤에서 실제 클라이언트 IP 를 읽는다 — 로그인 실패 한도·요청 수 한도의 기준 (TRUST_PROXY). // 운영 체인: 클라이언트 → 외부 리버스 프록시 → 프론트 nginx → 백엔드. 값이 없으면(비운영) 1 app.set('trust proxy', resolveTrustProxy(process.env.TRUST_PROXY)); // 글로벌 prefix — 모든 라우트가 /api/* (클라이언트 baseURL 과 일치) app.setGlobalPrefix(API_PREFIX, { exclude: [{ path: '/', method: RequestMethod.GET }], }); // 보안 헤더 app.use(helmet()); // 인증 쿠키(access_token·refresh_token)를 읽는다 app.use(cookieParser()); // JSON 만 받는다 — urlencoded 파서를 등록하지 않으면 다른 사이트의
으로 로그인·발송을 시도하는 // 요청(CSRF)은 본문이 비어 VAL_001 이 된다. JSON 요청은 CORS 사전 확인을 거치므로 화이트리스트가 막는다. // inflate=false: 압축된 요청 본문을 받지 않는다 (브라우저는 보내지 않는다 — 압축 폭탄·body-parser DoS 경로 차단) // // 경로별 한도: 발송·임시 저장 요청만 4MB 파서가 읽고(type 판정으로 고른다), 나머지는 1MB 파서가 읽는다. // 같은 요청을 두 번 읽지 않는다 — body-parser 는 이미 다 읽은 요청을 건너뛴다(read.js 의 isFinished 확인). // 순서가 중요하다: 4MB 파서가 먼저여야 발송 본문이 1MB 파서에서 413 이 되지 않는다. // 두 파서 모두 읽기 전 확인(verify — UTF-8 만 · 모양 한도)을 건다 — Nest 의 useBodyParser 는 verify 를 rawBody 용으로 남겨 두고 받지 않아(타입에서 뺀다) // 같은 파서(express.json = body-parser)를 직접 붙인다 (useBodyParser 도 안에서 이렇게 붙인다) // // 엔진 웹훅(12단계 — 계약 5절 "엔진 웹훅 받기")은 맨 앞의 자기 파서가 읽는다: 본문을 받기 전에 소켓 주소(앱 네트워크) · 서명 키 유무를 보고 // 아니면 본문을 받지 않고 401 AUTH_001(연결을 닫는다), 서명(HMAC)은 원문을 받은 뒤 JSON 으로 읽기 전에 본다. // 한도는 4MB · 배열 10,000 · 값 200,000 (modules/account-security/mail-engine-events.parser.ts) const accountSecurity = app.get( ACCOUNT_SECURITY_SETTINGS, { strict: false }, ); app.use(createMailEngineEventsBodyParser(accountSecurity)); app.use( json({ limit: SEND_MESSAGE_JSON_BODY_LIMIT, inflate: false, type: (req: IncomingMessage) => (isSendMessageRequest(req) || isDraftSaveRequest(req)) && isJsonBody(req), verify: verifyJsonBody, }), ); app.use( json({ limit: JSON_BODY_LIMIT, inflate: false, verify: verifyJsonBody }), ); // 두 파서가 읽은 본문 모두 — 배열 길이 · 값 수 · 깊이가 한도를 넘으면 DTO 변환 · 검사 전에 400 (JSON_SHAPE_LIMITS) app.use(rejectOversizedJsonBody); // 전역 DTO 검증 app.useGlobalPipes(createValidationPipe()); // 표준 실패·성공 응답 app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector))); }