feat(api): configure CORS middleware for API endpoints

- Add CORSMiddleware (disabled by default, enabled via CORS_ENABLED=true)
- Add cors_enabled, cors_allowed_origins, cors_allow_credentials,
  cors_allowed_methods, cors_allowed_headers settings to config.py
- Add parse_comma_separated_list validator for CORS list env vars
- Insert CORS middleware between SessionMiddleware and ProxyHeaders
  so preflight runs before CSRF/auth but after proxy-header processing
- Document CORS env vars in .env.demo with rationale for proxy-first approach
- Mark CORS TODO as completed in SECURITY_AUDIT.md
- Add tests/test_cors.py with 12 unit and integration tests

Closes #175

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-22 19:26:39 +00:00
parent 7a10f4a7c0
commit 1648d8c745
5 changed files with 328 additions and 2 deletions
+48
View File
@@ -300,6 +300,42 @@ class Settings(BaseSettings):
description="Stricter rate limit for authentication endpoints to prevent brute force attacks.",
)
# CORS Configuration (see SECURITY_AUDIT.md Infrastructure Security section)
# Disabled by default since most deployments use a reverse proxy (Traefik, Nginx, etc.)
# that already adds CORS headers. Enable only if deploying without a reverse proxy or if
# the proxy does not handle CORS. See docs/DeploymentGuide.md for rationale.
cors_enabled: bool = Field(
default=False,
description=(
"Enable CORS middleware. Set to False if reverse proxy (Traefik, Nginx) handles CORS headers. "
"When True, CORSMiddleware is added to the application with the settings below."
),
)
cors_allowed_origins: Union[List[str], str] = Field(
default_factory=lambda: ["*"],
description=(
"List of allowed CORS origins. Use ['*'] to allow all origins (not recommended with "
"cors_allow_credentials=True). Comma-separated string is also accepted via env var, "
"e.g. CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com"
),
)
cors_allow_credentials: bool = Field(
default=False,
description=(
"Allow credentials (cookies, Authorization headers) in CORS requests. "
"Cannot be True when cors_allowed_origins=['*']. "
"When True, set cors_allowed_origins to specific origins."
),
)
cors_allowed_methods: Union[List[str], str] = Field(
default_factory=lambda: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
description="Allowed HTTP methods for CORS requests.",
)
cors_allowed_headers: Union[List[str], str] = Field(
default_factory=lambda: ["*"],
description="Allowed request headers for CORS. Use ['*'] to allow all headers.",
)
@model_validator(mode="before")
@classmethod
def strip_outer_quotes(cls, data: Any) -> Any:
@@ -331,6 +367,18 @@ class Settings(BaseSettings):
return []
return v
@field_validator("cors_allowed_origins", "cors_allowed_methods", "cors_allowed_headers", mode="before")
@classmethod
def parse_comma_separated_list(cls, v: str | list[str]) -> list[str]:
"""Parse comma-separated string or list for CORS list settings."""
if isinstance(v, str):
if "," in v:
return [item.strip() for item in v.split(",") if item.strip()]
elif v.strip():
return [v.strip()]
return []
return v
@field_validator("session_secret")
@classmethod
def validate_session_secret(cls, v: str | None, info: object) -> str | None: