diff --git a/builders/server/main.py b/builders/server/main.py index 4834f7e..d9bb21b 100644 --- a/builders/server/main.py +++ b/builders/server/main.py @@ -11,6 +11,7 @@ from core.runtime.registry import load_all_configs from core.runtime.venv_management import setup_builder_venvs from fastapi import Depends, FastAPI, Request, Response +from fastapi.middleware.cors import CORSMiddleware from log_config import setup_logging as _setup_logging logger = structlog.get_logger() @@ -45,3 +46,19 @@ async def request_context_middleware(request: Request, call_next) -> Response: structlog.contextvars.clear_contextvars() structlog.contextvars.bind_contextvars(request_id=str(uuid.uuid4())) return await call_next(request) + + +# added last so CORS wraps outermost and preflight requests short-circuit +# before auth and request-context logging +_DEFAULT_CORS_ORIGINS = "https://wat-street.github.io,http://localhost:5173" +cors_origins = [ + origin.strip() + for origin in os.environ.get("CORS_ALLOW_ORIGINS", _DEFAULT_CORS_ORIGINS).split(",") + if origin.strip() +] +app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_methods=["*"], + allow_headers=["Authorization", "Content-Type"], +) diff --git a/builders/server/tests/core/api/test_cors.py b/builders/server/tests/core/api/test_cors.py new file mode 100644 index 0000000..92ef83f --- /dev/null +++ b/builders/server/tests/core/api/test_cors.py @@ -0,0 +1,41 @@ +from fastapi.testclient import TestClient +from main import app + +client: TestClient = TestClient(app) + +ALLOWED_ORIGIN = "https://wat-street.github.io" + + +def _preflight(origin: str) -> dict[str, str]: + """build preflight headers for a GET with an Authorization header.""" + return { + "Origin": origin, + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "authorization", + } + + +def test_preflight_allowed_origin() -> None: + """an allowed origin gets a 200 preflight with cors headers, no auth needed.""" + res = client.options("/api/v1/datasets", headers=_preflight(ALLOWED_ORIGIN)) + assert res.status_code == 200 + assert res.headers["access-control-allow-origin"] == ALLOWED_ORIGIN + assert "Authorization" in res.headers["access-control-allow-headers"] + + +def test_preflight_dev_origin() -> None: + """the local vite dev server origin is allowed by default.""" + res = client.options( + "/api/v1/datasets", headers=_preflight("http://localhost:5173") + ) + assert res.status_code == 200 + assert res.headers["access-control-allow-origin"] == "http://localhost:5173" + + +def test_preflight_unknown_origin_rejected() -> None: + """an unknown origin gets no cors headers.""" + res = client.options( + "/api/v1/datasets", headers=_preflight("https://evil.example.com") + ) + assert res.status_code == 400 + assert "access-control-allow-origin" not in res.headers diff --git a/infra/.env.template b/infra/.env.template index 89ced49..d544d70 100644 --- a/infra/.env.template +++ b/infra/.env.template @@ -14,3 +14,7 @@ DOMAIN=localhost # cd builders/server && uv run python -m core.auth generate