diff --git a/.env.example b/.env.example index 06f5d167..5e8c7e78 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,9 @@ # FastAPI ######################################## +# Local development only; deployed runtime defaults to verification enabled. +TURNSTILE_ENABLED=false + # 서버 설정 HOST=0.0.0.0 PORT=8000 diff --git a/.env.production.example b/.env.production.example index 4437b129..88fa4ded 100644 --- a/.env.production.example +++ b/.env.production.example @@ -2,6 +2,10 @@ # Development runs locally with compose.dev.yaml; OCI only hosts production. # Never copy local-development sample passwords into a deployed environment. DEBUG=false +TURNSTILE_ENABLED=true +TURNSTILE_SECRET_KEY=replace-on-server +TURNSTILE_ALLOWED_HOSTNAMES=backtest.yeonjae.kr +TURNSTILE_EXPECTED_ACTION=backtest LOG_LEVEL=INFO DATABASE_HOST=replace-with-private-db-host DATABASE_PORT=3306 diff --git a/HISTORY.md b/HISTORY.md index 18267ee2..a83f456a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -9,6 +9,18 @@ --- +## 2026-10-25 백테스트 계산 사람 인증 + +- 계산 POST의 FastAPI 의존성에서 Cloudflare Siteverify를 검증한 뒤 기존 실행기로 + 전달한다. 토큰 누락·위조·만료·재사용·hostname/action 불일치는 403, 설정 누락·통신 + 장애는 503으로 거부한다. 계산·DB I/O·슬롯 점유 전에 차단하며 기존 JSON 계약은 유지한다. +- 기본 설정은 인증 활성화이며 로컬 개발·단위 테스트만 명시적으로 끈다. 비밀키는 + 런타임 환경 파일에만 보관한다. 네 조회 API와 헬스체크는 인증 대상이 아니다. +- 새 외부 SDK 의존성·토큰 DB·세션 캐시는 추가하지 않았다. Cloudflare의 일회용 토큰을 + 검증하고, 기존 컨테이너 전체/IP별 동시 실행 제한도 유지한다. +- Docker 검증: 단위 테스트 558개와 계산 golden master 1개 통과. API 계약 검사는 + 새 인증 헤더와 403 응답만 허용하고 기존 요청·응답 스키마의 동일성을 확인한다. + ## 2026-10-25 네 종류의 독립 조회 - **A-39**: 통합 GET `/api/v1/market-data`를 `/prices`, `/exchange-rates`, diff --git a/backtest_be_fast/.env.example b/backtest_be_fast/.env.example index 7a9dfb4c..c4c87ecf 100644 --- a/backtest_be_fast/.env.example +++ b/backtest_be_fast/.env.example @@ -12,6 +12,8 @@ HOST=0.0.0.0 PORT=8000 DEBUG=false +# Local development only. Never disable verification in the deployed .env. +TURNSTILE_ENABLED=false LOG_LEVEL=INFO # 보안 diff --git a/backtest_be_fast/app/api/v1/endpoints/backtest.py b/backtest_be_fast/app/api/v1/endpoints/backtest.py index d5f0e768..19d47f3c 100644 --- a/backtest_be_fast/app/api/v1/endpoints/backtest.py +++ b/backtest_be_fast/app/api/v1/endpoints/backtest.py @@ -3,7 +3,7 @@ 포트폴리오 백테스트 실행 및 관련 데이터를 반환하는 FastAPI 엔드포인트입니다. """ -from fastapi import APIRouter, Request, status +from fastapi import APIRouter, Depends, Request, status from fastapi.responses import JSONResponse import logging import asyncio @@ -20,6 +20,7 @@ from ....core.client_ip import client_limit_key, parse_trusted_networks, resolve_client_ip from ....services.backtest_runner import BacktestRejected, get_backtest_runner from ..decorators import handle_portfolio_errors +from ..turnstile import require_turnstile logger = logging.getLogger(__name__) router = APIRouter() @@ -176,9 +177,11 @@ def _rejection_response(exc: BacktestRejected) -> JSONResponse: status_code=status.HTTP_200_OK, summary="포트폴리오 백테스트 실행", description="포트폴리오 백테스트 실행 및 관련 데이터 반환", + dependencies=[Depends(require_turnstile)], responses={ + 403: {"description": "사람 인증 누락·실패·만료 또는 토큰 재사용 — 계산을 시작하지 않음"}, 429: {"description": "같은 클라이언트(IP)의 동시 실행 상한 초과 — 대기 없이 즉시 거부"}, - 503: {"description": "동시 실행 슬롯 대기 시간 초과 — 작업을 시작하지 않음"}, + 503: {"description": "사람 인증 검증 불가 또는 동시 실행 슬롯 대기 시간 초과 — 작업을 시작하지 않음"}, 504: {"description": "실행 시간 초과 — 작업에 취소 신호를 보냄"}, }, ) diff --git a/backtest_be_fast/app/api/v1/turnstile.py b/backtest_be_fast/app/api/v1/turnstile.py new file mode 100644 index 00000000..103fbdf6 --- /dev/null +++ b/backtest_be_fast/app/api/v1/turnstile.py @@ -0,0 +1,58 @@ +"""Validate human verification before accepting calculation work. + +Only Cloudflare's Siteverify response is authoritative. No result/token cache: +Siteverify enforces expiry and single use, including across backend workers. +""" +import asyncio +import json +import urllib.error +import urllib.parse +import urllib.request +from typing import Annotated + +from fastapi import Header, HTTPException, Request + +from ...core.config import settings + +SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" +DENIED = "사람 인증이 필요하거나 만료되었습니다. 다시 인증한 뒤 실행해 주세요." +UNAVAILABLE = "사람 인증을 확인할 수 없습니다. 잠시 후 다시 시도해 주세요." + + +def _siteverify(token: str, secret: str) -> dict: + data = urllib.parse.urlencode({"secret": secret, "response": token}).encode("utf-8") + request = urllib.request.Request(SITEVERIFY_URL, data=data, method="POST") + with urllib.request.urlopen(request, timeout=settings.turnstile_timeout_seconds) as response: + # Never log responses or submitted credentials, even on failure. + body = response.read(16_385) + if len(body) > 16_384: + raise ValueError("Oversized Siteverify response") + result = json.loads(body) + if not isinstance(result, dict): + raise ValueError("Invalid Siteverify response") + return result + + +async def require_turnstile( + request: Request, + token: Annotated[str | None, Header(alias="X-Turnstile-Token", + description="Turnstile single-use token for the backtest action")] = None, +) -> None: + if not settings.turnstile_enabled: + return + secret = settings.turnstile_secret_key.get_secret_value().strip() + hosts = {host.strip() for host in settings.turnstile_allowed_hostnames.split(",") if host.strip()} + if not secret or not hosts or not settings.turnstile_expected_action: + raise HTTPException(503, UNAVAILABLE) + if (not token or not token.strip() or len(token) > 2048 + or len(request.headers.getlist("X-Turnstile-Token")) != 1): + raise HTTPException(403, DENIED) + try: + result = await asyncio.to_thread(_siteverify, token, secret) + except (OSError, ValueError, urllib.error.URLError): + # Fail closed; external errors must not reach the simulation/error decorator. + raise HTTPException(503, UNAVAILABLE) from None + if (result.get("success") is not True or not isinstance(result.get("hostname"), str) + or result.get("hostname") not in hosts + or result.get("action") != settings.turnstile_expected_action): + raise HTTPException(403, DENIED) diff --git a/backtest_be_fast/app/core/config.py b/backtest_be_fast/app/core/config.py index 942dd941..715f7f5b 100644 --- a/backtest_be_fast/app/core/config.py +++ b/backtest_be_fast/app/core/config.py @@ -28,7 +28,7 @@ import json from typing import Optional from pydantic_settings import BaseSettings -from pydantic import Field +from pydantic import Field, SecretStr from pydantic import field_validator @@ -45,6 +45,14 @@ class Settings(BaseSettings): host: str = Field(default="0.0.0.0", env="HOST") port: int = Field(default=8000, env="PORT") debug: bool = Field(default=False, env="DEBUG") + + # Enabled by default: missing production configuration must never allow a job. + # Disable explicitly only for local development/tests. + turnstile_enabled: bool = True + turnstile_secret_key: SecretStr = SecretStr("") + turnstile_allowed_hostnames: str = "" + turnstile_expected_action: str = "backtest" + turnstile_timeout_seconds: float = Field(default=5.0, gt=0, le=10) # CORS 설정 # CORS 설정: read raw env as string and expose a parsed property to avoid diff --git a/backtest_be_fast/tests/conftest.py b/backtest_be_fast/tests/conftest.py index 01648799..7e9849e6 100644 --- a/backtest_be_fast/tests/conftest.py +++ b/backtest_be_fast/tests/conftest.py @@ -5,6 +5,12 @@ 단위 테스트는 DB 없이 실행 가능하도록 설계되었습니다. """ +import os + +# Unit/golden tests use no external credentials or network. Gate tests explicitly +# enable verification and exercise rejection before any job can start. +os.environ["TURNSTILE_ENABLED"] = "false" + import pytest import asyncio from typing import AsyncGenerator diff --git a/backtest_be_fast/tests/integration/verify_reference_reads_http.py b/backtest_be_fast/tests/integration/verify_reference_reads_http.py index 306c9825..0cecc23f 100644 --- a/backtest_be_fast/tests/integration/verify_reference_reads_http.py +++ b/backtest_be_fast/tests/integration/verify_reference_reads_http.py @@ -7,16 +7,20 @@ from concurrent.futures import ThreadPoolExecutor import json import math +import os from pathlib import Path import urllib.error import urllib.parse import urllib.request -def verify(base): +def verify(base, token=None): def call(path, payload=None): body = json.dumps(payload).encode() if payload is not None else None - req = urllib.request.Request(base + path, data=body, headers={'Content-Type': 'application/json'}) + headers = {'Content-Type': 'application/json'} + if payload is not None and token: + headers['X-Turnstile-Token'] = token + req = urllib.request.Request(base + path, data=body, headers=headers) try: response = urllib.request.urlopen(req, timeout=35) except urllib.error.HTTPError as exc: @@ -86,7 +90,8 @@ def call(path, payload=None): url = urllib.parse.urlsplit(args.base_url) if url.scheme != 'http' or url.hostname not in ('localhost', '127.0.0.1', '::1'): parser.error('Only a local HTTP test stack is allowed') - report = verify(args.base_url.rstrip('/')) + # Only documented dummy tokens, against the localhost restriction above. + report = verify(args.base_url.rstrip('/'), os.getenv('TURNSTILE_TEST_TOKEN')) if args.report: args.report.write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') print(json.dumps(report, indent=2)) diff --git a/backtest_be_fast/tests/unit/test_api_contract.py b/backtest_be_fast/tests/unit/test_api_contract.py index e1e9659b..e7d9a6d2 100644 --- a/backtest_be_fast/tests/unit/test_api_contract.py +++ b/backtest_be_fast/tests/unit/test_api_contract.py @@ -28,5 +28,12 @@ def test_preserves_production_routes_requests_and_responses(): assert set(actual['paths']) == set(expected['paths']) | reads for path in reads: assert 'get' in actual['paths'].pop(path) + # Only the calculation's verification header/403 response are additive. + post = actual['paths']['/api/v1/backtest']['post'] + header = post.pop('parameters') + assert len(header) == 1 + assert header[0]['name'] == 'X-Turnstile-Token' + assert header[0]['in'] == 'header' and header[0]['required'] is False + assert set(post['responses'].pop('403')) == {'description'} for section in ("paths", "components"): assert _contract(actual[section]) == expected[section] diff --git a/backtest_be_fast/tests/unit/test_turnstile.py b/backtest_be_fast/tests/unit/test_turnstile.py new file mode 100644 index 00000000..03ffcb44 --- /dev/null +++ b/backtest_be_fast/tests/unit/test_turnstile.py @@ -0,0 +1,140 @@ +"""Human verification must reject before reserving a slot or doing data I/O.""" +import json +from unittest.mock import Mock +import urllib.error + +import pytest +from fastapi.testclient import TestClient +from pydantic import SecretStr + +from app.main import app +from app.api.v1 import turnstile +from app.api.v1.endpoints import backtest +from app.core.config import Settings + +pytestmark = pytest.mark.unit +PAYLOAD = {"portfolio": [{"symbol": "AAPL", "amount": 1000}], + "start_date": "2023-01-01", "end_date": "2023-12-31", "strategy": "buy_hold_strategy"} +GOOD = {"success": True, "hostname": "backtest.yeonjae.kr", "action": "backtest"} + + +@pytest.fixture +def gate(monkeypatch): + for name, value in {"turnstile_enabled": True, "turnstile_secret_key": SecretStr("test-only-secret"), + "turnstile_allowed_hostnames": "backtest.yeonjae.kr", + "turnstile_expected_action": "backtest"}.items(): + monkeypatch.setattr(turnstile.settings, name, value) + verifier = Mock(return_value=GOOD) + runner_factory = Mock() + repository = Mock() + monkeypatch.setattr(turnstile, "_siteverify", verifier) + monkeypatch.setattr(backtest, "get_backtest_runner", runner_factory) + monkeypatch.setattr(backtest, "get_stock_repository", repository) + return verifier, runner_factory, repository + + +def test_enabled_by_default_and_secret_repr_redacted(monkeypatch): + monkeypatch.delenv("TURNSTILE_ENABLED") + settings = Settings(_env_file=None, turnstile_secret_key="not-a-real-secret") + assert settings.turnstile_enabled is True + assert "not-a-real-secret" not in repr(settings) + + +@pytest.mark.parametrize("headers", [{}, {"X-Turnstile-Token": ""}, {"X-Turnstile-Token": " "}, + {"X-Turnstile-Token": "a" * 2049}, [("X-Turnstile-Token", "one"), ("X-Turnstile-Token", "two")]]) +def test_missing_malformed_tokens_do_not_verify_or_start_work(gate, headers): + response = TestClient(app).post("/api/v1/backtest", json=PAYLOAD, headers=headers) + assert response.status_code == 403 + for mock in gate: + mock.assert_not_called() + + +@pytest.mark.parametrize("result", [ + {"success": False, "error-codes": ["invalid-input-response"]}, + {"success": False, "error-codes": ["timeout-or-duplicate"]}, + {**GOOD, "success": "true"}, {**GOOD, "hostname": "evil.example"}, + {**GOOD, "hostname": "backtest.yeonjae.kr.evil.example"}, + {**GOOD, "action": "login"}, {"success": True}, {**GOOD, "hostname": []}, +]) +def test_invalid_expired_replayed_or_wrong_scope_tokens_never_start_work(gate, result): + verifier, runner, repository = gate + verifier.return_value = result + response = TestClient(app).post("/api/v1/backtest", json=PAYLOAD, + headers={"X-Turnstile-Token": "opaque-token", "Host": "evil.example"}) + assert response.status_code == 403 + verifier.assert_called_once_with("opaque-token", "test-only-secret") + runner.assert_not_called() + repository.assert_not_called() + + +@pytest.mark.parametrize("error", [TimeoutError("secret should not leak"), + urllib.error.URLError("secret should not leak"), ValueError("secret should not leak")]) +def test_outage_is_fail_closed_and_does_not_leak_details(gate, error): + verifier, runner, repository = gate + verifier.side_effect = error + response = TestClient(app).post("/api/v1/backtest", json=PAYLOAD, headers={"X-Turnstile-Token": "token"}) + assert response.status_code == 503 + assert "secret" not in response.text + runner.assert_not_called() + repository.assert_not_called() + + +@pytest.mark.parametrize("name,value", [("turnstile_secret_key", SecretStr("")), + ("turnstile_allowed_hostnames", ""), ("turnstile_expected_action", "")]) +def test_missing_configuration_does_not_bypass(gate, monkeypatch, name, value): + monkeypatch.setattr(turnstile.settings, name, value) + assert TestClient(app).post("/api/v1/backtest", json=PAYLOAD, + headers={"X-Turnstile-Token": "token"}).status_code == 503 + for mock in gate: + mock.assert_not_called() + + +def test_valid_token_is_checked_before_existing_runner_and_replay_rejected(gate): + verifier, factory, repository = gate + class Runner: + async def run(self, work, **kwargs): + verifier.assert_called_once() + return {"status": "success", "data": {"calculation": "existing-result"}} + factory.return_value = Runner() + http = TestClient(app) + headers = {"X-Turnstile-Token": "single-use-token"} + first = http.post("/api/v1/backtest", json=PAYLOAD, headers=headers) + assert first.status_code == 200 + verifier.return_value = {"success": False, "error-codes": ["timeout-or-duplicate"]} + assert http.post("/api/v1/backtest", json=PAYLOAD, headers=headers).status_code == 403 + factory.assert_called_once() + repository.assert_not_called() + + +@pytest.mark.parametrize("path", ["prices", "exchange-rates", "benchmarks", "news"]) +def test_read_routes_do_not_require_verification(gate, path): + # Missing required query -> existing schema validation, not a human challenge. + assert TestClient(app).get("/api/v1/" + path).status_code == 422 + gate[0].assert_not_called() + + +def test_siteverify_uses_fixed_https_post_bounded_timeout_and_no_remoteip(monkeypatch): + response = Mock() + response.__enter__ = Mock(return_value=response) + response.__exit__ = Mock(return_value=False) + response.read.return_value = json.dumps(GOOD).encode() + urlopen = Mock(return_value=response) + monkeypatch.setattr(turnstile.urllib.request, "urlopen", urlopen) + assert turnstile._siteverify("a+token", "test-only-secret") == GOOD + request = urlopen.call_args.args[0] + assert request.full_url == turnstile.SITEVERIFY_URL + assert request.get_method() == "POST" + assert request.data == b"secret=test-only-secret&response=a%2Btoken" + assert 0 < urlopen.call_args.kwargs["timeout"] <= 10 + response.read.assert_called_once_with(16_385) + + +@pytest.mark.parametrize("body", [b"not-json", b"[]", b"x" * 16_385]) +def test_malformed_siteverify_payload_rejected(monkeypatch, body): + response = Mock() + response.__enter__ = Mock(return_value=response) + response.__exit__ = Mock(return_value=False) + response.read.return_value = body + monkeypatch.setattr(turnstile.urllib.request, "urlopen", Mock(return_value=response)) + with pytest.raises(ValueError): + turnstile._siteverify("token", "test-only-secret") diff --git a/compose.dev.yaml b/compose.dev.yaml index efedb1a2..142c43a4 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -17,6 +17,7 @@ services: - .env environment: - DEBUG=true + - TURNSTILE_ENABLED=${TURNSTILE_ENABLED:-false} - WATCHFILES_FORCE_POLLING=false volumes: - ./backtest_be_fast:/app diff --git a/docs/architecture/api-boundaries.md b/docs/architecture/api-boundaries.md index 190836a5..e74c043c 100644 --- a/docs/architecture/api-boundaries.md +++ b/docs/architecture/api-boundaries.md @@ -40,6 +40,32 @@ prices/news의 symbols는 필수이며 반복 파라미터를 쓴다. 입력 최 종목 메타데이터를 조회하지 않는다. 기존 캐시·수집·직렬화 코드를 재사용하며 계산식은 바꾸지 않는다. 벤치마크 자료가 부족하면 sp500_total_return_pct는 null이다. +## 계산 전 사람 인증 + +운영 계산 POST에는 `X-Turnstile-Token` 헤더가 필요하다. JSON 요청·응답 필드는 +유지한다. FastAPI 의존성이 계산 슬롯 확보·데이터 조회 전에 Cloudflare Siteverify로 +검증하며, `success=true`, 설정한 정확한 hostname, `action=backtest`를 모두 확인한다. +클라이언트의 Host 헤더나 인증 완료 표시만으로 허용하지 않는다. + +- 누락·잘못된 토큰·만료·재사용·hostname/action 불일치: 403, 계산 미실행. +- 키/호스트 설정 누락·검증 통신 장애: 503, 계산 미실행. 토큰/키/검증 응답은 로그에 남기지 않는다. +- 토큰은 Cloudflare가 5분 만료·일회성을 관리한다. 자체 세션·토큰 캐시를 추가하지 않는다. +- 네 GET과 해당 재시도, 헬스체크는 인증을 요구하지 않는다. 기존 실행/IP 상한은 유지한다. + +런타임 설정은 `TURNSTILE_ENABLED=true`(기본값), `TURNSTILE_SECRET_KEY`, +`TURNSTILE_ALLOWED_HOSTNAMES=backtest.yeonjae.kr`, `TURNSTILE_EXPECTED_ACTION=backtest`다. +검증 요청 타임아웃은 기본 5초다. 비밀키는 기존 서버 `.env`와 개인 키 디렉터리에서만 +관리하며 GitHub·문서에 값을 기록하지 않는다. 공개 sitekey는 콘솔 저장소의 +GitHub Actions Variable `VITE_TURNSTILE_SITE_KEY`로 빌드에 전달한다. +위젯은 Managed 모드이며 Pre-clearance는 사용하지 않는다. + +로컬 개발만 `TURNSTILE_ENABLED=false`로 명시해 위젯 없이 실행할 수 있다. +로컬 통합 검증에는 [Cloudflare 공식 테스트 키](https://developers.cloudflare.com/turnstile/troubleshooting/testing/)를 +사용한다. 2026-10-25 실측 더미 Siteverify 응답은 `example.com`이며 action이 없어 +엄격한 검증을 통과하지 못했다(공식 문서의 응답 예시와 다름). 따라서 로컬 계산 통합 +검증은 테스트 컨테이너에서만 검증 응답의 hostname/action을 고정하는 어댑터를 사용하며, +운영 키로 별도 확인한다. 애플리케이션에는 더미 키 허용 분기나 운영 우회 경로가 없다. + ## 실행 상한과 취소 - 계산: 기존 컨테이너 전체 8건, IP별 2건, 대기 30초 + 실행 60초.