From 90f8a999c1f52408500ccf114403e8b82a74590f Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 15:48:01 -0400 Subject: [PATCH] CORS config file fix --- api/core/config.py | 30 ++++++++++++++++++++++++++++-- api/main.py | 4 +--- tests/unit/test_config.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/api/core/config.py b/api/core/config.py index 7911264..dbda4b3 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -1,3 +1,5 @@ +import json + from pydantic_settings import BaseSettings, SettingsConfigDict @@ -10,8 +12,12 @@ class Settings(BaseSettings): PROJECT_NAME: str = "Workspaces API" - # Comma separated list of origins, or "*" to allow all origins. Defaults to an empty list. - # Example: CORS_ORIGINS="https://workspaces.example.com,https://leaderboard.example.com" + # Allowed CORS origins. Accepts either a comma-separated list OR a JSON + # array (the deployment stack historically supplies a JSON array), plus "*" + # for all origins. Read via `cors_origins_list`, not this raw string. + # Examples: + # CORS_ORIGINS="https://workspaces.example.com,https://leaderboard.example.com" + # CORS_ORIGINS='["https://workspaces.example.com"]' CORS_ORIGINS: str = "" TASK_DATABASE_URL: str = ( @@ -55,6 +61,26 @@ class Settings(BaseSettings): SENTRY_DSN: str = "" + @property + def cors_origins_list(self) -> list[str]: + """Allowed CORS origins as a list. + + Tolerates both formats the deployment has used: a JSON array + (``["https://a","https://b"]``) and a comma-separated string + (``https://a,https://b``). ``"*"`` is passed through as-is. + """ + raw = self.CORS_ORIGINS.strip() + if not raw: + return [] + if raw.startswith("["): + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = None + if isinstance(parsed, list): + return [str(o).strip() for o in parsed if str(o).strip()] + return [o.strip() for o in raw.split(",") if o.strip()] + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/api/main.py b/api/main.py index 9123a4e..c3708b3 100644 --- a/api/main.py +++ b/api/main.py @@ -97,9 +97,7 @@ async def lifespan(_app: FastAPI): app.add_middleware( CORSMiddleware, - allow_origins=[ - origin.strip() for origin in settings.CORS_ORIGINS.split(",") if origin.strip() - ], + allow_origins=settings.cors_origins_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 4a9ab45..cc96fa0 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -45,6 +45,43 @@ def test_cors_origins_parsed_from_json_env(monkeypatch): assert s.CORS_ORIGINS == "https://a.example,https://b.example" +def test_cors_origins_list_comma_separated(monkeypatch): + monkeypatch.setenv("CORS_ORIGINS", "https://a.example, https://b.example") + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["https://a.example", "https://b.example"] + + +def test_cors_origins_list_json_array(monkeypatch): + # The deployment stack supplies a JSON array; it must parse, not become one + # malformed bracketed origin. + monkeypatch.setenv("CORS_ORIGINS", '["https://a.example","https://b.example"]') + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["https://a.example", "https://b.example"] + + +def test_cors_origins_list_single_json_array(monkeypatch): + monkeypatch.setenv("CORS_ORIGINS", '["https://only.example"]') + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["https://only.example"] + + +def test_cors_origins_list_empty_and_wildcard(monkeypatch): + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == [] + + monkeypatch.setenv("CORS_ORIGINS", "*") + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["*"] + + +def test_cors_origins_list_malformed_json_falls_back_to_comma(monkeypatch): + # A value that starts with "[" but isn't valid JSON should not crash; fall + # back to comma-splitting rather than raising. + monkeypatch.setenv("CORS_ORIGINS", "[not json") + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["[not json"] + + def test_value_types_and_formats(): s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg