fix: 보안 규칙을 어기던 컨테이너·네트워크 설정 수정
security.md 가 금지한다고 적어둔 것을 실제 설정이 어기고 있었다. - 프론트엔드 운영 컨테이너가 root 로 nginx 를 실행하고 있었다. nginx-unprivileged 로 전환하고 포트를 8080 으로 맞춤(비-root 는 1024 미만 바인드 불가). - dev 오버라이드가 사용하는 빌드 스테이지에도 USER node 적용. 익명 볼륨이 node 소유로 생성되도록 chown 을 USER 전환 앞에 둔다 (그렇지 않으면 vite 가 node_modules/.vite 캐시를 쓰지 못한다). - dev 의 DB/Redis 포트가 0.0.0.0 에 바인딩되어 같은 LAN 에 노출돼 있었다 → 127.0.0.1 로 한정. - CSP 헤더 추가. 대신 현대 브라우저에서 권장되지 않는 X-XSS-Protection 은 제거. - CORS 차단 시 Error 를 던져 500(SYS_001)이 되던 것을 헤더 미부여 방식으로 변경. - 헬스체크가 localhost 를 ::1 로 해석해 항상 실패하던 문제 수정(127.0.0.1 명시). - graceful shutdown 을 위해 init(tini)과 stop_grace_period 적용. - *.example 의 실제같은 비밀번호를 placeholder 로 교체하고, 비어 있던 서비스 env 예시에 안내를 채움. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,77 @@
|
||||
---
|
||||
name: security
|
||||
description: 프로젝트 공통 보안 규칙 — 민감정보 커밋 금지, JWT 쿠키/secure storage, SQL Injection 차단, DTO 검증, 보안 헤더/CORS/Rate Limiting, 컨테이너 비-root 구동.
|
||||
---
|
||||
|
||||
# 보안
|
||||
|
||||
## 비밀 정보
|
||||
|
||||
- `.env*` 등 민감정보 Git 커밋 금지 (`.gitignore` 확인) — 예시는 `*.example`(예: `.env.development.example`, `.env.example`) 로만 공유
|
||||
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용
|
||||
- **`*.example` 파일의 값은 반드시 더미 placeholder 여야 한다.** 실제로 쓰는(또는 쓸 법한) 비밀번호·키를 예시 파일에 적지 않는다.
|
||||
- ❌ `DB_PASSWORD=dev_960426`
|
||||
- ✅ `DB_PASSWORD=change-me-in-local-env`
|
||||
- 시크릿이 한 번이라도 커밋되었다면 **값을 교체(rotate)** 한다. 커밋을 되돌리는 것만으로는 노출이 해소되지 않는다.
|
||||
- 로그·에러 응답에 시크릿을 남기지 않는다 ([[logging]]).
|
||||
|
||||
## 인증 · 토큰
|
||||
|
||||
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용 (키: `jwt_token`)
|
||||
- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 HttpOnly 쿠키로만 전달
|
||||
- 비밀번호는 단방향 해시(`bcrypt`/`argon2`)로 저장한다. 평문·가역 암호화 금지
|
||||
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_001)
|
||||
|
||||
## 입력 · 출력
|
||||
|
||||
- 입력값은 DTO(`class-validator`) 검증을 거친 뒤 서비스 계층에 전달한다. 전역 `ValidationPipe` 의 `whitelist`/`forbidNonWhitelisted` 를 끄지 않는다
|
||||
- TypeORM ORM 쿼리 사용으로 SQL Injection 차단 (Raw 쿼리 시 파라미터 바인딩 필수)
|
||||
- 입력값은 DTO(`class-validator`) 검증을 거친 뒤 서비스 계층에 전달
|
||||
- 보안 헤더(`helmet`)·CORS·Rate Limiting(`@nestjs/throttler`) 적용
|
||||
- Docker 컨테이너 내부 root 실행 금지 (비-root 사용자로 구동)
|
||||
- 에러 응답에 스택·쿼리·내부 호스트명을 싣지 않는다 — 미정의 예외는 항상 `SYS_001` 로 수렴시킨다 ([[error-codes]])
|
||||
- 파일 업로드는 확장자·MIME·용량을 서버에서 검증하고, 업로드 경로를 실행 가능한 위치에 두지 않는다
|
||||
|
||||
## 네트워크 · 인프라
|
||||
|
||||
- 보안 헤더(`helmet`) 적용
|
||||
- CORS 는 화이트리스트 방식. 차단 시 `callback(null, false)` 로 **헤더를 부여하지 않아** 브라우저가 막게 한다
|
||||
(`callback(new Error(...))` 를 쓰면 500(SYS_001)이 되어 원인 파악이 어려워진다)
|
||||
- Rate Limiting: **`@nestjs/throttler` 하나로 일원화**되어 있다 (`app.module.ts` 의 `APP_GUARD`).
|
||||
초과 시 429 → 전역 필터가 **`REQ_001`** 로 매핑한다.
|
||||
- 기본 정책은 루트 `.env` 의 `THROTTLE_TTL_MS`/`THROTTLE_LIMIT` (미설정 시 60초/100회)
|
||||
- 라우트 단위 조정: `@Throttle({ default: { ttl, limit } })` / 제외: `@SkipThrottle()`
|
||||
- 로그인·비밀번호 재설정처럼 브루트포스 표적이 되는 엔드포인트는 **더 엄격한 한도**를 따로 건다
|
||||
- ⚠️ rate limiter 를 이중으로 걸지 말 것 — 어느 쪽이 실제로 동작하는지 알 수 없게 되고 응답 헤더가 실제 한도와 어긋난다
|
||||
- **컨테이너 root 실행 금지 — 개발 환경 포함.** 운영뿐 아니라 dev 오버라이드가 사용하는 빌드 스테이지도 비-root 여야 한다.
|
||||
- 프론트엔드 운영: `nginxinc/nginx-unprivileged` + `listen 8080` (비-root 는 1024 미만 포트 바인드 불가)
|
||||
- Node 스테이지: `RUN chown node:node /app` → `USER node` → `COPY --chown=node:node`
|
||||
(소유권을 먼저 넘겨야 익명 볼륨 `node_modules` 가 node 소유로 생성되어 캐시 쓰기가 가능하다)
|
||||
- DB·Redis 는 `expose` 만 사용해 내부 네트워크에만 노출한다.
|
||||
개발 편의로 호스트에 열어야 한다면 **반드시 `127.0.0.1` 로 한정**한다 — `"${DB_PORT}:5432"` 는 `0.0.0.0` 바인딩이라
|
||||
같은 공유기(사무실·카페)에 있는 누구나 개발 DB 에 접속할 수 있다.
|
||||
```yaml
|
||||
ports:
|
||||
- "127.0.0.1:${DB_PORT}:5432" # ✅
|
||||
# - "${DB_PORT}:5432" # ❌ 외부 네트워크 노출
|
||||
```
|
||||
- **보안 헤더는 CSP 까지** 설정한다 (`frontend/nginx.conf`). 외부 CDN·폰트·분석 스크립트를 추가하면
|
||||
해당 도메인을 CSP 에 명시한다. `script-src` 에 `'unsafe-inline'`/`'unsafe-eval'` 을 넣으면 CSP 의 의미가 사라진다.
|
||||
API 도메인이 다르면 `connect-src` 에 추가해야 요청이 차단되지 않는다.
|
||||
|
||||
## 의존성
|
||||
|
||||
- 의존성 추가 시 유지보수 상태를 확인하고, 주기적으로 `npm audit` / `flutter pub outdated` 로 취약점을 점검한다
|
||||
- **`npm audit fix` 는 반드시 컨테이너 안에서 실행**한다. 호스트(Windows)에서 실행하면 lock 파일이 깨진다 ([[nestjs]])
|
||||
```bash
|
||||
docker exec <프로젝트>-backend npm audit fix
|
||||
```
|
||||
- **`--force` 는 함부로 쓰지 않는다.** 메이저 버전을 올려 다른 패키지와의 호환이 깨질 수 있다.
|
||||
실행 전 `npm audit fix --force --dry-run` 으로 어떤 패키지가 올라가는지 확인한다.
|
||||
|
||||
### 남아 있는 취약점 판단 기준
|
||||
|
||||
모든 취약점을 즉시 올릴 수 있는 것은 아니다. 다음을 근거로 판단하고 **결정을 기록**한다.
|
||||
|
||||
1. **도달 가능한 코드 경로가 있는가** — 해당 기능을 쓰지 않으면 노출되지 않는다
|
||||
2. **수정이 다른 것을 깨뜨리는가** — 생태계 버전이 어긋나면 더 큰 위험
|
||||
3. 미룬다면 **언제 다시 볼지** 조건을 적어둔다
|
||||
|
||||
> **현재 유예 중인 항목**
|
||||
> - `multer` (high, DoS via deeply nested field names) — `@nestjs/platform-express` 의 전이 의존성.
|
||||
> 수정하려면 `@nestjs/platform-express@12` 가 필요한데 나머지 Nest 생태계가 v11 이라 호환이 깨진다.
|
||||
> **이 템플릿에는 파일 업로드 엔드포인트가 없어 도달 경로가 없다**(`FileInterceptor` 미사용).
|
||||
> → **파일 업로드를 구현하는 시점에 반드시 재검토**한다. Nest v12 로 일괄 업그레이드하는 것이 정석.
|
||||
|
||||
> 환경변수 관리 규칙은 [[env-structure]], 도메인별 적용은 [[nestjs]] · [[flutter]] 참조.
|
||||
|
||||
@@ -79,6 +79,25 @@ dc exec redis redis-cli -a <REDIS_PASSWORD>
|
||||
## 규칙
|
||||
|
||||
- **빌드 최소화**: 의존성(package.json / pubspec 등) 변경이 없으면 `--build` 없이 기동한다.
|
||||
- **env 파일**: 실행 전 `.env.{APP_ENV}` 존재를 확인한다. 없으면 `.env.{APP_ENV}.example` 을 복사해 생성.
|
||||
- **컨테이너 비-root 구동**: 모든 서비스 컨테이너는 비-root 사용자로 실행한다 ([[security]] 참조).
|
||||
- **의존성을 바꿨다면 `--renew-anon-volumes` 를 함께 준다.**
|
||||
dev 는 `/app/node_modules` 를 익명 볼륨으로 마운트하는데, `--build` 만으로는 **예전 볼륨이 그대로 재사용된다.**
|
||||
즉 새 패키지가 이미지에는 들어가도 컨테이너에는 반영되지 않는다.
|
||||
```bash
|
||||
dc up -d --build --renew-anon-volumes backend
|
||||
```
|
||||
(`-V` 는 같은 옵션의 축약형. DB 볼륨은 named volume 이라 영향받지 않는다)
|
||||
- **env 파일**: 실행 전 아래 두 파일이 모두 있어야 한다. 없으면 `*.example` 을 복사해 생성한다.
|
||||
- 루트 `.env.{APP_ENV}` — 설정 SSOT ([[env-structure]])
|
||||
- `backend/.env.{APP_ENV}` — compose `env_file` 대상이라 **비어 있어도 존재해야 한다**
|
||||
복사 후 `change-me-*` placeholder 값을 반드시 교체한다 ([[security]]).
|
||||
- **Graceful shutdown**: backend 는 `init: true`(tini)로 PID 1 을 두어 SIGTERM 이 애플리케이션까지 전달되게 하고,
|
||||
`stop_grace_period: 30s` 동안 처리 중인 요청을 마치도록 한다.
|
||||
- 정상 종료 시 컨테이너 **종료 코드는 143**(=128+SIGTERM)이다. 오류가 아니라 시그널에 의한 정상 종료다.
|
||||
- ⚠️ dev 오버라이드는 PID 1 이 `npm` 이라 SIGTERM 이 앱까지 전달되지 않는다(개발 편의상 허용).
|
||||
graceful shutdown 동작 확인은 **운영 이미지**(`--target production`)로 해야 한다.
|
||||
- **컨테이너 비-root 구동**: 모든 서비스 컨테이너는 비-root 로 실행한다 — **dev 오버라이드도 포함** ([[security]] 참조).
|
||||
- 프론트엔드 운영 컨테이너는 `nginx-unprivileged` 라 **8080** 포트를 쓴다 (`expose`·healthcheck·nginx.conf 가 모두 8080).
|
||||
- 프론트엔드 dev 컨테이너는 vite 를 **5173** 으로 띄운다 (비-root 는 80 포트 바인드 불가).
|
||||
- 포트를 바꿀 때는 Dockerfile `EXPOSE` · `nginx.conf` 의 `listen` · compose `expose`/`healthcheck` 를 **함께** 고쳐야 한다.
|
||||
- **DB/Redis 호스트 포트**: dev 에서만 열며 `127.0.0.1` 로 한정한다. 접두어 없이 쓰면 LAN 전체에 노출된다 ([[security]]).
|
||||
- **개발/운영 분리**: 개발은 `docker-compose.yml + docker-compose.dev.yml`, 운영은 `docker-compose.yml` 단독 + `.env.production`.
|
||||
|
||||
@@ -20,8 +20,8 @@ REDIS_PORT=6379
|
||||
# Database 설정
|
||||
# ==========================================
|
||||
DB_IMAGE=postgres:15-alpine
|
||||
DB_USER=dev_ttipo
|
||||
DB_PASSWORD=dev_960426
|
||||
DB_USER=app_user
|
||||
DB_PASSWORD=change-me-in-local-env
|
||||
DB_NAME=project_db
|
||||
DB_DATA_DIR=./data/postgres
|
||||
|
||||
@@ -29,11 +29,18 @@ DB_DATA_DIR=./data/postgres
|
||||
# Redis 설정
|
||||
# ==========================================
|
||||
REDIS_IMAGE=redis:7-alpine
|
||||
REDIS_PASSWORD=dev_960426
|
||||
REDIS_PASSWORD=change-me-in-local-env
|
||||
REDIS_DATA_DIR=./data/redis
|
||||
|
||||
# ==========================================
|
||||
# API 경로 설정
|
||||
# ==========================================
|
||||
BACKEND_API_URL=http://localhost:3000/api
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# ==========================================
|
||||
# Rate Limiting (API 남용 방지)
|
||||
# 미설정 시 백엔드 기본값(60초 / 100회) 사용
|
||||
# ==========================================
|
||||
THROTTLE_TTL_MS=60000
|
||||
THROTTLE_LIMIT=100
|
||||
|
||||
+11
-4
@@ -20,8 +20,8 @@ REDIS_PORT=6379
|
||||
# Database 설정
|
||||
# ==========================================
|
||||
DB_IMAGE=postgres:15-alpine
|
||||
DB_USER=prd_ttipo
|
||||
DB_PASSWORD=prd_960426
|
||||
DB_USER=app_user
|
||||
DB_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
|
||||
DB_NAME=project_db
|
||||
DB_DATA_DIR=./data/postgres
|
||||
|
||||
@@ -29,11 +29,18 @@ DB_DATA_DIR=./data/postgres
|
||||
# Redis 설정
|
||||
# ==========================================
|
||||
REDIS_IMAGE=redis:7-alpine
|
||||
REDIS_PASSWORD=prd_960426
|
||||
REDIS_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
|
||||
REDIS_DATA_DIR=./data/redis
|
||||
|
||||
# ==========================================
|
||||
# API 경로 설정
|
||||
# ==========================================
|
||||
BACKEND_API_URL=http://localhost:3000/api
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# ==========================================
|
||||
# Rate Limiting (API 남용 방지)
|
||||
# 미설정 시 백엔드 기본값(60초 / 100회) 사용
|
||||
# ==========================================
|
||||
THROTTLE_TTL_MS=60000
|
||||
THROTTLE_LIMIT=100
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# ==========================================
|
||||
# 백엔드 고유 시크릿 (development)
|
||||
#
|
||||
# ⚠️ DB / Redis / CORS / PORT 는 루트 .env.development 에서 docker-compose 가 주입한다.
|
||||
# 여기에 중복 작성하지 말 것 (SSOT: 루트 .env — .claude/rules/env-structure.md).
|
||||
# 이 파일에는 "루트에서 내려주지 않는 값"만 작성한다.
|
||||
#
|
||||
# 사용법: cp .env.development.example .env.development
|
||||
# docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다.
|
||||
# ==========================================
|
||||
|
||||
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
|
||||
JWT_SECRET=change-me-generate-a-long-random-string
|
||||
JWT_ACCESS_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=14d
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# ==========================================
|
||||
# 백엔드 고유 시크릿 (production)
|
||||
#
|
||||
# ⚠️ DB / Redis / CORS / PORT 는 루트 .env.production 에서 docker-compose 가 주입한다.
|
||||
# 여기에 중복 작성하지 말 것 (SSOT: 루트 .env — .claude/rules/env-structure.md).
|
||||
# 이 파일에는 "루트에서 내려주지 않는 값"만 작성한다.
|
||||
#
|
||||
# 사용법: cp .env.production.example .env.production
|
||||
# docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다.
|
||||
# ==========================================
|
||||
|
||||
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
|
||||
JWT_SECRET=change-me-generate-a-long-random-string
|
||||
JWT_ACCESS_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=14d
|
||||
|
||||
+8
-2
@@ -2,13 +2,19 @@ FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
|
||||
# (전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행)
|
||||
RUN npm install -g npm@11
|
||||
|
||||
# 개발 환경(docker-compose.dev.yml)은 이 스테이지를 그대로 구동하므로 비-root 로 전환한다.
|
||||
# /app 소유권을 넘겨야 익명 볼륨(node_modules)도 node 소유로 생성되어 캐시 쓰기가 가능하다.
|
||||
RUN chown node:node /app
|
||||
USER node
|
||||
|
||||
# 재현 가능한 빌드를 위해 npm ci 사용 (lockfile 기반)
|
||||
COPY package*.json ./
|
||||
COPY --chown=node:node package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
COPY --chown=node:node . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
+10
-4
@@ -4,9 +4,13 @@ services:
|
||||
restart: "no"
|
||||
build:
|
||||
target: build-stage
|
||||
command: npm run dev -- --host 0.0.0.0 --port 80
|
||||
# 비-root 구동이라 1024 미만 포트를 쓸 수 없다 → vite 기본 포트 사용
|
||||
command: npm run dev -- --host 0.0.0.0 --port 5173
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
- "${FRONTEND_PORT}:5173"
|
||||
# dev 는 nginx(8080)가 아니라 vite(5173)를 띄우므로 base 의 헬스체크를 덮어쓴다
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5173/ || exit 1"]
|
||||
environment:
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
- WATCHPACK_POLLING=true
|
||||
@@ -39,14 +43,16 @@ services:
|
||||
db:
|
||||
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀)
|
||||
restart: "no"
|
||||
# 로컬 DB 툴 접속용. 127.0.0.1 로 한정해 외부 네트워크에 노출되지 않게 한다 (보안 규칙)
|
||||
ports:
|
||||
- "${DB_PORT}:5432"
|
||||
- "127.0.0.1:${DB_PORT}:5432"
|
||||
|
||||
redis:
|
||||
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀)
|
||||
restart: "no"
|
||||
# 로컬 Redis CLI 접속용. 127.0.0.1 로 한정 (보안 규칙)
|
||||
ports:
|
||||
- "${REDIS_PORT}:6379"
|
||||
- "127.0.0.1:${REDIS_PORT}:6379"
|
||||
|
||||
# base의 proxy 외부 네트워크 의존을 dev에서 제거
|
||||
networks:
|
||||
|
||||
+11
-3
@@ -11,7 +11,7 @@ services:
|
||||
- BUILD_MODE=${BUILD_MODE}
|
||||
- VITE_API_BASE_URL=${BACKEND_API_URL}
|
||||
expose:
|
||||
- "80"
|
||||
- "8080"
|
||||
environment:
|
||||
- NODE_ENV=${APP_ENV}
|
||||
restart: unless-stopped
|
||||
@@ -21,7 +21,8 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"]
|
||||
# localhost 는 컨테이너 안에서 ::1 로 먼저 해석되는데 nginx 는 IPv4 만 바인딩하므로 127.0.0.1 을 명시한다
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -43,6 +44,10 @@ services:
|
||||
expose:
|
||||
- "${BACKEND_PORT}"
|
||||
- "${WEBSOCKET_PORT}"
|
||||
# PID 1 을 tini 로 두어 SIGTERM 이 애플리케이션까지 확실히 전달되게 한다 (graceful shutdown)
|
||||
init: true
|
||||
# 처리 중인 요청을 마칠 시간 — 초과하면 SIGKILL (기본 10초)
|
||||
stop_grace_period: 30s
|
||||
env_file:
|
||||
- ./${BACKEND_DIR}/.env.${APP_ENV}
|
||||
environment:
|
||||
@@ -50,6 +55,8 @@ services:
|
||||
- PORT=${BACKEND_PORT}
|
||||
- WEBSOCKET_PORT=${WEBSOCKET_PORT}
|
||||
- CORS_ORIGIN=${CORS_ORIGIN}
|
||||
- THROTTLE_TTL_MS=${THROTTLE_TTL_MS}
|
||||
- THROTTLE_LIMIT=${THROTTLE_LIMIT}
|
||||
- DB_HOST=db
|
||||
- DB_PORT=5432
|
||||
- DB_USER=${DB_USER}
|
||||
@@ -68,7 +75,8 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:${BACKEND_PORT}/ || exit 1"]
|
||||
# localhost 는 컨테이너 안에서 ::1 로 먼저 해석되는데 Node 는 0.0.0.0(IPv4) 바인딩이므로 127.0.0.1 을 명시한다
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:${BACKEND_PORT}/ || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# ==========================================
|
||||
# 프론트엔드 환경변수 (development)
|
||||
#
|
||||
# ⚠️ VITE_API_BASE_URL 은 루트 .env.development 의 BACKEND_API_URL 이
|
||||
# docker-compose build arg 로 주입된다 — 여기에 작성할 필요가 없다.
|
||||
# (SSOT: 루트 .env — .claude/rules/env-structure.md)
|
||||
#
|
||||
# Docker 없이 vite dev 서버를 직접 띄울 때만 아래 값을 사용한다.
|
||||
# cp .env.development.example .env.development
|
||||
#
|
||||
# 모든 키는 VITE_ 접두어가 있어야 클라이언트 번들에 포함된다.
|
||||
# ⚠️ 번들에 그대로 노출되므로 시크릿을 넣지 말 것.
|
||||
# ==========================================
|
||||
|
||||
# VITE_API_BASE_URL=http://localhost:3000/api
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# ==========================================
|
||||
# 프론트엔드 환경변수 (production)
|
||||
#
|
||||
# ⚠️ VITE_API_BASE_URL 은 루트 .env.production 의 BACKEND_API_URL 이
|
||||
# docker-compose build arg 로 주입된다 — 여기에 작성할 필요가 없다.
|
||||
# (SSOT: 루트 .env — .claude/rules/env-structure.md)
|
||||
#
|
||||
# Docker 없이 vite dev 서버를 직접 띄울 때만 아래 값을 사용한다.
|
||||
# cp .env.production.example .env.production
|
||||
#
|
||||
# 모든 키는 VITE_ 접두어가 있어야 클라이언트 번들에 포함된다.
|
||||
# ⚠️ 번들에 그대로 노출되므로 시크릿을 넣지 말 것.
|
||||
# ==========================================
|
||||
|
||||
# VITE_API_BASE_URL=http://localhost:3000/api
|
||||
|
||||
+15
-5
@@ -2,12 +2,18 @@ FROM node:20-alpine AS build-stage
|
||||
WORKDIR /app
|
||||
|
||||
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
|
||||
# (전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행)
|
||||
RUN npm install -g npm@11
|
||||
|
||||
COPY package*.json ./
|
||||
# 개발 환경(docker-compose.dev.yml)은 이 스테이지에서 vite dev 서버를 구동하므로 비-root 로 전환한다.
|
||||
# /app 소유권을 넘겨야 익명 볼륨(node_modules)이 node 소유로 생성되어 vite 캐시(.vite) 쓰기가 가능하다.
|
||||
RUN chown node:node /app
|
||||
USER node
|
||||
|
||||
COPY --chown=node:node package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
COPY --chown=node:node . .
|
||||
|
||||
ARG BUILD_MODE=production
|
||||
ARG VITE_API_BASE_URL
|
||||
@@ -15,12 +21,16 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
|
||||
RUN npm run build-only -- --mode ${BUILD_MODE}
|
||||
|
||||
FROM nginx:stable-alpine AS production-stage
|
||||
# 비-root 전용 nginx 이미지 (보안 규칙: 컨테이너 root 실행 금지)
|
||||
# 1024 미만 포트는 바인드할 수 없으므로 8080 을 사용한다 — nginx.conf 의 listen 과 일치해야 한다.
|
||||
FROM nginxinc/nginx-unprivileged:stable-alpine AS production-stage
|
||||
|
||||
# 보안 헤더 포함된 nginx 설정 적용
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
|
||||
# 비-root 실행은 nginx-unprivileged 이미지 도입 시 활성화 (현재는 80 포트 바인드 필요)
|
||||
EXPOSE 80
|
||||
# 이미지 기본 사용자(nginx, uid 101)로 구동 — root 로 되돌리지 말 것
|
||||
USER nginx
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
+16
-2
@@ -1,5 +1,6 @@
|
||||
server {
|
||||
listen 80;
|
||||
# 비-root(nginx-unprivileged) 구동이라 1024 미만 포트를 쓸 수 없다
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
|
||||
# ----------------------------------------
|
||||
@@ -7,10 +8,23 @@ server {
|
||||
# ----------------------------------------
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
|
||||
# Content-Security-Policy — XSS 방어의 핵심 헤더
|
||||
#
|
||||
# ⚠️ 프로젝트에 맞게 조정이 필요한 설정이다. 기본값 설명:
|
||||
# - default-src 'self' : 기본적으로 같은 출처만 허용
|
||||
# - style-src 'unsafe-inline' : Vue 의 동적 스타일 바인딩(:style)이 인라인 스타일을 생성하므로 필요
|
||||
# - connect-src 'self' : API 가 다른 도메인이면 그 origin 을 반드시 추가할 것
|
||||
# (예: connect-src 'self' https://api.example.com)
|
||||
# - img-src data: blob: : 인라인 아이콘·캔버스/파일 미리보기 대응
|
||||
# - frame-ancestors 'none' : 클릭재킹 차단 (X-Frame-Options 의 최신 대체재)
|
||||
#
|
||||
# 외부 폰트·분석 스크립트·이미지 CDN 을 도입하면 해당 도메인을 명시적으로 추가한다.
|
||||
# script-src 에 'unsafe-inline'/'unsafe-eval' 을 추가하면 CSP 의 의미가 사라지므로 피할 것.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
|
||||
# ----------------------------------------
|
||||
# 압축 (Gzip)
|
||||
# ----------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user