Files
arasystem/backend/src/app.setup.ts
T
ttipo f9e3b1c57a feat(backend): 12단계 계정 보안 — 2단계 로그인 · 2FA · 앱 비밀번호 · 세션 끊기 · 로그인 기록 · 엔진 웹훅
엔진(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>
2026-10-31 17:17:27 +09:00

325 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)));
}