diff --git a/agentic_security/middleware/cors.py b/agentic_security/middleware/cors.py index fb65051d..d775a513 100644 --- a/agentic_security/middleware/cors.py +++ b/agentic_security/middleware/cors.py @@ -1,13 +1,35 @@ +import os + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +def _resolve_allowed_origins() -> list[str]: + """Resolve the list of CORS-allowed origins from the environment. + + The previous behaviour hard-coded ``["*"]``, which exposed the + unauthenticated ``/scan``, ``/scan-csv``, ``/stop`` and ``/verify`` + endpoints to any web origin (issue #334): a malicious site could drive + those endpoints from a victim's browser. + + Origins are now opt-in. Set ``AGENTIC_SECURITY_CORS_ORIGINS`` to a + comma-separated list of trusted origins (e.g. your front-end URL). When + the variable is unset or empty, no cross-origin origin is allowed, which + is the safe default for a server-side scanning API. + """ + raw = os.environ.get("AGENTIC_SECURITY_CORS_ORIGINS", "").strip() + if not raw: + return [] + return [origin.strip() for origin in raw.split(",") if origin.strip()] + + def setup_cors(app: FastAPI): - origins = ["*"] + origins = _resolve_allowed_origins() app.add_middleware( CORSMiddleware, allow_origins=origins, allow_methods=["*"], # Allows all methods allow_headers=["*"], # Allows all headers + allow_credentials=False, ) diff --git a/tests/unit/test_cors_middleware.py b/tests/unit/test_cors_middleware.py index e8d0696f..b221cf16 100644 --- a/tests/unit/test_cors_middleware.py +++ b/tests/unit/test_cors_middleware.py @@ -1,17 +1,22 @@ """Unit tests for CORS middleware configuration. -Verifies that the wildcard-origins + allow_credentials=True spec violation -(CORS spec §3.2, Fetch §4.7) has been removed. Browsers silently strip -credentials when the response carries Access-Control-Allow-Origin: * paired -with Access-Control-Allow-Credentials: true, so the old config was both -broken and misleading. +Verifies that cross-origin access is opt-in (issue #334): the previous +hard-coded ``allow_origins=["*"]`` exposed the unauthenticated ``/scan``, +``/scan-csv``, ``/stop`` and ``/verify`` endpoints to any web origin. + +Policy under test: +- No credentials are ever allowed (CORS spec §3.2, Fetch §4.7). +- Allowed origins come from AGENTIC_SECURITY_CORS_ORIGINS; when unset, no + cross-origin origin is permitted (safe default). """ +import os + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.testclient import TestClient -from agentic_security.middleware.cors import setup_cors +from agentic_security.middleware.cors import _resolve_allowed_origins, setup_cors def _get_cors_options(app: FastAPI) -> dict: @@ -32,26 +37,34 @@ def test_cors_middleware_is_registered(self): cls_names = [m.cls.__name__ for m in app.user_middleware] assert "CORSMiddleware" in cls_names - def test_wildcard_origins_without_credentials(self): - """allow_origins=['*'] must not be paired with allow_credentials=True. + def test_default_has_no_wildcard_origin(self): + """By default (no env var) no cross-origin origin is allowed. - The combination is forbidden by the CORS spec and causes browsers to - silently drop credentials on every cross-origin request. + This is the fix for #334: a wildcard origin must never be the default + for an API that exposes unauthenticated scanning endpoints. """ + os.environ.pop("AGENTIC_SECURITY_CORS_ORIGINS", None) app = FastAPI() setup_cors(app) opts = _get_cors_options(app) allow_origins = opts.get("allow_origins", []) - allow_credentials = opts.get("allow_credentials", False) - - if "*" in allow_origins or allow_origins == ["*"]: - assert not allow_credentials, ( - "allow_origins=['*'] with allow_credentials=True is invalid per " - "the CORS spec — browsers reject it and credentials are silently dropped" - ) - - def test_cors_allows_cross_origin_requests(self): - """Cross-origin preflight requests return a 200 with CORS headers.""" + assert allow_origins == [] + assert opts.get("allow_credentials", True) is False + + def test_env_var_configures_explicit_origins(self): + """AGENTIC_SECURITY_CORS_ORIGINS is parsed into the allow list.""" + os.environ["AGENTIC_SECURITY_CORS_ORIGINS"] = "https://app.example.com, https://dash.example.com" + try: + assert _resolve_allowed_origins() == [ + "https://app.example.com", + "https://dash.example.com", + ] + finally: + os.environ.pop("AGENTIC_SECURITY_CORS_ORIGINS", None) + + def test_disallowed_origin_gets_no_cors_header(self): + """A non-allowed origin must not receive Access-Control-Allow-Origin.""" + os.environ.pop("AGENTIC_SECURITY_CORS_ORIGINS", None) app = FastAPI() @app.get("/probe") @@ -59,21 +72,31 @@ async def probe(): return {"ok": True} setup_cors(app) - client = TestClient(app, raise_server_exceptions=True) - - response = client.options( - "/probe", - headers={ - "Origin": "http://localhost:3000", - "Access-Control-Request-Method": "GET", - }, - ) - assert response.status_code == 200 - assert "access-control-allow-origin" in response.headers - - def test_cors_no_credentials_header_with_wildcard(self): - """With wildcard origins, the response must not include - Access-Control-Allow-Credentials: true.""" + client = TestClient(app) + response = client.get("/probe", headers={"Origin": "http://evil.example.com"}) + assert "access-control-allow-origin" not in response.headers + + def test_allowed_origin_gets_cors_header(self): + """An explicitly allowed origin receives Access-Control-Allow-Origin.""" + os.environ["AGENTIC_SECURITY_CORS_ORIGINS"] = "https://app.example.com" + try: + app = FastAPI() + + @app.get("/probe") + async def probe(): + return {"ok": True} + + setup_cors(app) + client = TestClient(app) + response = client.get("/probe", headers={"Origin": "https://app.example.com"}) + assert response.headers.get("access-control-allow-origin") == "https://app.example.com" + # Credentials must never be enabled alongside an origin allow list. + assert response.headers.get("access-control-allow-credentials", "false").lower() != "true" + finally: + os.environ.pop("AGENTIC_SECURITY_CORS_ORIGINS", None) + + def test_no_credentials_header(self): + """Credentials are never advertised, regardless of origin config.""" app = FastAPI() @app.get("/probe") @@ -83,12 +106,4 @@ async def probe(): setup_cors(app) client = TestClient(app) response = client.get("/probe", headers={"Origin": "http://evil.example.com"}) - - acao = response.headers.get("access-control-allow-origin", "") - acac = response.headers.get("access-control-allow-credentials", "false") - - if acao == "*": - assert acac.lower() != "true", ( - "Wildcard ACAO + ACAC:true is a spec violation (RFC 6454 §7.2, " - "Fetch §4.7) and silently breaks credentialed cross-origin requests" - ) + assert response.headers.get("access-control-allow-credentials", "false").lower() != "true"