diff --git a/.env.example b/.env.example index b7a9e22..8656cef 100644 --- a/.env.example +++ b/.env.example @@ -34,5 +34,8 @@ JWT_SECRET=change-this-in-production-min-32-bytes JWT_ALGORITHM=HS256 ACCESS_TOKEN_MINUTES=720 +# ── CORS ─────────────────────────────────────────────────────── +CORS_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:3000,http://127.0.0.1:3000 + # ── Database ─────────────────────────────────────────────────── DATABASE_URL=sqlite:///./assistant.db diff --git a/README.md b/README.md index d38e8e5..e0c2280 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ Important variables: - `JWT_SECRET` - `DATABASE_URL` - `RATE_LIMIT_PER_MINUTE` +- `CORS_ALLOW_ORIGINS` - `LLM_API_KEY` (optional) The app can still run without external AI providers when `LLM_ENABLED=false`. @@ -372,6 +373,7 @@ The backend includes built-in resilience for LLM requests: | Variable | Default | Description | |---|---|---| | `RATE_LIMIT_PER_MINUTE` | `30` | Max requests per IP per minute | +| `CORS_ALLOW_ORIGINS` | Local dev + Render app origins | Comma-separated browser origins allowed to call the API | | `LLM_ENABLED` | `false` | Enable LLM provider | | `LLM_API_KEY` | — | API key for your LLM provider | | `LLM_BASE_URL` | `https://api.openai.com/v1` | LLM base URL | diff --git a/backend/app/config.py b/backend/app/config.py index 06146d1..6908777 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -41,6 +41,15 @@ def _bool_env(name: str, default: bool) -> bool: return raw_value.strip().lower() in {"1", "true", "yes", "on"} +def _csv_env(name: str, default: list[str]) -> list[str]: + raw_value = os.getenv(name) + if raw_value is None: + return default + + values = [value.strip() for value in raw_value.split(",") if value.strip()] + return values or default + + class Settings: """Application settings loaded from environment variables.""" @@ -62,6 +71,16 @@ class Settings: jwt_secret: str = os.getenv("JWT_SECRET", "change-this-in-production-min-32-bytes") jwt_algorithm: str = os.getenv("JWT_ALGORITHM", "HS256") access_token_minutes: int = _int_env("ACCESS_TOKEN_MINUTES", 720) + cors_allow_origins: list[str] = _csv_env( + "CORS_ALLOW_ORIGINS", + [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:3000", + "http://127.0.0.1:3000", + "https://qyverixai.onrender.com", + ], + ) llm_enabled: bool = _bool_env("LLM_ENABLED", False) llm_api_key: str | None = os.getenv("LLM_API_KEY") llm_base_url: str = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1") diff --git a/backend/app/main.py b/backend/app/main.py index e868905..dc1562f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -30,6 +30,7 @@ from .services import database from .services.scheduler import start_scheduler, stop_scheduler from .database import Base, engine +from .config import settings from .schemas import HealthResponse @@ -85,10 +86,18 @@ async def lifespan(app: FastAPI): # ── Middleware ──────────────────────────────────────────────────────────────── app.add_middleware(GZipMiddleware, minimum_size=1000) + + +def _allow_credentials_for_origins(origins: list[str]) -> bool: + return "*" not in origins + + +_cors_allow_origins = settings.cors_allow_origins +_cors_allow_credentials = _allow_credentials_for_origins(_cors_allow_origins) app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, + allow_origins=_cors_allow_origins, + allow_credentials=_cors_allow_credentials, allow_methods=["*"], allow_headers=["*"], ) diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py index c4d776b..a622afc 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -188,6 +188,40 @@ def test_rate_limit_returns_429_with_retry_after_header(): assert r.headers["X-RateLimit-Remaining"] == "0" +def test_cors_allows_configured_origin_with_credentials(): + r = client.options( + "/auth/me", + headers={ + "Origin": "http://localhost:5173", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "authorization", + }, + ) + + assert r.status_code == 200 + assert r.headers["access-control-allow-origin"] == "http://localhost:5173" + assert r.headers["access-control-allow-credentials"] == "true" + + +def test_cors_rejects_unconfigured_origin(): + r = client.options( + "/auth/me", + headers={ + "Origin": "https://attacker.example", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "authorization", + }, + ) + + assert r.status_code == 400 + assert "access-control-allow-origin" not in r.headers + + +def test_wildcard_cors_disables_credentials(): + assert app_main._allow_credentials_for_origins(["*"]) is False + assert app_main._allow_credentials_for_origins(["http://localhost:5173"]) is True + + # ── Explanation ─────────────────────────────────────────────────────────────── def test_explanation_python(): r = client.post("/explanation/", json={"code": PYTHON_CLEAN, "language": "python"}) diff --git a/render.yaml b/render.yaml index a28b9fe..e9d06a5 100644 --- a/render.yaml +++ b/render.yaml @@ -10,8 +10,10 @@ services: value: 30 - key: LLM_ENABLED value: false + - key: CORS_ALLOW_ORIGINS + value: https://qyverixai.onrender.com staticPublishPath: frontend routes: - type: rewrite source: /app - destination: /frontend/index.html \ No newline at end of file + destination: /frontend/index.html