Files
Ten31-Portal/backend/ten31portal/main.py
T
Jonathan Kirkwood 3a4b7dfd8b 0.2.46: security hardening (external-fund scope, role guards, forced password rotation, headers)
Pre-release audit fixes:
- External Administrators can only act on investors already in their own funds
  (grant_access, create_statement, set_partner_exited, import match), closing a
  cross-fund account-takeover chain.
- Role and is_active changes are gated: no self-escalation, non-approvers cannot
  mint or alter approver accounts, and the Service Admin is protected.
- must_change_password is enforced server-side, not just in the client.
- Session cookie is Secure with a 12h max-age and SameSite; CSP and standard
  security headers on every response; the app refuses to boot on a known or short
  session secret; uvicorn runs with proxy-headers so login throttling keys on the
  real client IP behind the platform proxy.
- Import preview no longer confirms cross-fund investors; no-store on document
  downloads.
- Adds backend/tests/test_security_hardening.py (15 tests); full suite 76 passing.
- SECURITY-AUDIT.md records the swarm audit, findings, and remediation status.
2026-08-28 09:53:16 -05:00

152 lines
6.1 KiB
Python

"""Ten31Portal FastAPI application."""
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from starlette.middleware.gzip import GZipMiddleware
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import FileResponse
from ten31portal import config
from ten31portal.config import (
INSECURE_SESSION_SECRETS, SESSION_COOKIE_SECURE, SESSION_MAX_AGE, SESSION_SECRET,
)
from ten31portal.db_init import run_migrations
from ten31portal.routers.auth_router import router as auth_router
from ten31portal.routers.audit_router import router as audit_router
from ten31portal.routers.entity_router import router as entity_router
from ten31portal.routers.holding_router import router as holding_router
from ten31portal.routers.position_router import router as position_router
from ten31portal.routers.round_router import router as round_router
from ten31portal.routers.import_router import router as import_router
from ten31portal.routers.user_router import router as user_router
from ten31portal.routers.document_router import router as document_router
from ten31portal.routers.capital_account_router import router as capital_account_router
from ten31portal.routers.capital_import_router import router as capital_import_router
@asynccontextmanager
async def lifespan(app: FastAPI):
# Refuse to boot on a publicly-known session secret: with one, anyone can forge a session
# cookie for any user id and bypass authentication. start.sh generates a real secret; this
# is the backstop for a misconfigured or bypassed entrypoint.
if SESSION_SECRET in INSECURE_SESSION_SECRETS or len(SESSION_SECRET) < 32:
raise RuntimeError(
"TEN31_SESSION_SECRET is unset, a known default, or too short. Set it to a random "
"value of at least 32 characters before starting the portal."
)
run_migrations()
from ten31portal.storage import ensure_docs_dir
ensure_docs_dir()
yield
# Security headers applied to every response. The SPA loads only same-origin hashed assets and
# renders the TOTP QR as a data: image, so this CSP does not need to loosen script-src.
_SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Content-Security-Policy": (
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; "
"img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; "
"connect-src 'self'; font-src 'self' data:; form-action 'self'"
),
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
}
app = FastAPI(title="Ten31Portal", version="0.1.0", lifespan=lifespan)
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
for name, value in _SECURITY_HEADERS.items():
response.headers.setdefault(name, value)
return response
app.add_middleware(
SessionMiddleware,
secret_key=SESSION_SECRET,
https_only=SESSION_COOKIE_SECURE,
same_site="lax",
max_age=SESSION_MAX_AGE,
)
# Compress HTML/JS/CSS/JSON over the wire (the 320KB JS bundle gzips to ~80KB). Added after
# SessionMiddleware so it sits outermost and compresses the final response. minimum_size skips
# tiny payloads (health checks, small JSON) where compression isn't worth it.
app.add_middleware(GZipMiddleware, minimum_size=500)
app.include_router(auth_router)
app.include_router(audit_router)
app.include_router(entity_router)
app.include_router(holding_router)
app.include_router(position_router)
app.include_router(round_router)
app.include_router(import_router)
app.include_router(user_router)
app.include_router(document_router)
app.include_router(capital_account_router)
app.include_router(capital_import_router)
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
def _contained_static_path(static_root: Path, path: str) -> Path | None:
"""Resolve `path` under `static_root`, returning the file only if it stays
within the root. Percent-encoded traversal (..%2f) survives routing and
would otherwise let an unauthenticated caller read files outside static/
(e.g. the DB or session secret on the data volume). Returns None if the
resolved path escapes the root."""
root = static_root.resolve()
candidate = (root / path).resolve()
if candidate != root and root not in candidate.parents:
return None
return candidate
# Serve built frontend in production (when static/ dir exists next to the app)
_static_dir = Path(__file__).resolve().parent.parent / "static"
if _static_dir.is_dir():
# index.html must always be revalidated so a new build is picked up right after an
# upgrade (its hashed asset references change). Hashed assets themselves are immutable.
_NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"}
# Vite content-hashes asset filenames (index-UPbvqVP1.js), so they can cache forever.
_IMMUTABLE = {"Cache-Control": "public, max-age=31536000, immutable"}
_ONE_DAY = {"Cache-Control": "public, max-age=86400"}
def _index() -> FileResponse:
return FileResponse(_static_dir / "index.html", headers=_NO_CACHE)
@app.api_route("/{path:path}", methods=["GET", "HEAD"])
async def serve_spa(path: str):
file = _contained_static_path(_static_dir, path)
if file is None:
return _index()
if file.is_file():
# Don't let the HTML entrypoint get cached; fingerprinted assets can cache.
if file.name == "index.html":
return _index()
if path.startswith("assets/"):
return FileResponse(file, headers=_IMMUTABLE)
if path in ("ten31-logo.png", "favicon.svg", "favicon.ico"):
return FileResponse(file, headers=_ONE_DAY)
return FileResponse(file)
return _index()
def cli() -> None:
import uvicorn
uvicorn.run("ten31portal.main:app", host="0.0.0.0", port=8000, reload=True)
if __name__ == "__main__":
cli()