Configurable API base + CORS for the frontend/backend split (Stage 5) (#18)

This commit is contained in:
emi 2026-07-21 20:52:11 +00:00
parent f21aa2c2e0
commit 897f413f3c
2 changed files with 48 additions and 2 deletions

View file

@ -41,6 +41,21 @@ def _cookie_secure() -> bool:
return v in ("1", "true", "yes", "always", "on") return v in ("1", "true", "yes", "always", "on")
_COOKIE_SECURE = _cookie_secure()
# Default "lax" (today's exact hardcoded behavior): blocks cross-site form posts
# (CSRF), allows top-level nav, and needs no Secure. "none" is what genuine
# cross-origin fetch (frontend and backend on different origins -- repo-split
# Stage 5) requires -- browsers refuse to store a SameSite=None cookie without
# Secure at all, so that combination fails loud here instead of manifesting
# only as "login mysteriously doesn't persist" once deployed.
COOKIE_SAMESITE = os.environ.get("THERMOGRAPH_COOKIE_SAMESITE", "lax").strip().lower()
if COOKIE_SAMESITE == "none" and not _COOKIE_SECURE:
raise RuntimeError(
"THERMOGRAPH_COOKIE_SAMESITE=none requires THERMOGRAPH_COOKIE_SECURE=1 "
"(browsers drop SameSite=None cookies that aren't also Secure)."
)
async def get_user_db(session: AsyncSession = Depends(get_async_session)): async def get_user_db(session: AsyncSession = Depends(get_async_session)):
yield SQLAlchemyUserDatabase(session, User) yield SQLAlchemyUserDatabase(session, User)
@ -91,9 +106,9 @@ cookie_transport = CookieTransport(
cookie_name="tg_session", cookie_name="tg_session",
cookie_max_age=SESSION_TTL_SECONDS, cookie_max_age=SESSION_TTL_SECONDS,
cookie_path=BASE, # scope the cookie to /thermograph, not the whole domain cookie_path=BASE, # scope the cookie to /thermograph, not the whole domain
cookie_secure=_cookie_secure(), cookie_secure=_COOKIE_SECURE,
cookie_httponly=True, # unreadable by JS -> XSS can't steal the session cookie_httponly=True, # unreadable by JS -> XSS can't steal the session
cookie_samesite="lax", # blocks cross-site form posts (CSRF), allows top-level nav cookie_samesite=COOKIE_SAMESITE,
) )

View file

@ -15,6 +15,7 @@ import html as html_escape
import httpx import httpx
from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@ -187,6 +188,28 @@ app = FastAPI(title="Thermograph", version="0.2.0", lifespan=_lifespan)
# Applies to API JSON and static assets alike; tiny responses are left alone. # Applies to API JSON and static assets alike; tiny responses are left alone.
app.add_middleware(GZipMiddleware, minimum_size=1024) app.add_middleware(GZipMiddleware, minimum_size=1024)
# CORS (repo-split Stage 5): unset (default) means no CORSMiddleware at all --
# today's exact behavior, browsers already allow same-origin fetch with no
# help from us. Comma-separated THERMOGRAPH_CORS_ORIGINS opts in an explicit
# allowlist for a genuinely cross-origin frontend (never "*" -- FastAPI/
# Starlette itself refuses that combined with allow_credentials=True, since a
# credentialed wildcard is exactly the misconfiguration CORS exists to
# prevent). expose_headers=["ETag"] matters as much as allow_origins: without
# it, cache.js's res.headers.get("ETag") silently returns null cross-origin
# (the browser hides every response header except a fixed "simple" allowlist
# by default) and 304 revalidation quietly stops working with no console
# error at all.
_cors_origins = [o.strip() for o in os.environ.get("THERMOGRAPH_CORS_ORIGINS", "").split(",") if o.strip()]
if _cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["ETag"],
)
@app.get("/healthz") @app.get("/healthz")
def healthz(): def healthz():
@ -754,6 +777,14 @@ async def _proxy_to_frontend(request: Request) -> Response:
plan's Stage 4 section.""" plan's Stage 4 section."""
headers = {k: v for k, v in request.headers.items() headers = {k: v for k, v in request.headers.items()
if k.lower() not in _PROXY_EXCLUDED_REQUEST_HEADERS} if k.lower() not in _PROXY_EXCLUDED_REQUEST_HEADERS}
# Host is deliberately excluded above (it'd otherwise target this internal
# hop, not the browser-facing address) -- forward it as X-Forwarded-Host
# instead, so frontend_ssr's own _origin() can still build correct
# absolute URLs (canonical, og:url, asset_base_url) instead of resolving
# to its own internal address. Chain through an existing value first, the
# same precedence _origin() itself uses for the proto.
headers["x-forwarded-host"] = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc
headers["x-forwarded-proto"] = request.headers.get("x-forwarded-proto") or request.url.scheme
upstream = await _frontend_client.request( upstream = await _frontend_client.request(
request.method, request.url.path, params=request.url.query, headers=headers) request.method, request.url.path, params=request.url.query, headers=headers)
resp_headers = {k: v for k, v in upstream.headers.items() resp_headers = {k: v for k, v in upstream.headers.items()