mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-10-19 15:44:27 +00:00
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:
commit
4ce2408dae
4 changed files with 77 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() == []
|
||||
|
|
|
|||
40
surfsense_local/backend/tests/unit/shared/test_cors.py
Normal file
40
surfsense_local/backend/tests/unit/shared/test_cors.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue