feat/security-hardening #5
@@ -51,9 +51,14 @@ PGID=1000
|
||||
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||
|
||||
# Admin API Key (trade/order 등 민감 엔드포인트 보호, 미설정 시 인증 비활성화)
|
||||
# Admin API Key — /api/trade/* 등 민감 엔드포인트 보호.
|
||||
# 운영 .env에는 반드시 값을 채워야 함. 빈 값이면 503 응답으로 거부됨 (CODE_REVIEW F2).
|
||||
ADMIN_API_KEY=
|
||||
|
||||
# 개발 모드: 위 ADMIN_API_KEY 비워둔 채로 trade/admin 엔드포인트 호출 허용.
|
||||
# 운영 환경에서는 절대 true로 두지 말 것. 기본 false (보호 활성).
|
||||
ALLOW_UNAUTHENTICATED_ADMIN=false
|
||||
|
||||
# Anthropic API Key (AI Coach 프록시 + 뉴스 요약 Claude provider)
|
||||
ANTHROPIC_API_KEY=
|
||||
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||
|
||||
@@ -133,8 +133,12 @@ async def sign_link(
|
||||
|
||||
# 경로 안전: PACK_HOST_DIR(NAS 호스트 절대경로) 하위인지 확인.
|
||||
# file_path는 upload 라우트가 Supabase에 저장한 호스트경로 그대로 전달되어 DSM API에 사용됨.
|
||||
# str.startswith는 '/foo/packs' 와 '/foo/packs_evil' 같은 sibling 경로를 통과시키므로
|
||||
# Path.relative_to로 엄격하게 컴포넌트 단위 검증한다 (CODE_REVIEW F1).
|
||||
abs_path = Path(payload.file_path).resolve()
|
||||
if not str(abs_path).startswith(str(PACK_HOST_DIR)):
|
||||
try:
|
||||
abs_path.relative_to(PACK_HOST_DIR.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="허용된 경로 외부")
|
||||
|
||||
try:
|
||||
|
||||
@@ -60,6 +60,29 @@ def test_sign_link_path_outside_base():
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_sign_link_rejects_sibling_path():
|
||||
"""PACK_HOST_DIR='/foo/packs' 일 때 '/foo/packs_evil/x.mp4' 같이 prefix만
|
||||
통과하는 sibling 경로는 거부해야 한다 (CODE_REVIEW F1, path traversal 변형).
|
||||
|
||||
기존 str.startswith 방식은 trailing slash가 없어 sibling 경로를 통과시킴.
|
||||
relative_to 기반 검증으로 교체되어야 통과한다.
|
||||
"""
|
||||
import json as _json
|
||||
from pathlib import Path
|
||||
base_resolved = Path("/foo/packs").resolve()
|
||||
# base의 자식이 아닌 sibling 경로 (예: /foo/packs_evil/...)
|
||||
sibling_posix = (base_resolved.parent / f"{base_resolved.name}_evil" / "x.mp4").as_posix()
|
||||
with patch("app.routes.PACK_HOST_DIR", base_resolved):
|
||||
body = _json.dumps(
|
||||
{"file_path": sibling_posix, "expires_in_seconds": 14400}
|
||||
).encode()
|
||||
r = client.post("/api/packs/sign-link", content=body, headers=_signed(body))
|
||||
assert r.status_code == 400, (
|
||||
f"sibling 경로 '{sibling_posix}'가 허용됨 (status={r.status_code}) "
|
||||
f"— path traversal 가능성"
|
||||
)
|
||||
|
||||
|
||||
def test_upload_invalid_token():
|
||||
r = client.post(
|
||||
"/api/packs/upload",
|
||||
|
||||
@@ -47,13 +47,30 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||
# Windows AI Server URL (NAS .env에서 설정)
|
||||
WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000")
|
||||
|
||||
# Admin API Key 인증
|
||||
# Admin API Key 인증 — /api/trade/* 보호 (CODE_REVIEW F2)
|
||||
# 빈 키 + 명시적 dev flag 없으면 503으로 거부. 운영 .env에 ADMIN_API_KEY 누락 시
|
||||
# 무인증 통과되던 버그 차단.
|
||||
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "")
|
||||
|
||||
def verify_admin(x_admin_key: str = Header(None)):
|
||||
"""admin/trade 엔드포인트 보호용 API 키 검증"""
|
||||
"""admin/trade 엔드포인트 보호용 API 키 검증.
|
||||
|
||||
- ADMIN_API_KEY 설정됨 + 키 일치 → 통과
|
||||
- ADMIN_API_KEY 설정됨 + 키 불일치 → 401 Unauthorized
|
||||
- ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN=true → 통과 (개발 모드)
|
||||
- ADMIN_API_KEY 미설정 + dev flag 없음 → 503 (보호 강화, 운영 .env 누락 차단)
|
||||
"""
|
||||
if not ADMIN_API_KEY:
|
||||
return # 키 미설정 시 인증 비활성화 (개발 환경)
|
||||
if os.getenv("ALLOW_UNAUTHENTICATED_ADMIN", "false").lower() == "true":
|
||||
return # 개발 환경 명시적 허용
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"admin endpoint protected — ADMIN_API_KEY not configured. "
|
||||
"Set ADMIN_API_KEY in .env, or set ALLOW_UNAUTHENTICATED_ADMIN=true "
|
||||
"for development only."
|
||||
),
|
||||
)
|
||||
if x_admin_key != ADMIN_API_KEY:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
3
stock/pytest.ini
Normal file
3
stock/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
asyncio_mode = auto
|
||||
43
stock/tests/test_admin_auth.py
Normal file
43
stock/tests/test_admin_auth.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""verify_admin 보안 강화 회귀 테스트 (CODE_REVIEW F2).
|
||||
|
||||
운영 .env에서 ADMIN_API_KEY가 누락되면 /api/trade/balance, /api/trade/order
|
||||
인증이 무력화되는 버그를 막기 위한 가드.
|
||||
"""
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app import main as stock_main
|
||||
|
||||
|
||||
def test_verify_admin_rejects_when_key_missing_and_no_dev_flag(monkeypatch):
|
||||
"""ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN 미설정 → 503."""
|
||||
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "")
|
||||
monkeypatch.delenv("ALLOW_UNAUTHENTICATED_ADMIN", raising=False)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
stock_main.verify_admin(x_admin_key=None)
|
||||
assert exc_info.value.status_code == 503
|
||||
assert "ADMIN_API_KEY" in exc_info.value.detail
|
||||
|
||||
|
||||
def test_verify_admin_allows_when_key_missing_with_dev_flag(monkeypatch):
|
||||
"""ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN=true → 통과 (개발 모드)."""
|
||||
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "")
|
||||
monkeypatch.setenv("ALLOW_UNAUTHENTICATED_ADMIN", "true")
|
||||
stock_main.verify_admin(x_admin_key=None) # 예외 없으면 통과
|
||||
|
||||
|
||||
def test_verify_admin_rejects_wrong_key(monkeypatch):
|
||||
"""ADMIN_API_KEY 설정 + 잘못된 키 → 401 (regression)."""
|
||||
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "secret123")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
stock_main.verify_admin(x_admin_key="wrong")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
def test_verify_admin_allows_correct_key(monkeypatch):
|
||||
"""ADMIN_API_KEY 설정 + 올바른 키 → 통과 (regression)."""
|
||||
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "secret123")
|
||||
stock_main.verify_admin(x_admin_key="secret123") # 예외 없으면 통과
|
||||
Reference in New Issue
Block a user