Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion agentic_security/middleware/cors.py
Original file line number Diff line number Diff line change
@@ -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,
)
103 changes: 59 additions & 44 deletions tests/unit/test_cors_middleware.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -32,48 +37,66 @@ 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")
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")
Expand All @@ -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"