From 897f413f3c1a1f918b7650e926a2c05db78a4cc3 Mon Sep 17 00:00:00 2001 From: emi Date: Tue, 21 Jul 2026 20:52:11 +0000 Subject: [PATCH] Configurable API base + CORS for the frontend/backend split (Stage 5) (#18) --- accounts/users.py | 19 +++++++++++++++++-- web/app.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/accounts/users.py b/accounts/users.py index c713790..80a1e16 100644 --- a/accounts/users.py +++ b/accounts/users.py @@ -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, ) diff --git a/web/app.py b/web/app.py index 57df842..70d4027 100644 --- a/web/app.py +++ b/web/app.py @@ -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()