feat(api): add configurable CORS middleware
The web UI is multi-instance and can connect to the backend at a different origin (the direct :8000 port, a LAN IP, 127.0.0.1 vs localhost), which the browser blocks without CORS headers. Adds CORSMiddleware driven by a new cors_allow_origins setting (default "*", safe here: bearer-token auth with allow_credentials=False). Accepts a comma-separated string in .env. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,17 @@ class Settings(BaseSettings):
|
|||||||
# admin-only (POST /admin/users). Registered users are never superusers.
|
# admin-only (POST /admin/users). Registered users are never superusers.
|
||||||
allow_registration: bool = True
|
allow_registration: bool = True
|
||||||
|
|
||||||
|
# -- CORS -------------------------------------------------------------
|
||||||
|
# Origins allowed to call the API from a browser. The web UI is multi-
|
||||||
|
# instance — it connects to whatever origin the operator types on the
|
||||||
|
# connect screen — so a page served from origin A may call this backend at
|
||||||
|
# origin B (e.g. the direct :8000 port, a LAN IP, or 127.0.0.1 vs localhost).
|
||||||
|
# Auth rides in the ``Authorization`` bearer header (not cookies), so the
|
||||||
|
# wildcard default is safe here — it is paired with ``allow_credentials=False``.
|
||||||
|
# Set explicit origins in hardened deployments. Accepts a comma-separated
|
||||||
|
# string in ``.env`` (``CORS_ALLOW_ORIGINS=https://a,https://b``) or ``*``.
|
||||||
|
cors_allow_origins: list[str] = Field(default_factory=lambda: ["*"])
|
||||||
|
|
||||||
# -- subsonic ---------------------------------------------------------
|
# -- subsonic ---------------------------------------------------------
|
||||||
# Symmetric key (any string) used to encrypt each user's recoverable
|
# Symmetric key (any string) used to encrypt each user's recoverable
|
||||||
# Subsonic app-password at rest. A Fernet key is derived from it; rotating
|
# Subsonic app-password at rest. A Fernet key is derived from it; rotating
|
||||||
@@ -127,6 +138,15 @@ class Settings(BaseSettings):
|
|||||||
raise ValueError("database_url must use the asyncpg driver: postgresql+asyncpg://")
|
raise ValueError("database_url must use the asyncpg driver: postgresql+asyncpg://")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator("cors_allow_origins", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _split_cors_origins(cls, v: object) -> object:
|
||||||
|
# Allow a plain comma-separated string in .env (pydantic would otherwise
|
||||||
|
# try to JSON-decode a list field): "a, b" -> ["a", "b"]; "*" -> ["*"].
|
||||||
|
if isinstance(v, str):
|
||||||
|
return [origin.strip() for origin in v.split(",") if origin.strip()]
|
||||||
|
return v
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_prod(self) -> bool:
|
def is_prod(self) -> bool:
|
||||||
return self.environment == "prod"
|
return self.environment == "prod"
|
||||||
|
|||||||
+14
@@ -4,6 +4,7 @@ from collections.abc import AsyncIterator
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket
|
from fastapi import FastAPI, WebSocket
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.errors import register_exception_handlers
|
from app.api.errors import register_exception_handlers
|
||||||
from app.api.health import router as health_router
|
from app.api.health import router as health_router
|
||||||
@@ -40,6 +41,19 @@ def create_app() -> FastAPI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(CorrelationIdMiddleware)
|
app.add_middleware(CorrelationIdMiddleware)
|
||||||
|
# CORS added last → outermost, so browser preflight (OPTIONS) is answered
|
||||||
|
# before anything else. The web UI can connect cross-origin (direct :8000,
|
||||||
|
# a LAN IP, 127.0.0.1 vs localhost), which needs these headers. Bearer-token
|
||||||
|
# auth (no cookies) → wildcard origins are safe with allow_credentials=False.
|
||||||
|
if settings.cors_allow_origins:
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_allow_origins,
|
||||||
|
allow_credentials=False,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
expose_headers=["Content-Range", "Accept-Ranges", "Content-Length"],
|
||||||
|
)
|
||||||
register_exception_handlers(app)
|
register_exception_handlers(app)
|
||||||
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
|||||||
Reference in New Issue
Block a user