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")
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
31
web/app.py
31
web/app.py
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue