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.
This commit is contained in:
Jonathan Kirkwood
2026-08-28 09:53:16 -05:00
parent 858bbe10da
commit 3a4b7dfd8b
15 changed files with 795 additions and 31 deletions
+45 -3
View File
@@ -4,13 +4,16 @@ import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
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.config import SESSION_SECRET
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
@@ -27,14 +30,53 @@ from ten31portal.routers.capital_import_router import router as capital_import_r
@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.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET)
@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.