feat(backend): 파일 업로드 공통 골격 추가 (로컬 디스크)
템플릿이 나중에 반드시 필요해질 공용 기능을 미리 갖춘다. 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:
@@ -21,7 +21,24 @@
|
||||
- 입력값은 DTO(`class-validator`) 검증을 거친 뒤 서비스 계층에 전달한다. 전역 `ValidationPipe` 의 `whitelist`/`forbidNonWhitelisted` 를 끄지 않는다
|
||||
- TypeORM ORM 쿼리 사용으로 SQL Injection 차단 (Raw 쿼리 시 파라미터 바인딩 필수)
|
||||
- 에러 응답에 스택·쿼리·내부 호스트명을 싣지 않는다 — 미정의 예외는 항상 `SYS_001` 로 수렴시킨다 ([[error-codes]])
|
||||
- 파일 업로드는 확장자·MIME·용량을 서버에서 검증하고, 업로드 경로를 실행 가능한 위치에 두지 않는다
|
||||
### 파일 업로드
|
||||
|
||||
템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
|
||||
|
||||
- **확장자와 MIME 을 둘 다** 검사한다 (`upload.config.ts`). 확장자만 보면 위장이 쉽고,
|
||||
MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
|
||||
- **허용 목록(allowlist) 방식**으로만 받는다. 차단 목록은 언제나 빠뜨린다.
|
||||
`.html`·`.svg`·`.js` 등 **브라우저가 실행할 수 있는 형식은 넣지 않는다.**
|
||||
- **저장 파일명은 서버가 생성**한다(UUID + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면
|
||||
`../../etc/passwd` 같은 경로 탈출이 가능하다. 원본 이름은 표시용 메타데이터로만 보관한다.
|
||||
- 크기 한도를 설정한다 (`UPLOAD_MAX_SIZE_MB`, 기본 5MB). 초과 시 `FILE_002`(413).
|
||||
- 업로드 디렉터리를 **정적 파일로 그대로 서빙하지 않는다.** 내려줄 때는 컨트롤러를 거쳐
|
||||
`Content-Type` 을 서버가 지정하고 `Content-Disposition: attachment` 를 붙인다.
|
||||
|
||||
> ⚠️ **알려진 한계**: 확장자·MIME 검사는 **파일 내용을 보지 않는다.**
|
||||
> 이름이 `.png` 이고 MIME 이 `image/png` 이면 내용이 HTML 이어도 통과한다.
|
||||
> 그래서 위의 "정적 서빙 금지"가 중요하다 — 내용 기반 검증이 필요하면
|
||||
> 매직 바이트를 확인하는 라이브러리(`file-type` 등)를 파이프라인에 추가한다.
|
||||
|
||||
## 네트워크 · 인프라
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
|
||||
| 마이그레이션 | `src/data-source.ts` + `src/migrations/` | `npm run migration:*` (아래 "DB 마이그레이션") |
|
||||
| Graceful shutdown | `main.ts` 의 `enableShutdownHooks()` + `shared/logger/shutdown.logger.ts` | SIGTERM 시 요청/커넥션 정리 후 종료 |
|
||||
| 예제 모듈(참조용) | `modules/user`(DTO+Swagger+페이지네이션) | 새 모듈 패턴 참고. in-memory 데모 저장소 |
|
||||
| 파일 업로드 | `modules/upload` | 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]) |
|
||||
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) |
|
||||
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 |
|
||||
|
||||
|
||||
@@ -63,3 +63,9 @@ DB_SYNCHRONIZE=false
|
||||
# 일회성으로 켜려면: docker compose --profile redis ... up -d
|
||||
# ==========================================
|
||||
COMPOSE_PROFILES=
|
||||
|
||||
# ==========================================
|
||||
# 파일 업로드 (로컬 디스크)
|
||||
# ==========================================
|
||||
UPLOAD_DATA_DIR=./data/uploads
|
||||
UPLOAD_MAX_SIZE_MB=5
|
||||
|
||||
@@ -63,3 +63,9 @@ DB_SYNCHRONIZE=false
|
||||
# 일회성으로 켜려면: docker compose --profile redis ... up -d
|
||||
# ==========================================
|
||||
COMPOSE_PROFILES=
|
||||
|
||||
# ==========================================
|
||||
# 파일 업로드 (로컬 디스크)
|
||||
# ==========================================
|
||||
UPLOAD_DATA_DIR=./data/uploads
|
||||
UPLOAD_MAX_SIZE_MB=5
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
!/data/.gitkeep
|
||||
/db-data/
|
||||
/redis-data/
|
||||
# 업로드 파일 (로컬 저장) — 코드가 아니다
|
||||
/data/uploads/
|
||||
backend/uploads/
|
||||
|
||||
# ==========================================
|
||||
# 의존성/빌드 산출물
|
||||
|
||||
Generated
+128
-3
@@ -40,6 +40,7 @@
|
||||
"@swc/core": "^1.15.24",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"chokidar": "^5.0.0",
|
||||
@@ -176,6 +177,24 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics-cli/node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics-cli/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -183,6 +202,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics-cli/node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics-cli/node_modules/rxjs": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||
@@ -248,6 +283,24 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics/node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -255,6 +308,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics/node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics/node_modules/rxjs": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||
@@ -3023,6 +3092,24 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics/node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -3030,6 +3117,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nestjs/schematics/node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics/node_modules/rxjs": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||
@@ -3431,7 +3534,7 @@
|
||||
"version": "1.15.41",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz",
|
||||
"integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -3475,6 +3578,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3491,6 +3595,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3507,6 +3612,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3523,6 +3629,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3539,6 +3646,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3555,6 +3663,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3571,6 +3680,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3587,6 +3697,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3603,6 +3714,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3619,6 +3731,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3635,6 +3748,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3651,6 +3765,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3664,14 +3779,14 @@
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@swc/types": {
|
||||
"version": "0.1.26",
|
||||
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
|
||||
"integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/counter": "^0.1.3"
|
||||
@@ -3938,6 +4053,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz",
|
||||
"integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"@swc/core": "^1.15.24",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"chokidar": "^5.0.0",
|
||||
|
||||
@@ -7,12 +7,28 @@
|
||||
* 생성 파일은 직접 수정하지 말 것 — 레지스트리를 고치고 다시 실행한다.
|
||||
* CI 는 이 스크립트를 실행한 뒤 git diff 로 드리프트를 검사한다.
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
|
||||
import { ERROR_CODES } from '../src/common/errors/error-codes';
|
||||
|
||||
const REPO_ROOT = resolve(__dirname, '..', '..');
|
||||
|
||||
// 이 스크립트는 저장소 루트 기준으로 frontend/·flutter/ 에 파일을 쓴다.
|
||||
// 컨테이너 안에서는 backend/ 만 마운트되어 형제 디렉터리가 없으므로 실행할 수 없다.
|
||||
if (!existsSync(resolve(REPO_ROOT, 'frontend'))) {
|
||||
console.error(
|
||||
[
|
||||
'저장소 루트를 찾을 수 없습니다: ' + REPO_ROOT,
|
||||
'',
|
||||
'이 스크립트는 frontend/·flutter/ 에 사전 파일을 생성하므로',
|
||||
'저장소 전체가 보이는 곳에서 실행해야 합니다.',
|
||||
'컨테이너 안(backend/ 만 마운트됨)이 아니라 **호스트에서** 실행하세요:',
|
||||
' cd backend && npm run sync:error-codes',
|
||||
].join('\n'),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const GENERATED_NOTICE =
|
||||
'backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요.';
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,8 @@ services:
|
||||
volumes:
|
||||
- ./${BACKEND_DIR}:/app
|
||||
- /app/node_modules
|
||||
# base 의 업로드 볼륨을 소스 바인드 마운트 위에 다시 얹는다
|
||||
- ${UPLOAD_DATA_DIR:-./data/uploads}:/app/uploads
|
||||
# dev에서는 리버스 프록시 미사용 — base의 networks 목록을 덮어씀
|
||||
networks: !override
|
||||
- app-network
|
||||
|
||||
@@ -63,9 +63,14 @@ services:
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_NAME=${DB_NAME}
|
||||
- DB_SYNCHRONIZE=${DB_SYNCHRONIZE}
|
||||
- UPLOAD_DIR=/app/uploads
|
||||
- UPLOAD_MAX_SIZE_MB=${UPLOAD_MAX_SIZE_MB:-5}
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||
# 업로드 파일은 컨테이너 재생성과 무관하게 보존되어야 한다
|
||||
volumes:
|
||||
- ${UPLOAD_DATA_DIR:-./data/uploads}:/app/uploads
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
@@ -9,6 +9,8 @@ const Map<String, String> kErrorCodeMessages = {
|
||||
'VAL_001': '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
'FILE_001': '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
|
||||
'FILE_002': '파일 크기가 허용 범위를 초과했습니다.',
|
||||
};
|
||||
|
||||
/// 매핑 실패 시 사용하는 폴백 코드
|
||||
|
||||
@@ -9,6 +9,8 @@ export const ERROR_CODE_MESSAGES = {
|
||||
VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
FILE_001: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
|
||||
FILE_002: '파일 크기가 허용 범위를 초과했습니다.',
|
||||
} as const
|
||||
|
||||
export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES
|
||||
|
||||
Reference in New Issue
Block a user