From 935705a7b1d7a27545248aff78b84a17f7fc8d20 Mon Sep 17 00:00:00 2001 From: qwist1233-cpu Date: Thu, 1 Oct 2026 11:09:03 +0300 Subject: [PATCH] feat(local-backend): read CORS origins from SURFSENSE_LOCAL_CORS_ORIGINS Comma-separated, default "*" so the desktop app keeps working from file://. Empty switches CORS headers off for the Docker image. Fixes #2101 --- surfsense_local/backend/api/config.py | 9 +++++ surfsense_local/backend/api/main.py | 20 ++++++---- .../backend/tests/unit/shared/test_config.py | 16 ++++++++ .../backend/tests/unit/shared/test_cors.py | 40 +++++++++++++++++++ 4 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 surfsense_local/backend/tests/unit/shared/test_cors.py diff --git a/surfsense_local/backend/api/config.py b/surfsense_local/backend/api/config.py index 723acc2c2..37c166ea2 100644 --- a/surfsense_local/backend/api/config.py +++ b/surfsense_local/backend/api/config.py @@ -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 diff --git a/surfsense_local/backend/api/main.py b/surfsense_local/backend/api/main.py index 7e0af787f..824c07d04 100644 --- a/surfsense_local/backend/api/main.py +++ b/surfsense_local/backend/api/main.py @@ -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) diff --git a/surfsense_local/backend/tests/unit/shared/test_config.py b/surfsense_local/backend/tests/unit/shared/test_config.py index b08620ee0..acf7cd699 100644 --- a/surfsense_local/backend/tests/unit/shared/test_config.py +++ b/surfsense_local/backend/tests/unit/shared/test_config.py @@ -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() == [] diff --git a/surfsense_local/backend/tests/unit/shared/test_cors.py b/surfsense_local/backend/tests/unit/shared/test_cors.py new file mode 100644 index 000000000..720d1eb63 --- /dev/null +++ b/surfsense_local/backend/tests/unit/shared/test_cors.py @@ -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