From 52e671cedbdf55097a2b902475c3359ff4ddd13d Mon Sep 17 00:00:00 2001 From: ttipo Date: Mon, 21 Sep 2026 11:42:23 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=EC=9A=B4=EC=98=81=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=20URL=20=EC=97=90=20=EB=A1=9C=EC=BB=AC=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=EA=B0=80=20=EB=82=A8=EC=9C=BC=EB=A9=B4=20=EA=B8=B0?= =?UTF-8?q?=EB=8F=99=EC=9D=84=20=EC=8B=A4=ED=8C=A8=EC=8B=9C=ED=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .env.production.example 이 개발용 값(localhost)을 그대로 담고 있어, 복사해 배포하면 서버는 정상 기동하지만 CORS 화이트리스트가 실제 도메인을 막고 프론트 번들은 localhost 를 호출한다 — 브라우저에서만 조용히 실패해 원인을 찾기 어렵다. - CORS_ORIGIN·BACKEND_API_URL 에 로컬 주소나 예시 도메인이 있으면 운영에서 기동 실패 - 예제 값을 도메인 형태로 교체 (그대로 두면 이 검사에 걸린다) - BACKEND_API_URL 은 앱이 쓰지 않지만 검사를 위해 compose 가 backend 에 주입한다 - 로컬에서 운영 이미지를 검증할 때만 ALLOW_LOCALHOST_URL=true 로 건너뛴다 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/rules/checklist.md | 1 + .claude/rules/env-structure.md | 4 ++ .claude/skills/docker/SKILL.md | 5 ++ .env.production.example | 15 +++++- .../src/shared/config/env.validation.spec.ts | 49 +++++++++++++++++++ backend/src/shared/config/env.validation.ts | 43 ++++++++++++++++ docker-compose.yml | 5 ++ 7 files changed, 120 insertions(+), 2 deletions(-) diff --git a/.claude/rules/checklist.md b/.claude/rules/checklist.md index ee5ed19..154a73e 100644 --- a/.claude/rules/checklist.md +++ b/.claude/rules/checklist.md @@ -8,6 +8,7 @@ ## 1. 신규 프로젝트 셋업 (클론 직후 1회) - [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]]) + 운영은 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인**으로 — `localhost`/`example.com` 이 남으면 기동 실패한다 - [ ] `backend/.env.{env}.example` → `backend/.env.{env}` 복사 후 `JWT_SECRET` 등 고유 시크릿 작성 (compose `env_file` 로 로드 — 파일이 없으면 기동 실패) - [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL` 확인 (Android 에뮬레이터: `10.0.2.2`) — **이 파일이 없으면 `flutter analyze` 가 asset 경고를 낸다** diff --git a/.claude/rules/env-structure.md b/.claude/rules/env-structure.md index 7ad4a9b..6318e7f 100644 --- a/.claude/rules/env-structure.md +++ b/.claude/rules/env-structure.md @@ -39,6 +39,10 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{ - 숫자 항목은 형식 검사 (모든 환경) - 운영 환경: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME` 필수 - 운영 환경: `change-me`·`password` 같은 **예시 값이 그대로 배포되는 것을 차단** + - 운영 환경: `CORS_ORIGIN`·`BACKEND_API_URL` 에 **로컬 주소(`localhost`·`127.0.0.1` 등)나 + 예시 도메인(`example.com`)이 남아 있으면 차단** — 기동은 되는데 브라우저만 조용히 실패하는 조합을 막는다. + 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓸 때만 `ALLOW_LOCALHOST_URL=true` 로 건너뛴다 + (`BACKEND_API_URL` 은 앱이 쓰지 않지만 이 검사를 위해 compose 가 backend 에 주입한다) - 문제는 하나씩이 아니라 **한 번에 모아서** 보여준다 (재기동 반복 방지) - 새 필수 설정을 추가하면 이 파일의 목록과 테스트도 함께 갱신한다 diff --git a/.claude/skills/docker/SKILL.md b/.claude/skills/docker/SKILL.md index 89d6b6c..d6c0256 100644 --- a/.claude/skills/docker/SKILL.md +++ b/.claude/skills/docker/SKILL.md @@ -29,6 +29,10 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d ## 운영 환경 +> ⚠️ `.env.production` 의 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인으로 교체**해야 한다. +> `localhost` 나 예시 도메인(`example.com`)이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]). +> 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만 `ALLOW_LOCALHOST_URL=true` 를 함께 준다. + ```bash # 운영 환경 배포 sudo docker compose --env-file .env.production up -d --build @@ -179,6 +183,7 @@ dc --profile redis up -d - 정상 종료 시 컨테이너 **종료 코드는 143**(=128+SIGTERM)이다. 오류가 아니라 시그널에 의한 정상 종료다. - ⚠️ dev 오버라이드는 PID 1 이 `npm` 이라 SIGTERM 이 앱까지 전달되지 않는다(개발 편의상 허용). graceful shutdown 동작 확인은 **운영 이미지**(`--target production`)로 해야 한다. + 이때 로컬 주소를 쓰게 되므로 `ALLOW_LOCALHOST_URL=true` 로 공개 주소 검사를 건너뛴다 ([[env-structure]]). - **컨테이너 비-root 구동**: 모든 서비스 컨테이너는 비-root 로 실행한다 — **dev 오버라이드도 포함** ([[security]] 참조). - 프론트엔드 운영 컨테이너는 `nginx-unprivileged` 라 **8080** 포트를 쓴다 (`expose`·healthcheck·nginx.conf 가 모두 8080). - 프론트엔드 dev 컨테이너는 vite 를 **5173** 으로 띄운다 (비-root 는 80 포트 바인드 불가). diff --git a/.env.production.example b/.env.production.example index 3c1fb5d..7aae564 100644 --- a/.env.production.example +++ b/.env.production.example @@ -34,9 +34,20 @@ REDIS_DATA_DIR=./data/redis # ========================================== # API 경로 설정 +# +# ⚠️ 운영에서는 **반드시 실제 공개 도메인**으로 교체한다. +# - BACKEND_API_URL : 브라우저가 호출할 API 주소 (프론트엔드 번들에 그대로 박힌다) +# - CORS_ORIGIN : 백엔드가 허용할 오리진 (scheme+host[+port], 여러 개면 콤마 구분) +# +# 아래 example.com 이나 localhost 가 남아 있으면 **백엔드가 기동에 실패한다** +# (backend/src/shared/config/env.validation.ts — fail-fast). +# 기동은 되는데 브라우저만 조용히 실패하는 상황을 막기 위한 검사다. +# +# 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만 +# ALLOW_LOCALHOST_URL=true 로 이 검사를 건너뛴다. 실제 배포에는 설정하지 않는다. # ========================================== -BACKEND_API_URL=http://localhost:3000/api -CORS_ORIGIN=http://localhost:5173 +BACKEND_API_URL=https://api.example.com/api +CORS_ORIGIN=https://app.example.com # ========================================== # Rate Limiting (API 남용 방지) diff --git a/backend/src/shared/config/env.validation.spec.ts b/backend/src/shared/config/env.validation.spec.ts index e67221e..65ef407 100644 --- a/backend/src/shared/config/env.validation.spec.ts +++ b/backend/src/shared/config/env.validation.spec.ts @@ -61,6 +61,55 @@ describe('validateEnv', () => { ); }); + it('공개 주소에 로컬 주소가 남아 있으면 실패한다', () => { + expect(() => + validateEnv(prod({ CORS_ORIGIN: 'http://localhost:5173' })), + ).toThrow(/CORS_ORIGIN 가 운영에서 쓸 수 없는 주소입니다/); + + expect(() => + validateEnv(prod({ BACKEND_API_URL: 'http://127.0.0.1:3000/api' })), + ).toThrow(/BACKEND_API_URL 가 운영에서 쓸 수 없는 주소입니다/); + }); + + it('예시 파일의 example.com 이 그대로면 실패한다', () => { + expect(() => + validateEnv(prod({ CORS_ORIGIN: 'https://app.example.com' })), + ).toThrow(/CORS_ORIGIN 가 운영에서 쓸 수 없는 주소입니다/); + }); + + it('실제 도메인이면 통과한다', () => { + expect(() => + validateEnv( + prod({ + CORS_ORIGIN: 'https://app.myservice.io', + BACKEND_API_URL: 'https://api.myservice.io/api', + }), + ), + ).not.toThrow(); + }); + + it('ALLOW_LOCALHOST_URL=true 면 공개 주소 검사를 건너뛴다 (운영 이미지 로컬 검증용)', () => { + expect(() => + validateEnv( + prod({ + CORS_ORIGIN: 'http://localhost:5173', + BACKEND_API_URL: 'http://localhost:3000/api', + ALLOW_LOCALHOST_URL: 'true', + }), + ), + ).not.toThrow(); + }); + + it('공개 주소 검사는 운영 환경에서만 동작한다', () => { + expect(() => + validateEnv({ + NODE_ENV: 'development', + CORS_ORIGIN: 'http://localhost:5173', + BACKEND_API_URL: 'http://localhost:3000/api', + }), + ).not.toThrow(); + }); + it('문제가 여러 개면 한 번에 모두 보여준다', () => { const partial = omit(prod(), 'DB_HOST', 'DB_NAME'); diff --git a/backend/src/shared/config/env.validation.ts b/backend/src/shared/config/env.validation.ts index 8ac8692..674a545 100644 --- a/backend/src/shared/config/env.validation.ts +++ b/backend/src/shared/config/env.validation.ts @@ -33,6 +33,29 @@ const NO_PLACEHOLDER_IN_PRODUCTION = [ 'JWT_SECRET', ] as const; +/** + * 운영에서 **공개 주소**여야 하는 키. + * 로컬 주소가 남은 채 배포되면 서버는 정상 기동하지만 + * 브라우저가 API 에 닿지 못하거나 CORS 화이트리스트가 실제 도메인을 막아 **조용히** 실패한다. + */ +const PUBLIC_URL_IN_PRODUCTION = ['CORS_ORIGIN', 'BACKEND_API_URL'] as const; + +/** 공개 주소로 쓸 수 없는 값 — 로컬 주소와 예시 파일의 예시 도메인 */ +const NON_PUBLIC_URL_PATTERNS = [ + /localhost/i, + /127\.0\.0\.1/, + /\[::1\]/, + /0\.0\.0\.0/, + /example\.com/i, +]; + +/** + * 위 공개 주소 검사를 건너뛰는 탈출구. + * 로컬에서 **운영 이미지를 검증**할 때만 쓴다 (예: graceful shutdown 확인 — .claude/skills/docker/SKILL.md). + * 실제 배포에서는 절대 설정하지 않는다. + */ +const ALLOW_LOCALHOST_URL_KEY = 'ALLOW_LOCALHOST_URL'; + function readString( config: Record, key: string, @@ -47,6 +70,10 @@ function looksLikePlaceholder(value: string): boolean { return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value)); } +function looksNonPublic(value: string): boolean { + return NON_PUBLIC_URL_PATTERNS.some((pattern) => pattern.test(value)); +} + /** * 환경변수를 검증한다. 문제가 있으면 **모든 문제를 모아** 예외를 던진다. * (하나씩 고쳐가며 재기동하는 낭비를 막기 위해 한 번에 보여준다) @@ -85,6 +112,22 @@ export function validateEnv( ); } } + + // 4. 공개 주소가 로컬 주소·예시 도메인으로 남은 채 배포되는 것을 막는다 + const allowLocalhostUrl = + readString(config, ALLOW_LOCALHOST_URL_KEY) === 'true'; + + if (!allowLocalhostUrl) { + for (const key of PUBLIC_URL_IN_PRODUCTION) { + const value = readString(config, key); + if (value !== undefined && looksNonPublic(value)) { + errors.push( + `${key} 가 운영에서 쓸 수 없는 주소입니다. 실제 도메인으로 교체해 주세요. (현재 값: "${value}")` + + ` — 로컬에서 운영 이미지를 검증하는 중이라면 ${ALLOW_LOCALHOST_URL_KEY}=true`, + ); + } + } + } } if (errors.length > 0) { diff --git a/docker-compose.yml b/docker-compose.yml index 79419d2..7ccb969 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,11 @@ services: - PORT=${BACKEND_PORT} - WEBSOCKET_PORT=${WEBSOCKET_PORT} - CORS_ORIGIN=${CORS_ORIGIN} + # 앱이 직접 쓰지는 않는다. 로컬 주소가 남은 채 운영에 배포되는 것을 + # 기동 시점에 잡기 위해 검증용으로 주입한다 (shared/config/env.validation.ts) + - BACKEND_API_URL=${BACKEND_API_URL} + # 위 공개 주소 검사를 건너뛰는 탈출구 — 로컬에서 운영 이미지를 검증할 때만 true + - ALLOW_LOCALHOST_URL=${ALLOW_LOCALHOST_URL:-} - THROTTLE_TTL_MS=${THROTTLE_TTL_MS:-60000} - THROTTLE_LIMIT=${THROTTLE_LIMIT:-100} - DB_HOST=db