|
| 1 | +import os |
| 2 | +import pathlib |
| 3 | + |
1 | 4 | from fastapi import FastAPI, HTTPException, Query |
2 | 5 |
|
3 | | -from api_models import ( |
| 6 | +from src.api_models import ( |
4 | 7 | CatBoostPredictionRequest, |
5 | 8 | CatBoostPredictionResponse, |
6 | 9 | ResearchSignalFeatures, |
7 | 10 | ResearchSignalPayload, |
8 | 11 | ScorecardResponse, |
9 | 12 | ) |
10 | | -from go_no_go_scorecard import ScorecardDataError, evaluate_scorecard |
11 | | -from predict_catboost import CatBoostTradingPredictor |
| 13 | +from src.go_no_go_scorecard import ScorecardDataError, evaluate_scorecard |
| 14 | +from src.predict_catboost import CatBoostTradingPredictor |
12 | 15 | from research_signal import load_research_signal_payload, normalize_research_payload_model |
13 | 16 |
|
14 | 17 |
|
|
18 | 21 | description="Typed FastAPI endpoints for scorecards and research signals.", |
19 | 22 | ) |
20 | 23 |
|
| 24 | +# Allowlist: when JOURNAL_DIR is set, restrict scorecard reads to that directory only. |
| 25 | +# When not set, any absolute path is accepted but relative paths with traversal are blocked. |
| 26 | +_SCORECARD_JOURNAL_DIR = os.getenv("JOURNAL_DIR", "") |
| 27 | +_SCORECARD_ALLOWED_DIR: pathlib.Path | None = ( |
| 28 | + pathlib.Path(_SCORECARD_JOURNAL_DIR).resolve( |
| 29 | + ) if _SCORECARD_JOURNAL_DIR else None |
| 30 | +) |
| 31 | + |
| 32 | + |
| 33 | +def _validate_scorecard_path(file: str) -> pathlib.Path: |
| 34 | + """Resolve and validate the scorecard file path to prevent path traversal. |
| 35 | +
|
| 36 | + - If JOURNAL_DIR is set: the file must resolve to within that directory. |
| 37 | + - If JOURNAL_DIR is not set: absolute paths are accepted as-is; relative paths |
| 38 | + are resolved against the project root and must not escape it. |
| 39 | + """ |
| 40 | + project_root = pathlib.Path(__file__).parent.parent.resolve() |
| 41 | + try: |
| 42 | + if pathlib.Path(file).is_absolute(): |
| 43 | + resolved = pathlib.Path(file).resolve() |
| 44 | + else: |
| 45 | + resolved = (project_root / file).resolve() |
| 46 | + except Exception as exc: |
| 47 | + raise HTTPException( |
| 48 | + status_code=400, detail="Invalid file path.") from exc |
| 49 | + |
| 50 | + if _SCORECARD_ALLOWED_DIR is not None: |
| 51 | + if not str(resolved).startswith(str(_SCORECARD_ALLOWED_DIR) + os.sep) and resolved != _SCORECARD_ALLOWED_DIR: |
| 52 | + raise HTTPException( |
| 53 | + status_code=403, |
| 54 | + detail="Access denied: file is outside the allowed directory.", |
| 55 | + ) |
| 56 | + elif not pathlib.Path(file).is_absolute(): |
| 57 | + # Relative path: block traversal that escapes the project root. |
| 58 | + if not str(resolved).startswith(str(project_root) + os.sep) and resolved != project_root: |
| 59 | + raise HTTPException( |
| 60 | + status_code=403, |
| 61 | + detail="Access denied: relative path escapes the project root.", |
| 62 | + ) |
| 63 | + return resolved |
| 64 | + |
21 | 65 |
|
22 | 66 | @app.get("/health") |
23 | 67 | def health() -> dict[str, str]: |
@@ -78,9 +122,10 @@ def get_scorecard( |
78 | 122 | min_catboost_vs_rules_pnl_delta: float = Query(default=-0.05), |
79 | 123 | min_source_trades_for_delta: int = Query(default=50, ge=0), |
80 | 124 | ) -> ScorecardResponse: |
| 125 | + validated_path = _validate_scorecard_path(file) |
81 | 126 | try: |
82 | 127 | return evaluate_scorecard( |
83 | | - file_path=file, |
| 128 | + file_path=str(validated_path), |
84 | 129 | base_currency=base_currency, |
85 | 130 | lookback_days=lookback_days, |
86 | 131 | starting_capital=starting_capital, |
|
0 commit comments