api: source guard on the transport peer, X-Forwarded-For trusted only from the tunnel gateway
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 changed files +20 −8
modified
infra/mld/findata.json
+1 −1
@@ -12,7 +12,7 @@ | ||
| 12 | 12 | "size_mb": 20, |
| 13 | 13 | "placement": { "pin": "M3U96a", "prefer": "M3U96a", "avoid": ["M1M32"], "reason": "Storage node chosen in docs/DISCOVERY.md: 1 TB Apple NVMe (508 GB free, SMART verified, 5 GB/s), 32 cores / 96 GB for DuckDB, Postgres 17 + Redis already local, wg1 10.67.0.10. Lake at ~/findata-lake (outside the app dir)." }, |
| 14 | 14 | "processes": [ |
| 15 | − { "name": "findata-api", "manager": "pm2", "script": "{{HOME}}/apps/findata/.venv/bin/python", "args": ["-m", "uvicorn", "findata_api.main:app", "--host", "0.0.0.0", "--port", "8600", "--workers", "1", "--proxy-headers", "--forwarded-allow-ips", "10.67.0.1,127.0.0.1"], "interpreter": null, "cwd": "{{HOME}}/apps/findata", "env": {"FINDATA_ROOT": "{{HOME}}/apps/findata", "PATH": "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"}, "cron_restart": null, "autorestart": true, "max_memory_restart": "6G" }, | |
| 15 | + { "name": "findata-api", "manager": "pm2", "script": "{{HOME}}/apps/findata/.venv/bin/python", "args": ["-m", "uvicorn", "findata_api.main:app", "--host", "0.0.0.0", "--port", "8600", "--workers", "1"], "interpreter": null, "cwd": "{{HOME}}/apps/findata", "env": {"FINDATA_ROOT": "{{HOME}}/apps/findata", "PATH": "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"}, "cron_restart": null, "autorestart": true, "max_memory_restart": "6G" }, | |
| 16 | 16 | { "name": "findata-worker", "manager": "pm2", "script": "{{HOME}}/apps/findata/.venv/bin/python", "args": ["-m", "findata_ingest.cli", "worker"], "interpreter": null, "cwd": "{{HOME}}/apps/findata", "env": {"FINDATA_ROOT": "{{HOME}}/apps/findata", "PATH": "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"}, "cron_restart": null, "autorestart": true, "max_memory_restart": "24G" } |
| 17 | 17 | ], |
| 18 | 18 | "ngrok": null, |
modified
services/api/src/findata_api/auth.py
+8 −1
@@ -241,4 +241,11 @@ def audit(request: Request | None, action: str, target: str | None = None, outco | ||
| 241 | 241 | conn.execute("INSERT INTO audit_log(actor_type, actor_id, action, target, request_id, ip, outcome, details) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)", |
| 242 | 242 | (p.kind if p else "system", p.id if p else None, action, target, |
| 243 | 243 | getattr(request.state, "request_id", None) if request else None, |
| 244 | − request.client.host if request and request.client else None, outcome, json.dumps(details, default=str))) | |
| 244 | + client_ip(request) if request else None, outcome, json.dumps(details, default=str))) | |
| 245 | + | |
| 246 | + | |
| 247 | +def client_ip(request: Request) -> str | None: | |
| 248 | + ip = getattr(request.state, "client_ip", None) | |
| 249 | + if ip: | |
| 250 | + return ip | |
| 251 | + return request.client.host if request.client else None | |
modified
services/api/src/findata_api/main.py
+9 −4
@@ -80,9 +80,14 @@ class SourceGuard(BaseHTTPMiddleware): | ||
| 80 | 80 | the tunnel subnet and the LAN may connect. Caddy terminates TLS and forwards the real client in X-Forwarded-For.""" |
| 81 | 81 | |
| 82 | 82 | async def dispatch(self, request: Request, call_next): |
| 83 | − host = request.client.host if request.client else "" | |
| 84 | − if not host.startswith(TRUSTED_PREFIXES) and host != "testclient": | |
| 83 | + peer = request.client.host if request.client else "" | |
| 84 | + if not peer.startswith(TRUSTED_PREFIXES) and peer != "testclient": | |
| 85 | 85 | return JSONResponse({"error": {"code": "forbidden", "message": "untrusted network"}}, status_code=403) |
| 86 | + # real client address for audit/rate limiting: X-Forwarded-For is trusted only from the tunnel gateway | |
| 87 | + client_ip = peer | |
| 88 | + if peer.startswith("10.67.") and request.headers.get("x-forwarded-for"): | |
| 89 | + client_ip = request.headers["x-forwarded-for"].split(",")[0].strip() | |
| 90 | + request.state.client_ip = client_ip | |
| 86 | 91 | return await call_next(request) |
| 87 | 92 | |
| 88 | 93 | |
@@ -90,7 +95,7 @@ class RateLimit(BaseHTTPMiddleware): | ||
| 90 | 95 | async def dispatch(self, request: Request, call_next): |
| 91 | 96 | if request.url.path.startswith(API_PREFIX) and not request.url.path.endswith("/health"): |
| 92 | 97 | auth_h = request.headers.get("authorization", "") |
| 93 | − key = auth_h[-24:] if auth_h else (request.cookies.get(A.SESSION_COOKIE, "")[-24:] or (request.client.host if request.client else "anon")) | |
| 98 | + key = auth_h[-24:] if auth_h else (request.cookies.get(A.SESSION_COOKIE, "")[-24:] or (A.client_ip(request) or "anon")) | |
| 94 | 99 | ok, remaining = bucket.allow(f"rl:{key}", get_settings().rate_limit_per_minute) |
| 95 | 100 | if not ok: |
| 96 | 101 | return JSONResponse({"error": {"code": "rate_limited", "message": "Too many requests", "request_id": getattr(request.state, "request_id", None)}}, |
@@ -132,7 +137,7 @@ def create_app() -> FastAPI: | ||
| 132 | 137 | |
| 133 | 138 | @app.get("/metrics", include_in_schema=False) |
| 134 | 139 | def metrics(request: Request, p: A.Principal = Depends(A.get_principal)): |
| 135 | − host = request.client.host if request.client else "" | |
| 140 | + host = A.client_ip(request) or "" | |
| 136 | 141 | private = host in ("127.0.0.1", "::1") or host.startswith(("192.168.", "10.67.", "10.", "172.16.")) |
| 137 | 142 | if not private and not p.has("admin"): |
| 138 | 143 | return JSONResponse({"error": {"code": "forbidden", "message": "metrics require admin or a private network"}}, status_code=403) |
modified
services/api/src/findata_api/routers/auth.py
+2 −2
@@ -30,13 +30,13 @@ def _set_cookie(resp: Response, sid: str) -> None: | ||
| 30 | 30 | def login(body: LoginIn, request: Request, response: Response): |
| 31 | 31 | with connection() as conn: |
| 32 | 32 | u = conn.execute("SELECT * FROM users WHERE email=%s", (body.email.lower(),)).fetchone() |
| 33 | − ok, _ = __import__("findata_api.ratelimit", fromlist=["bucket"]).bucket.allow(f"login:{request.client.host if request.client else '?'}", 10) | |
| 33 | + ok, _ = __import__("findata_api.ratelimit", fromlist=["bucket"]).bucket.allow(f"login:{A.client_ip(request) or '?'}", 10) | |
| 34 | 34 | if not ok: |
| 35 | 35 | raise ApiError(429, "rate_limited", "Too many login attempts") |
| 36 | 36 | if not u or u["disabled"] or not A.verify_password(u["password_hash"], body.password): |
| 37 | 37 | A.audit(request, "auth.login", body.email, outcome="denied") |
| 38 | 38 | raise ApiError(401, "invalid_credentials", "Invalid e-mail or password") |
| 39 | − sid, csrf, mfa_passed = A.create_session(u, request.client.host if request.client else None, request.headers.get("user-agent")) | |
| 39 | + sid, csrf, mfa_passed = A.create_session(u, A.client_ip(request), request.headers.get("user-agent")) | |
| 40 | 40 | _set_cookie(response, sid) |
| 41 | 41 | request.state.principal = A.Principal("user", str(u["id"]), u["email"], u["role"]) |
| 42 | 42 | A.audit(request, "auth.login", u["email"], mfa_required=not mfa_passed) |
| 43 | 43 | |