서버 기동을 거부시키는 설정 검증과 Path().name 한 줄로 막는 Path Traversal
`ALLOWED_ORIGINS`를 까먹으면 CORS가 `*` 폴백으로 조용히 열려 있던 문제와 URL 파라미터에 `../`를 꽂으면 상위 디렉토리 접근이 가능했던 path traversal을 각각 한 줄로 수정한 이야기입니다.
1. 문제 상황: 두 가지 조용한 보안 허점
이번 편은 하나의 도메인이 아니라 두 가지 서로 다른 보안 패턴을 묶었습니다. 둘 다 한 줄짜리 수정이지만, 공격 표면에는 큰 차이를 만듭니다.
| 문제 | 증상 | 치명도 |
|---|---|---|
| ① CORS wildcard 폴백 | ALLOWED_ORIGINS 환경변수가 없으면 origins=["*"]로 fallback. 프로덕션 배포에서 실수로 빈값이면 CORS가 사실상 무방비 |
높음 |
| ② Path traversal | URL 파라미터 train_id나 model_name에 ../를 넣으면 상위 디렉토리 파일 접근 가능 |
높음 |
두 문제의 공통점은 "누가 알려주기 전까지는 눈에 보이지 않는다"는 것입니다. 정상 시나리오에서는 완벽히 동작하고, 일부러 찾아봐야 발견됩니다.
2. 문제 ①: ALLOWED_ORIGINS 미설정 시 wildcard 폴백
Before
# app.py (Before)
_allowed_origins = [
o.strip()
for o in os.environ.get("ALLOWED_ORIGINS", "").split(",")
if o.strip()
]
CORS(app, supports_credentials=True, origins=_allowed_origins or ["*"])
문제가 되는 부분은 origins=_allowed_origins or ["*"] 입니다. ALLOWED_ORIGINS가 비어 있으면 _allowed_origins가 빈 리스트가 되고, 파이썬 or 연산자에 의해 ["*"]로 대체됩니다.
왜 이것이 치명적인가
Flask-CORS의 origins="*"는 모든 도메인에서 오는 요청을 허용한다는 뜻입니다. 여기에 supports_credentials=True가 붙어 있으면 문제가 더 심각해집니다.
CORS(app, supports_credentials=True, origins=["*"])
이 조합은 브라우저 스펙상으로는 "쿠키/인증 헤더를 포함한 요청을 모든 출처에서 허용"하려는 것으로, 대부분의 브라우저가 거부합니다. 하지만 일부 브라우저 구현과 네트워크 경계에 따라 CSRF 보호를 우회하거나 세션 토큰을 제3자 사이트로 누출할 위험이 있습니다.
Flask-CORS는 이 조합을 넘기면 워닝을 찍긴 하지만, 앱은 계속 실행됩니다. 배포자가 로그를 꼼꼼히 읽지 않으면 이 경고를 놓치기 쉽습니다.
After: Fail-fast
# app.py (After) — 초기 구현
_allowed_origins = [
o.strip()
for o in os.environ.get("ALLOWED_ORIGINS", "").split(",")
if o.strip()
]
if not _allowed_origins:
raise RuntimeError(
"ALLOWED_ORIGINS environment variable is required. "
"Example: ALLOWED_ORIGINS=http://localhost:8080,https://yourdomain.com"
)
CORS(app, supports_credentials=True, origins=_allowed_origins)
변화는 딱 한 가지입니다. wildcard 폴백을 제거하고 서버 기동을 거부합니다. ALLOWED_ORIGINS가 없으면 프로세스는 RuntimeError를 던지고 즉시 죽습니다.
이후 1편에서 Blueprint 분리 리팩토링을 거치면서 이 검증 코드는 config.py로 이동했습니다. 지금 코드는 다음과 같습니다.
# config.py (현재)
ALLOWED_ORIGINS = [
o.strip()
for o in os.environ.get("ALLOWED_ORIGINS", "").split(",")
if o.strip()
]
if not ALLOWED_ORIGINS:
raise RuntimeError(
"ALLOWED_ORIGINS environment variable is required. "
"Example: ALLOWED_ORIGINS=http://localhost:8080,https://yourdomain.com"
)
위치는 바뀌었지만 철학은 동일합니다. 필수 설정 없이는 앱이 뜨지 않는다.
3. Fail-fast의 가치
"앱이 뜨지 않는다"는 선택이 왜 안전한지 한 번 짚고 가겠습니다. 소프트웨어에서 장애는 두 종류가 있습니다.
| 장애 타입 | 증상 | 발견 시점 |
|---|---|---|
| Loud failure (요란한 장애) | 서버가 기동 실패, 배포 파이프라인이 깨짐 | 배포 시점 즉시 |
| Silent failure (조용한 장애) | 서버는 정상적으로 뜨지만 내부가 취약, 로그 경고만 있음 | 운영 중 또는 침입 후 |
Loud failure가 silent failure보다 항상 낫습니다. 배포 파이프라인이 깨지면 눈에 즉시 띄고, 수정 비용이 저렴합니다. Silent failure는 수주에서 수개월 뒤 사고로 드러나며, 그때는 이미 데이터가 노출됐을 수 있습니다.
이 철학이 바로 Fail-fast 입니다. "이게 없으면 안전하지 않다"는 설정은 "없으면 기본값을 쓴다"가 아니라 "없으면 뜨지 않는다"로 만들어야 합니다.
Fail-fast를 적용할 수 있는 설정의 체크리스트:
- [ ]
SECRET_KEY— 세션 서명 키가 없으면 세션을 만들 수 없으므로 검증 필수 (이 프로젝트는 이미 적용) - [ ]
ALLOWED_ORIGINS— CORS 정책의 핵심. wildcard 폴백 금지 (이번 편에서 적용) - [ ]
DATABASE_URL— DB 없이는 어차피 대부분 엔드포인트가 에러. 시작 시점에 검증 - [ ]
ADMIN_PASSWORD— 기본값이 있어야 하는 경우도 있지만, 기본값을 쓰면 로그 경고 + 후속 강제 변경 요구
4. .env.example 재배치
설정을 검증하는 것만으로는 부족합니다. 개발자가 필수 값을 쉽게 인식할 수 있어야 합니다. .env.example 파일을 재구성했습니다.
Before
# Required
SECRET_KEY=
ADMIN_PASSWORD=
# Optional
PORT=8080
HOST=0.0.0.0
FLASK_ENV=development
ALLOWED_ORIGINS= # CORS 허용 도메인
DATABASE_URL=
ALLOWED_ORIGINS가 "Optional" 섹션에 있었습니다. 배포자가 스캔만 하고 넘길 수 있는 위치입니다.
After
# Required
SECRET_KEY=
ALLOWED_ORIGINS=http://localhost:8080 # CORS 허용 도메인 (쉼표 구분)
# Optional
ADMIN_PASSWORD=
PORT=8080
HOST=0.0.0.0
FLASK_ENV=development
DATABASE_URL=
두 가지가 바뀌었습니다.
ALLOWED_ORIGINS를 Required 섹션으로 이동 하고,http://localhost:8080같은 샘플 값을 넣었습니다. 이 한 줄만 바꾸면 바로 로컬 기동이 됩니다.ADMIN_PASSWORD를 Optional로 이동했습니다. 기본값이 있으니 배포에 꼭 필요하진 않기 때문입니다.
문서화와 코드 검증이 같은 방향을 가리키도록 만드는 것이 중요합니다. 코드는 "이 설정이 없으면 죽는다"고 말하는데 .env.example이 "optional"로 써 있으면 혼란을 일으킵니다.
5. 문제 ②: URL 파라미터의 path traversal
두 번째 문제는 더 고전적인 취약점입니다.
Before
# app.py (Before)
@app.route("/api/train/<train_id>/model", methods=["GET"])
@require_login
def api_download_model(train_id):
"""학습된 모델 weight 파일(best.pt) 다운로드"""
uid = session["user_id"]
history = get_train_history(uid)
rec = next((h for h in history if h["id"] == train_id), None)
if not rec or rec["status"] != "success":
return jsonify({"error": "완료된 학습이 아닙니다"}), 400
model_path = MODEL_DIR / uid / train_id / "weights" / "best.pt"
# ...
return send_from_directory(...)
train_id는 URL 파라미터에서 직접 왔고, 그 값을 그대로 MODEL_DIR / uid / train_id / ... 경로 조립에 사용합니다. 이것이 문제입니다.
공격자가 이런 요청을 보낸다면:
GET /api/train/..%2F..%2F..%2Fetc/passwd/model
train_id는 "../../../etc/passwd"로 디코딩됩니다. MODEL_DIR / uid / "../../../etc/passwd" / ... 경로 조립은 Python에서 파일 시스템 상의 /etc/passwd를 가리키게 됩니다.
물론 이 특정 공격은 rec = next((h for h in history if h["id"] == train_id), None)에서 id 매칭에 실패해 400으로 거절됩니다. 하지만 공격자가 유효한 train_id를 알고 있는 경우 는 이야기가 달라집니다. 예를 들어 "abc123/../../../../sensitive_file" 같은 입력이 오면, 히스토리 매칭은 실패해도 경로 조립은 여전히 위험합니다.
더 중요한 것은 "지금은 운이 좋아 막혔지만 다음 리팩토링에서 검증 순서가 바뀌면 뚫리는" 종류의 코드라는 점입니다. 방어는 입력 경계에서 정규화하는 것이 원칙 입니다.
After: Path().name
# app.py (After)
@app.route("/api/train/<train_id>/model", methods=["GET"])
@require_login
def api_download_model(train_id):
"""학습된 모델 weight 파일(best.pt) 다운로드"""
uid = session["user_id"]
train_id = Path(train_id).name # ← path traversal 방지
history = get_train_history(uid)
rec = next((h for h in history if h["id"] == train_id), None)
# ...
단 한 줄, train_id = Path(train_id).name이 추가됐습니다. 이 한 줄이 path traversal을 막는 핵심 방어입니다.
6. Path().name이 하는 일
Python의 pathlib.Path는 파일 시스템 경로를 다루는 고수준 API입니다. .name 속성은 "경로의 마지막 구성 요소"를 반환합니다.
from pathlib import Path
>>> Path("abc123").name
'abc123'
>>> Path("../etc/passwd").name
'passwd'
>>> Path("/../../../etc/passwd").name
'passwd'
>>> Path("abc/def/ghi").name
'ghi'
>>> Path("").name
''
핵심은 .., /, 절대 경로 표기가 모두 제거된다는 점입니다. 남는 것은 마지막 디렉토리/파일 이름 하나뿐입니다. train_id는 원래 UUID 단축형(abc12345) 같은 단순 식별자이므로, 이 정규화는 합법 입력에는 아무 영향을 주지 않고 공격 입력만 무력화합니다.
Windows 경로도 처리
pathlib은 OS에 따라 Path가 PosixPath 또는 WindowsPath로 분기됩니다. Windows에서는 \ 분리자와 드라이브 문자도 처리합니다.
# Windows에서 (WindowsPath 사용)
>>> Path(r"C:\Windows\System32\config").name
'config'
>>> Path(r"..\..\secret.txt").name
'secret.txt'
크로스 플랫폼 코드에서도 한 줄이면 끝입니다.
7. model_name에도 같은 방어
같은 리뷰 라운드에서 model_name 쪽에도 동일한 방어를 추가했습니다.
# app.py (Before)
model_name = d.get("model", "yolov8n.pt")
# ... 그대로 사용
# app.py (After)
model_name = Path(d.get("model", "yolov8n.pt")).name
if not model_name.endswith(".pt"):
return jsonify({"error": "모델 형식은 .pt만 지원합니다"}), 400
Path().name으로 path traversal을 막고, 추가로 .pt 확장자를 체크합니다. 이 두 줄은 나중에 6편의 multi-task 분기로 발전 하면서, 사이즈와 task를 조합해 파일명을 서버가 직접 조립 하는 구조로 교체됩니다. 서버가 파일명을 조립하면 클라이언트 입력이 파일명에 전혀 개입하지 않아 가장 안전한 상태가 됩니다.
8. SSE에 2시간 타임아웃 추가
이번 커밋에는 보안 외에도 한 가지 작은 방어가 더 들어갔습니다. SSE(Server-Sent Events)로 학습 진행 상황을 스트리밍하는 엔드포인트에 최대 2시간 타임아웃 을 넣었습니다.
Before
# routes/training.py (Before)
def generate():
while True: # ← 무한 루프
try:
# ... results.csv 모니터링 + 상태 체크
except Exception as inner_e:
logger.error(f"[SSE INNER ERROR] {inner_e}")
time.sleep(2)
이론적으로 학습이 끝나면 break가 호출되지만, 학습이 영원히 끝나지 않는 엣지 케이스(백그라운드 프로세스가 좀비 상태, 에러 후 상태 업데이트 실패 등)가 있으면 루프가 영원히 돕니다.
After
# routes/training.py (After)
def generate():
_MAX_SSE_WAIT = 7200 # 최대 2시간 (7200초)
_elapsed = 0
while _elapsed < _MAX_SSE_WAIT:
try:
# ... results.csv 모니터링 + 상태 체크
except Exception as inner_e:
logger.error(f"[SSE INNER ERROR] {inner_e}")
time.sleep(2)
_elapsed += 2
if _elapsed >= _MAX_SSE_WAIT:
logger.warning(f"[SSE TIMEOUT] {train_id} — {_MAX_SSE_WAIT}초 초과")
yield f"data: {json.dumps({'done': True, 'status': 'timeout'})}\n\n"
세 가지 변경:
- 루프 조건에
_elapsed < _MAX_SSE_WAIT추가 _elapsed += 2로 경과 시간 누적- 타임아웃 도달 시
status: "timeout"메시지를 푸시하고 종료
2시간은 경험에 기반한 안전 한계입니다. YOLOv8 커스텀 학습이 이 시간을 넘는 경우는 거의 없고, 그 이상 걸리는 작업이라면 SSE가 아닌 폴링 기반 API가 적합합니다. 타임아웃 값은 _MAX_SSE_WAIT 상수로 빼서, 필요시 쉽게 조정할 수 있게 했습니다.
9. 보안 리뷰 패턴의 정리
이번 편의 세 가지 수정을 통해 얻은 보안 리뷰 패턴 세 가지를 정리합니다.
① 입력 경계에서 정규화
사용자 입력은 반드시 경계(라우트 함수의 첫 줄)에서 정규화합니다. train_id, model_name 같은 파라미터는 실제 로직에 들어가기 전에 Path().name, 길이 체크, 정규표현식 검증 등을 거칩니다. 이 방어를 단 한 곳에 몰아두고, 이후 코드는 "이미 정규화된 값"이라는 가정 위에 쓰여야 합니다.
② 필수 설정에 Fail-fast 검증
"없으면 기본값을 쓰면 되지"가 유혹적이지만, 보안 관련 설정에서는 위험합니다. 다음 원칙을 따르세요.
- 보안과 직결된 설정 (CORS, SECRET_KEY, 암호화 키): 없으면 서버 기동 거부
- 기능과 관련된 설정 (PORT, LOG_LEVEL): 합리적 기본값 허용
- 기본값이 있더라도 경고:
ADMIN_PASSWORD같은 설정은 기본값을 쓰되 로그로 경고
③ 무한 루프에는 상한선
스트리밍, 폴링, 재시도 루프 같은 곳에는 반드시 상한선이 있어야 합니다. 정상 경로에서는 이 상한선에 도달하지 않지만, 비정상 상황에서 리소스 유출을 방지합니다. 상한선은 데이터 흐름 감각에 맞는 값 이어야 합니다(이번 프로젝트는 2시간).
10. 핵심 개념 정리
| 개념 | 역할 |
|---|---|
| Fail-fast 검증 | 필수 설정 누락 시 서버 기동 거부 (RuntimeError) |
.env.example Required 섹션 |
코드 검증과 문서가 같은 방향을 가리킴 |
Path().name |
path traversal 방지, ..와 경로 분리자 제거 |
| 입력 정규화 경계 | 라우트 함수 첫 줄에서 정규화, 이후 코드는 신뢰 |
| SSE 타임아웃 상한선 | 무한 루프에 경과 시간 누적 + _MAX_SSE_WAIT 상수 |
| CORS wildcard의 위험 | origins=["*"] + supports_credentials=True 조합의 공격 경로 |
11. 베스트 프랙티스 체크리스트
- [ ]
ALLOWED_ORIGINS,SECRET_KEY같은 필수 설정이 없으면RuntimeError로 즉시 종료하나요? - [ ]
.env.example의 Required 섹션이 코드 검증과 일치하나요? - [ ] URL 파라미터로 들어온 파일/디렉토리 이름에
Path().name을 적용하나요? - [ ] 보안 관련 검증이 라우트 함수의 첫 줄(또는 가능한 빠른 지점)에 있나요?
- [ ] SSE, 폴링, 재시도 루프에 타임아웃 상한이 있나요?
- [ ] CORS
origins에 wildcard 폴백이 없나요? - [ ]
supports_credentials=True와origins="*"를 함께 쓰지 않나요? - [ ] 파일명 조립을 서버가 직접 수행하고, 클라이언트 값이 경로 조립에 직접 사용되지 않나요?
12. FAQ
Q1. raise RuntimeError가 너무 강하지 않나요? 로그 경고 + 기본값이 더 친절하지 않을까요?
A. 기능 설정이라면 친절한 편이 낫지만, 보안 설정은 "강한 게 친절한 것"입니다. 기본값으로 시작한 서비스가 나중에 사고로 이어지는 것보다, 배포 시점에 "이거 설정하세요"라고 외치는 편이 훨씬 친절합니다. 게다가 Fail-fast는 배포 자동화(CI/CD)와 잘 어울립니다. 파이프라인이 RuntimeError를 만나면 즉시 멈추고, 해결 후 재배포하는 흐름이 자연스러워집니다.
Q2. Path().name이 모든 path traversal을 막나요?
A. URL path 파라미터에 대해서는 거의 완벽합니다. 다만 "경로가 아닌 다른 방식으로 파일 시스템을 건드리는" 취약점(심볼릭 링크, 특수 파일, 프로토콜 처리기 등)은 별개로 대응해야 합니다. Path().name은 "입력에서 디렉토리 이동을 제거"하는 도구이지, "보안의 만능 해결책"은 아닙니다.
Q3. Path(train_id).resolve().is_relative_to(ALLOWED_BASE) 같은 방식이 더 안전하지 않나요?
A. 좋은 지적입니다. 이 방식은 "정규화 후 절대 경로를 계산해 허용 베이스 디렉토리 아래인지 검증"하므로 심볼릭 링크를 포함한 복잡한 공격도 방어할 수 있습니다. 이 프로젝트의 train_id는 UUID 단축형이라 "경로 구성 요소가 아닌 단순 ID"라는 성격이 강해서 Path().name으로 충분했지만, 파일명에 디렉토리가 포함될 수 있는 경우에는 is_relative_to 방식이 권장됩니다.
Q4. SSE 타임아웃 2시간은 어떻게 정했나요?
A. 프로젝트의 실제 학습 시간 분포에 기반했습니다. YOLOv8 커스텀 학습은 대부분 수 분에서 1시간 이내에 끝나고, 2시간을 넘는 경우는 거의 없습니다. 상한을 너무 짧게 두면 정상 학습이 잘리고, 너무 길게 두면 좀비 학습을 너무 오래 붙잡게 됩니다. 경험적 관측값의 2~3배가 적당한 기준입니다.
Q5. CORS를 완전히 비활성화하는 옵션은 없나요?
A. Flask-CORS 자체를 호출하지 않으면 CORS 헤더가 붙지 않아 브라우저가 기본 동작(동일 출처만 허용)을 합니다. 이 프로젝트는 프론트엔드가 같은 도메인에서 서빙되므로 이론적으론 Flask-CORS 호출 자체를 제거해도 됩니다. 다만 서브도메인 분리, 로컬 개발 환경에서의 프록시, 향후 mobile 클라이언트 지원 가능성 등을 고려해 명시적 화이트리스트 방식을 유지하고 있습니다.
13. 참고 자료
- Flask-CORS 공식 문서: 검색 키워드
flask-cors origins supports_credentials - Python
pathlib공식 문서: 검색 키워드python pathlib Path name parts - OWASP Path Traversal: 검색 키워드
owasp path traversal prevention cheat sheet - OWASP CORS Misconfiguration: 검색 키워드
owasp cors misconfiguration testing guide - Fail-fast 원칙: 검색 키워드
fail fast programming principle robustness
14. 다음 단계
여기까지는 모두 "추가하거나 강화하는" 보안 변화였습니다. 다음 편은 완전히 반대 방향입니다. 배포 환경이 폐쇄망으로 확정되면서, 구현해 두었던 Rate Limiting을 오히려 제거 하는 결정을 내렸습니다. "보안은 항상 더하는 것"이라는 통념을 뒤집는 사례이고, 이미 블로그에 있던 Next.js Rate Limiting 구현 편과 정반대 관점에서 같은 주제를 다룹니다.
🐍 Flask 백엔드 실전 시리즈 (9부작)
- 모놀리식 app.py를 Blueprint로 분해하기
- JSON 파일 DB에서 PostgreSQL로 마이그레이션
- ML 학습 백그라운드 실행: ProcessPoolExecutor
- Python/SQLAlchemy N+1 쿼리 잡기
- train과 val이 같은 폴더일 때의 조용한 ML 버그
- 하나의 백엔드로 Detection/Classification/Segmentation
- Fail-fast 설정 검증과 Path Traversal 방어 (현재 글)
- Rate Limiting을 걷어낸 날: 폐쇄망 보안
- 작지만 기억할 만한 네 가지 교훈