SPB Git

spb/findata

Public

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>
Simon-Pierre Boucher committed (Nov 1, 2026) parent f720749 Browse files

4 changed files +20 −8

modified infra/mld/findata.json +1 −1
@@ -12,7 +12,7 @@
12 "size_mb": 20, 12 "size_mb": 20,
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)." }, 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 "processes": [ 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 { "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" } 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 "ngrok": null, 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 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)", 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 (p.kind if p else "system", p.id if p else None, action, target, 242 (p.kind if p else "system", p.id if p else None, action, target,
243 getattr(request.state, "request_id", None) if request else None, 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 the tunnel subnet and the LAN may connect. Caddy terminates TLS and forwards the real client in X-Forwarded-For.""" 80 the tunnel subnet and the LAN may connect. Caddy terminates TLS and forwards the real client in X-Forwarded-For."""
81 81
82 async def dispatch(self, request: Request, call_next): 82 async def dispatch(self, request: Request, call_next):
83− host = request.client.host if request.client else "" 83+ peer = request.client.host if request.client else ""
84− if not host.startswith(TRUSTED_PREFIXES) and host != "testclient": 84+ if not peer.startswith(TRUSTED_PREFIXES) and peer != "testclient":
85 return JSONResponse({"error": {"code": "forbidden", "message": "untrusted network"}}, status_code=403) 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 return await call_next(request) 91 return await call_next(request)
87 92
88 93
@@ -90,7 +95,7 @@ class RateLimit(BaseHTTPMiddleware):
90 async def dispatch(self, request: Request, call_next): 95 async def dispatch(self, request: Request, call_next):
91 if request.url.path.startswith(API_PREFIX) and not request.url.path.endswith("/health"): 96 if request.url.path.startswith(API_PREFIX) and not request.url.path.endswith("/health"):
92 auth_h = request.headers.get("authorization", "") 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 ok, remaining = bucket.allow(f"rl:{key}", get_settings().rate_limit_per_minute) 99 ok, remaining = bucket.allow(f"rl:{key}", get_settings().rate_limit_per_minute)
95 if not ok: 100 if not ok:
96 return JSONResponse({"error": {"code": "rate_limited", "message": "Too many requests", "request_id": getattr(request.state, "request_id", None)}}, 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 @app.get("/metrics", include_in_schema=False) 138 @app.get("/metrics", include_in_schema=False)
134 def metrics(request: Request, p: A.Principal = Depends(A.get_principal)): 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 private = host in ("127.0.0.1", "::1") or host.startswith(("192.168.", "10.67.", "10.", "172.16.")) 141 private = host in ("127.0.0.1", "::1") or host.startswith(("192.168.", "10.67.", "10.", "172.16."))
137 if not private and not p.has("admin"): 142 if not private and not p.has("admin"):
138 return JSONResponse({"error": {"code": "forbidden", "message": "metrics require admin or a private network"}}, status_code=403) 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 def login(body: LoginIn, request: Request, response: Response): 30 def login(body: LoginIn, request: Request, response: Response):
31 with connection() as conn: 31 with connection() as conn:
32 u = conn.execute("SELECT * FROM users WHERE email=%s", (body.email.lower(),)).fetchone() 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 if not ok: 34 if not ok:
35 raise ApiError(429, "rate_limited", "Too many login attempts") 35 raise ApiError(429, "rate_limited", "Too many login attempts")
36 if not u or u["disabled"] or not A.verify_password(u["password_hash"], body.password): 36 if not u or u["disabled"] or not A.verify_password(u["password_hash"], body.password):
37 A.audit(request, "auth.login", body.email, outcome="denied") 37 A.audit(request, "auth.login", body.email, outcome="denied")
38 raise ApiError(401, "invalid_credentials", "Invalid e-mail or password") 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 _set_cookie(response, sid) 40 _set_cookie(response, sid)
41 request.state.principal = A.Principal("user", str(u["id"]), u["email"], u["role"]) 41 request.state.principal = A.Principal("user", str(u["id"]), u["email"], u["role"])
42 A.audit(request, "auth.login", u["email"], mfa_required=not mfa_passed) 42 A.audit(request, "auth.login", u["email"], mfa_required=not mfa_passed)
43 43
Menu

Theme