Configurable API base + CORS for the frontend/backend split (Stage 5) (#18)
This commit is contained in:
parent
f21aa2c2e0
commit
897f413f3c
2 changed files with 48 additions and 2 deletions
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
31
web/app.py
31
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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue