Merge pull request #2122 from qwist1233-cpu/fix/cors-origins-env

feat(local-backend): read CORS origins from SURFSENSE_LOCAL_CORS_ORIGINS
This commit is contained in:
Anish Sarkar 2026-10-01 18:59:14 +05:30 • committed by GitHub
commit 4ce2408dae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 77 additions and 8 deletions

View file

@ -13,6 +13,15 @@ class Settings(BaseSettings):
# Electron's main process, above every sidecar and window process: the root
# of what the usage panel counts as the app. Unset outside Electron.
shell_pid: int | None = None
# Comma-separated. "*" is what the desktop app needs: its window loads from
# file:// and calls the loopback sidecar cross-origin. The Docker image sets
# it empty, so no cross-origin page can talk to the API.
cors_origins: str = "*"
def cors_origin_list(self) -> list[str]:
return [
origin.strip() for origin in self.cors_origins.split(",") if origin.strip()
]
@lru_cache

View file

@ -10,6 +10,7 @@ from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session, sessionmaker
from starlette.types import ASGIApp, Receive, Scope, Send
from api.config import Settings, get_settings
from modules.artifacts.podcast.router import router as podcast_router
from modules.artifacts.router import router as artifacts_router
from modules.chat.router import router as chat_router
@ -127,20 +128,23 @@ def _warm_catalog(session_factory: sessionmaker[Session]) -> None:
logger.exception("could not warm the selected model at startup")
def add_cors(app: FastAPI, settings: Settings) -> None:
"""An empty origin list makes CORSMiddleware add no headers at all."""
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list(),
allow_methods=["*"],
allow_headers=["*"],
)
def create_app() -> FastAPI:
"""Application factory; each call returns an app isolated from the others."""
import_models()
app = FastAPI(title="SurfSense Community Local", lifespan=lifespan)
app.add_middleware(MarkRequest)
# The packaged renderer loads from file:// and calls the 127.0.0.1 sidecar,
# a cross-origin request; the API is loopback-only single-user, so allow any.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
add_cors(app, get_settings())
app.state.broker = EventBroker() # No benefits from lifespan hooks.
app.include_router(health_router)
app.include_router(workspaces_router)

View file

@ -10,3 +10,19 @@ pytestmark = pytest.mark.unit
def test_default_host_is_loopback() -> None:
"""The API ships without auth, so it must never bind past this machine."""
assert ip_address(Settings().host).is_loopback
def test_cors_origins_default_allows_any() -> None:
"""The packaged renderer loads from file://, so unset keeps today's open CORS."""
assert Settings().cors_origin_list() == ["*"]
def test_cors_origins_splits_on_commas_and_drops_blanks() -> None:
"""Operators write `a, b,` by hand; whitespace and empty items are noise."""
settings = Settings(cors_origins=" https://a.example, https://b.example ,, ")
assert settings.cors_origin_list() == ["https://a.example", "https://b.example"]
def test_cors_origins_empty_means_no_origin_allowed() -> None:
"""The Docker image sets it empty to switch cross-origin access off."""
assert Settings(cors_origins="").cors_origin_list() == []

View file

@ -0,0 +1,40 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.config import Settings
from api.main import add_cors
pytestmark = pytest.mark.unit
def _client(cors_origins: str | None) -> TestClient:
app = FastAPI()
app.get("/ping")(lambda: {"ok": True})
settings = (
Settings() if cors_origins is None else Settings(cors_origins=cors_origins)
)
add_cors(app, settings)
return TestClient(app)
def test_unset_answers_cross_origin_requests() -> None:
"""Desktop app: the window loads from file:// and must keep reaching the sidecar."""
response = _client(None).get("/ping", headers={"Origin": "null"})
assert response.headers.get("access-control-allow-origin") == "*"
def test_empty_sends_no_cors_headers() -> None:
"""Docker image: same-origin behind the proxy, so cross-origin access is off."""
response = _client("").get("/ping", headers={"Origin": "https://evil.example"})
assert response.status_code == 200
assert "access-control-allow-origin" not in response.headers
def test_listed_origin_is_allowed_and_others_are_not() -> None:
"""A named list is honoured as-is, not widened to `*`."""
client = _client("https://app.example")
allowed = client.get("/ping", headers={"Origin": "https://app.example"})
denied = client.get("/ping", headers={"Origin": "https://other.example"})
assert allowed.headers.get("access-control-allow-origin") == "https://app.example"
assert "access-control-allow-origin" not in denied.headers