feat(backend): 파일 업로드 공통 골격 추가 (로컬 디스크)
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / Flutter (analyze · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled

템플릿이 나중에 반드시 필요해질 공용 기능을 미리 갖춘다.
multer 취약점을 해소한 김에, 안전한 업로드 기준도 함께 세운다.

- modules/upload — 단일 파일 업로드 엔드포인트(POST /api/files)와 재사용 가능한 검증 설정
- 확장자와 MIME 을 **둘 다** 검사하는 허용목록 방식.
  확장자만 보면 위장이 쉽고, MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
  .html/.svg/.js 등 브라우저가 실행할 수 있는 형식은 허용 목록에서 제외.
- 저장 파일명은 서버가 UUID 로 생성한다. 클라이언트 파일명을 경로에 쓰면 경로 탈출이 가능하다.
- 크기 한도(UPLOAD_MAX_SIZE_MB, 기본 5MB) 초과 시 FILE_002(413).
- 도메인 에러 코드 FILE_001/FILE_002 추가 후 세 플랫폼 사전 동기화.

함께 고친 것:

- 전역 필터가 프레임워크의 영문 메시지를 그대로 노출하고 있었다.
  크기 초과 시 multer 의 "File too large" 가 사용자에게 보였다 —
  이제 코드에 대응하는 레지스트리 문구만 사용하며, 재정의는 BusinessException 으로만 가능하다.
- sync:error-codes 가 컨테이너 안에서 실행되면 형제 디렉터리를 찾지 못해
  cryptic 한 mkdir 오류를 냈다. 원인과 해결 방법을 알리도록 보강.
- 업로드 볼륨 경로에 compose 기본값을 주어 기존 .env 에 키가 없어도 동작하게 함.

실제 multipart 요청으로 정상 업로드·형식 거부·MIME 위장·경로 탈출 시도·크기 초과·
파일 누락 시나리오를 모두 검증했다.

알려진 한계(확장자·MIME 검사는 파일 내용을 보지 않는다)는 security.md 에 명시하고,
업로드 디렉터리를 정적 서빙하지 말 것을 규칙으로 남겼다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 21:47:10 +09:00
parent 750cd6362a
commit aba274e56f
22 changed files with 463 additions and 23 deletions
+2
View File
@@ -8,6 +8,7 @@ import { APP_GUARD } from '@nestjs/core';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { HealthModule } from '@/modules/health/health.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { UserModule } from '@/modules/user/user.module';
import { parsePositiveInt } from '@/shared/utils/env';
import { buildDatabaseOptions } from '@/shared/config/database.config';
@@ -57,6 +58,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
}),
}),
HealthModule,
UploadModule,
UserModule,
],
providers: [
+12 -4
View File
@@ -64,10 +64,16 @@ const COMMON_ERROR_CODES = {
* message: '이미 가입된 이메일입니다. 다른 이메일을 사용해 주세요.',
* },
*/
const DOMAIN_ERROR_CODES = {} as const satisfies Record<
string,
ErrorCodeDefinition
>;
const DOMAIN_ERROR_CODES = {
FILE_001: {
status: HttpStatus.BAD_REQUEST,
message: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
},
FILE_002: {
status: HttpStatus.PAYLOAD_TOO_LARGE,
message: '파일 크기가 허용 범위를 초과했습니다.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
...COMMON_ERROR_CODES,
@@ -90,6 +96,8 @@ const STATUS_TO_ERROR_CODE: ReadonlyMap<number, ErrorCode> = new Map<
[HttpStatus.BAD_REQUEST, 'VAL_001'],
[HttpStatus.UNPROCESSABLE_ENTITY, 'VAL_001'],
[HttpStatus.TOO_MANY_REQUESTS, 'REQ_001'],
// multer 가 파일 크기 한도 초과 시 던지는 상태 코드
[HttpStatus.PAYLOAD_TOO_LARGE, 'FILE_002'],
]);
/** 등록된 에러 코드인지 확인한다. */
@@ -5,6 +5,7 @@ import {
HttpStatus,
Logger,
NotFoundException,
PayloadTooLargeException,
} from '@nestjs/common';
import { HttpExceptionFilter } from './http-exception.filter';
@@ -95,6 +96,17 @@ describe('HttpExceptionFilter', () => {
expect(result.body.error.message).toBe(ERROR_CODES.VAL_001.message);
});
it('라이브러리가 만든 영문 메시지를 사용자에게 노출하지 않는다', () => {
// multer 는 크기 초과 시 "File too large" 라는 영문 메시지로 예외를 만든다.
// 사용자 노출 문구는 에러 코드 사전에만 존재해야 한다.
const result = runFilter(new PayloadTooLargeException('File too large'));
expect(result.status).toBe(HttpStatus.PAYLOAD_TOO_LARGE);
expect(result.body.error.code).toBe('FILE_002');
expect(result.body.error.message).toBe(ERROR_CODES.FILE_002.message);
expect(JSON.stringify(result.body)).not.toContain('File too large');
});
it('미정의 예외는 내부 정보 없이 SYS_001 로 수렴한다', () => {
const result = runFilter(new Error('DB 커넥션 풀 고갈: host=10.0.0.5'));
@@ -75,8 +75,9 @@ export class HttpExceptionFilter implements ExceptionFilter {
return {
status,
code,
// 내장 예외의 영문 기본 메시지는 노출하지 않고 레지스트리 문구를 사용한다.
message: this.extractMessage(body) ?? getErrorDefinition(code).message,
// 프레임워크/라이브러리가 만든 영문 메시지("File too large" 등)가 그대로 새어나가지 않도록
// **항상 레지스트리 문구**를 쓴다. 사용자 노출 문구를 재정의하려면 BusinessException 을 사용한다.
message: getErrorDefinition(code).message,
};
}
@@ -97,18 +98,6 @@ export class HttpExceptionFilter implements ExceptionFilter {
return null;
}
/**
* 직접 문자열 메시지를 지정해 던진 경우에만 사용자 문구로 채택한다.
* (ValidationPipe 처럼 message 가 배열이면 상세 사유가 노출되므로 무시한다.)
*/
private extractMessage(body: unknown): string | null {
if (typeof body === 'object' && body !== null && 'message' in body) {
const message: unknown = body.message;
if (typeof message === 'string') return message;
}
return null;
}
/** 5xx 는 error, 4xx 는 warn 으로 남긴다. 추적을 위해 traceId 를 함께 기록한다. */
private log(
exception: unknown,
@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
/** 업로드 결과 — 저장 경로(절대경로)는 노출하지 않는다 */
export class UploadResponseDto {
@ApiProperty({
description: '서버가 생성한 파일 식별자 (저장 파일명)',
example: 'b1f0c2e4-8a3d-4c7e-9f21-0d5a6b7c8e9f.png',
})
fileName!: string;
@ApiProperty({
description: '업로드 당시 원본 파일명',
example: '프로필.png',
})
originalName!: string;
@ApiProperty({ description: 'MIME 타입', example: 'image/png' })
mimeType!: string;
@ApiProperty({ description: '파일 크기 (바이트)', example: 20480 })
size!: number;
}
@@ -0,0 +1,30 @@
import { allowedExtensionList, isAllowedFile } from './upload.config';
describe('파일 업로드 검증', () => {
it('확장자와 MIME 이 모두 맞으면 통과한다', () => {
expect(isAllowedFile('photo.png', 'image/png')).toBe(true);
expect(isAllowedFile('scan.PDF', 'application/pdf')).toBe(true);
});
it('허용되지 않은 MIME 은 거부한다', () => {
expect(isAllowedFile('script.js', 'application/javascript')).toBe(false);
expect(isAllowedFile('page.html', 'text/html')).toBe(false);
});
it('MIME 은 허용이지만 확장자가 불일치하면 거부한다 (확장자 위장 방지)', () => {
expect(isAllowedFile('payload.html', 'image/png')).toBe(false);
expect(isAllowedFile('payload.exe', 'application/pdf')).toBe(false);
});
it('확장자가 없으면 거부한다', () => {
expect(isAllowedFile('nofile', 'image/png')).toBe(false);
});
it('실행 가능한 형식은 허용 목록에 없다', () => {
const allowed = allowedExtensionList();
for (const dangerous of ['.html', '.svg', '.js', '.exe', '.sh']) {
expect(allowed).not.toContain(dangerous);
}
});
});
@@ -0,0 +1,48 @@
import { extname } from 'path';
import { parsePositiveInt } from '@/shared/utils/env';
/**
* 파일 업로드 정책 (로컬 디스크 기준).
*
* 오브젝트 스토리지(S3 등)로 전환할 때는 저장 어댑터만 교체하고
* 검증 규칙(허용 형식·크기)은 그대로 재사용한다.
*/
/** 업로드 파일 저장 디렉터리 — 컨테이너에서는 compose 볼륨이 연결된다 */
export const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads';
const MEGABYTE = 1024 * 1024;
/** 파일 1개 최대 크기 (기본 5MB) */
export const MAX_FILE_SIZE_BYTES =
parsePositiveInt(process.env.UPLOAD_MAX_SIZE_MB, 5) * MEGABYTE;
/**
* 허용 형식 — 확장자와 MIME 타입을 **둘 다** 검사한다.
* 확장자만 보면 위장이 쉽고, MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
*
* ⚠️ 실행 가능한 형식(.html, .svg, .js 등)은 넣지 말 것 —
* 저장된 파일을 그대로 서빙할 경우 XSS 로 이어진다.
*/
export const ALLOWED_FILE_TYPES: ReadonlyMap<string, readonly string[]> =
new Map([
['image/jpeg', ['.jpg', '.jpeg']],
['image/png', ['.png']],
['image/webp', ['.webp']],
['image/gif', ['.gif']],
['application/pdf', ['.pdf']],
]);
/** 허용 형식인지 검사한다 (확장자 ↔ MIME 조합이 일치해야 통과) */
export function isAllowedFile(originalName: string, mimeType: string): boolean {
const allowedExtensions = ALLOWED_FILE_TYPES.get(mimeType);
if (!allowedExtensions) return false;
return allowedExtensions.includes(extname(originalName).toLowerCase());
}
/** 사용자에게 안내할 허용 확장자 목록 */
export function allowedExtensionList(): string {
return [...ALLOWED_FILE_TYPES.values()].flat().join(', ');
}
@@ -0,0 +1,54 @@
import {
Controller,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { UploadResponseDto } from './dto/upload-response.dto';
import { UploadService } from './upload.service';
// 파일 업로드 샘플 — 새 업로드 엔드포인트는 이 패턴을 복제한다.
// 검증(형식·크기)과 안전한 파일명 생성은 UploadModule 의 multer 설정이 담당한다.
@ApiTags('Upload')
@Controller('files')
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
@Post()
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: '파일 업로드 (단일)' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: { type: 'string', format: 'binary' },
},
required: ['file'],
},
})
@ApiStandardResponse(UploadResponseDto, {
status: 201,
description: '업로드 성공',
})
@ApiErrorResponse('FILE_001', '허용되지 않는 파일 형식')
@ApiErrorResponse('FILE_002', '파일 크기 초과')
upload(@UploadedFile() file?: Express.Multer.File): UploadResponseDto {
if (!file) {
// 파일 없이 요청한 경우 — multer 는 오류를 내지 않으므로 여기서 막는다
throw new BusinessException('VAL_001', {
message: '업로드할 파일을 선택해 주세요.',
});
}
return this.uploadService.toResponse(file);
}
}
@@ -0,0 +1,53 @@
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { randomUUID } from 'crypto';
import { diskStorage } from 'multer';
import { mkdirSync } from 'fs';
import { extname } from 'path';
import { BusinessException } from '@/common/errors/business.exception';
import {
MAX_FILE_SIZE_BYTES,
UPLOAD_DIR,
isAllowedFile,
} from './upload.config';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
// 저장 디렉터리는 기동 시 한 번 보장한다 (없으면 multer 가 매 요청 실패한다)
mkdirSync(UPLOAD_DIR, { recursive: true });
@Module({
imports: [
MulterModule.register({
storage: diskStorage({
destination: UPLOAD_DIR,
filename: (_req, file, callback) => {
// ⚠️ 클라이언트가 보낸 파일명을 경로에 쓰지 않는다.
// '../../etc/passwd' 같은 값이 그대로 들어오면 경로 탈출이 된다.
// 서버가 생성한 UUID + 검증된 확장자만 사용한다.
const safeExtension = extname(file.originalname).toLowerCase();
callback(null, `${randomUUID()}${safeExtension}`);
},
}),
limits: {
fileSize: MAX_FILE_SIZE_BYTES,
// 파일 외 필드 수를 제한해 과도한 파싱을 막는다
files: 1,
fields: 10,
},
fileFilter: (_req, file, callback) => {
if (!isAllowedFile(file.originalname, file.mimetype)) {
// 에러 코드와 함께 거부한다 (전역 필터가 표준 응답으로 변환)
callback(new BusinessException('FILE_001'), false);
return;
}
callback(null, true);
},
}),
],
controllers: [UploadController],
providers: [UploadService],
exports: [UploadService],
})
export class UploadModule {}
@@ -0,0 +1,36 @@
import { Injectable, Logger } from '@nestjs/common';
import { unlink } from 'fs/promises';
import { join } from 'path';
import { UPLOAD_DIR } from './upload.config';
import { UploadResponseDto } from './dto/upload-response.dto';
// 업로드된 파일의 메타데이터를 다루는 계층.
// 저장 자체는 multer(diskStorage)가 수행하며, 여기서는 그 결과를 정리한다.
@Injectable()
export class UploadService {
private readonly logger = new Logger(UploadService.name);
/** multer 가 저장한 파일 정보를 응답 DTO 로 변환한다 */
toResponse(file: Express.Multer.File): UploadResponseDto {
this.logger.log(
`파일 업로드 완료: ${file.filename} (${file.size} bytes, ${file.mimetype})`,
);
return {
fileName: file.filename,
// 원본 파일명은 표시용 메타데이터일 뿐이며 경로 조립에 사용하지 않는다
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
};
}
/**
* 저장된 파일을 삭제한다.
* 파일명은 서버가 생성한 값만 받아야 한다 — 외부 입력을 그대로 넘기면 경로 탈출 위험이 있다.
*/
async remove(fileName: string): Promise<void> {
await unlink(join(UPLOAD_DIR, fileName));
}
}