generated from ttipo/project-base
f9e3b1c57a
엔진(Stalwart 0.16.27)의 TOTP 와 앱 비밀번호를 쓰되, 엔진이 하지 않는 것을 서버가 맡는다. - 등록 때 첫 코드 확인, 성공에 쓴 코드 2분 막기(확인 중인 같은 코드도), 코드 실패 한도(15분 5 · 24시간 20) - 웹메일 로그인 · 비밀번호 바꾸기에서 앱 비밀번호 모양 거절, 앱 비밀번호는 메일 프로그램 권한만 - 엔진 Basic 의 402 를 장애(MAIL_001)로 다루던 것을 고쳐 코드 단계로 — 402 면 정식 주소로 한도 · 기록 - 비밀번호를 바꾸면 앱 비밀번호도 지우고, 끊은 세션은 액세스 토큰의 sid 로 바로 막는다 - 메일 프로그램 로그인은 엔진 웹훅(소켓 주소 · HMAC · 내용 지문 · 1,000행씩)으로 받아 90일 남긴다 - 요청 대상에 날 \ · # 가 있으면 라우팅 전에 400 — Express 가 이를 다시 읽어 앞단이 닫은 경로가 열렸다 에러 코드 AUTH_006 ~ AUTH_009 와 웹 · 앱 사전, 마이그레이션 AddAccountSecurity 를 함께 넣었다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
325 lines
17 KiB
TypeScript
325 lines
17 KiB
TypeScript
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<Express> | 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<IncomingMessage, 'url'>): string {
|
||
return (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* 발송 요청인가 — `POST /api/messages`.
|
||
* 라우터가 발송 핸들러로 보내는 모양(끝의 '/' · 쿼리)이면 모두 해당하고, 대소문자 변형도 넓게 해당한다 — 그 변형은 라우터에서 404 (위 routedPath).
|
||
*/
|
||
export function isSendMessageRequest(
|
||
req: Pick<IncomingMessage, 'method' | 'url'>,
|
||
): boolean {
|
||
return req.method === 'POST' && routedPath(req) === SEND_MESSAGE_PATH;
|
||
}
|
||
|
||
/**
|
||
* 임시 저장 요청인가 — `POST /api/drafts`(처음 저장) · `PUT /api/drafts/{id}`(바꿔 저장). 조회(GET)는 본문이 없다.
|
||
*/
|
||
export function isDraftSaveRequest(
|
||
req: Pick<IncomingMessage, 'method' | 'url'>,
|
||
): 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<FileSharingSettings>(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 파서를 등록하지 않으면 다른 사이트의 <form> 으로 로그인·발송을 시도하는
|
||
// 요청(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<AccountSecuritySettings>(
|
||
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)));
|
||
}
|