From aba274e56f358d88614927fef65be322daa31793 Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 17 Sep 2026 21:47:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=ED=8C=8C=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B3=B5=ED=86=B5=20=EA=B3=A8=EA=B2=A9=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(=EB=A1=9C=EC=BB=AC=20=EB=94=94=EC=8A=A4?= =?UTF-8?q?=ED=81=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 템플릿이 나중에 반드시 필요해질 공용 기능을 미리 갖춘다. 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) --- .claude/rules/security.md | 19 ++- .claude/skills/nestjs/SKILL.md | 1 + .env.development.example | 6 + .env.production.example | 6 + .gitignore | 3 + backend/package-lock.json | 131 +++++++++++++++++- backend/package.json | 1 + backend/scripts/sync-error-codes.ts | 18 ++- backend/src/app.module.ts | 2 + backend/src/common/errors/error-codes.ts | 16 ++- .../filters/http-exception.filter.spec.ts | 12 ++ .../common/filters/http-exception.filter.ts | 17 +-- .../modules/upload/dto/upload-response.dto.ts | 22 +++ .../src/modules/upload/upload.config.spec.ts | 30 ++++ backend/src/modules/upload/upload.config.ts | 48 +++++++ .../src/modules/upload/upload.controller.ts | 54 ++++++++ backend/src/modules/upload/upload.module.ts | 53 +++++++ backend/src/modules/upload/upload.service.ts | 36 +++++ docker-compose.dev.yml | 2 + docker-compose.yml | 5 + flutter/lib/core/api/error_lexicon.g.dart | 2 + .../shared/constants/error-codes.generated.ts | 2 + 22 files changed, 463 insertions(+), 23 deletions(-) create mode 100644 backend/src/modules/upload/dto/upload-response.dto.ts create mode 100644 backend/src/modules/upload/upload.config.spec.ts create mode 100644 backend/src/modules/upload/upload.config.ts create mode 100644 backend/src/modules/upload/upload.controller.ts create mode 100644 backend/src/modules/upload/upload.module.ts create mode 100644 backend/src/modules/upload/upload.service.ts diff --git a/.claude/rules/security.md b/.claude/rules/security.md index b7b4a48..60b5646 100644 --- a/.claude/rules/security.md +++ b/.claude/rules/security.md @@ -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` 등)를 파이프라인에 추가한다. ## 네트워크 · 인프라 diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md index b133095..82200e2 100644 --- a/.claude/skills/nestjs/SKILL.md +++ b/.claude/skills/nestjs/SKILL.md @@ -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/` | 순수 함수 분리 위치 | diff --git a/.env.development.example b/.env.development.example index 3a7a2f5..9f5fa3b 100644 --- a/.env.development.example +++ b/.env.development.example @@ -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 diff --git a/.env.production.example b/.env.production.example index 70920c7..3c1fb5d 100644 --- a/.env.production.example +++ b/.env.production.example @@ -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 diff --git a/.gitignore b/.gitignore index c4d3512..01ad4af 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ !/data/.gitkeep /db-data/ /redis-data/ +# 업로드 파일 (로컬 저장) — 코드가 아니다 +/data/uploads/ +backend/uploads/ # ========================================== # 의존성/빌드 산출물 diff --git a/backend/package-lock.json b/backend/package-lock.json index a346945..f62dd97 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 3af004c..ccc5784 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/scripts/sync-error-codes.ts b/backend/scripts/sync-error-codes.ts index f4da218..a9e7940 100644 --- a/backend/scripts/sync-error-codes.ts +++ b/backend/scripts/sync-error-codes.ts @@ -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 에서 자동 생성된 파일입니다. 직접 수정하지 마세요.'; diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 0c84617..d84c929 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.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: [ diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 31fe48e..b742aac 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -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; export const ERROR_CODES = { ...COMMON_ERROR_CODES, @@ -90,6 +96,8 @@ const STATUS_TO_ERROR_CODE: ReadonlyMap = 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'], ]); /** 등록된 에러 코드인지 확인한다. */ diff --git a/backend/src/common/filters/http-exception.filter.spec.ts b/backend/src/common/filters/http-exception.filter.spec.ts index e525218..8979b3b 100644 --- a/backend/src/common/filters/http-exception.filter.spec.ts +++ b/backend/src/common/filters/http-exception.filter.spec.ts @@ -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')); diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index f2a5d3d..722b943 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -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, diff --git a/backend/src/modules/upload/dto/upload-response.dto.ts b/backend/src/modules/upload/dto/upload-response.dto.ts new file mode 100644 index 0000000..1c0a1f2 --- /dev/null +++ b/backend/src/modules/upload/dto/upload-response.dto.ts @@ -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; +} diff --git a/backend/src/modules/upload/upload.config.spec.ts b/backend/src/modules/upload/upload.config.spec.ts new file mode 100644 index 0000000..768f47f --- /dev/null +++ b/backend/src/modules/upload/upload.config.spec.ts @@ -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); + } + }); +}); diff --git a/backend/src/modules/upload/upload.config.ts b/backend/src/modules/upload/upload.config.ts new file mode 100644 index 0000000..418461a --- /dev/null +++ b/backend/src/modules/upload/upload.config.ts @@ -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 = + 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(', '); +} diff --git a/backend/src/modules/upload/upload.controller.ts b/backend/src/modules/upload/upload.controller.ts new file mode 100644 index 0000000..ec3402a --- /dev/null +++ b/backend/src/modules/upload/upload.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/upload/upload.module.ts b/backend/src/modules/upload/upload.module.ts new file mode 100644 index 0000000..137692b --- /dev/null +++ b/backend/src/modules/upload/upload.module.ts @@ -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 {} diff --git a/backend/src/modules/upload/upload.service.ts b/backend/src/modules/upload/upload.service.ts new file mode 100644 index 0000000..3199602 --- /dev/null +++ b/backend/src/modules/upload/upload.service.ts @@ -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 { + await unlink(join(UPLOAD_DIR, fileName)); + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index af7a065..f832449 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 6e4082c..5a0fac9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index a68a2d0..dad4a42 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -9,6 +9,8 @@ const Map kErrorCodeMessages = { 'VAL_001': '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)', 'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.', 'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', + 'FILE_001': '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.', + 'FILE_002': '파일 크기가 허용 범위를 초과했습니다.', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 8c0e472..7071d62 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -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