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")
_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)):
yield SQLAlchemyUserDatabase(session, User)
@ -91,9 +106,9 @@ cookie_transport = CookieTransport(
cookie_name="tg_session",
cookie_max_age=SESSION_TTL_SECONDS,
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_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
from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import RedirectResponse
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.
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")
def healthz():
@ -754,6 +777,14 @@ async def _proxy_to_frontend(request: Request) -> Response:
plan's Stage 4 section."""
headers = {k: v for k, v in request.headers.items()
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(
request.method, request.url.path, params=request.url.query, headers=headers)
resp_headers = {k: v for k, v in upstream.headers.items()