diff --git a/.audit-config.yaml b/.audit-config.yaml index 71aedcb5f..d6605aa68 100644 --- a/.audit-config.yaml +++ b/.audit-config.yaml @@ -17,7 +17,11 @@ policy: # Documented exceptions with business justification # Format: CVE-XXXX-XXXXX or GHSA-xxxx-xxxx-xxxx -exceptions: {} +exceptions: + GHSA-gv7w-rqvm-qjhr: + package: esbuild + reason: "esbuild vulnerability affects Deno module only; SecuScan uses esbuild via Vite for bundling in Node.js context. Fix requires Vite 8.x breaking upgrade." + expires_at: "2026-08-31" # Packages to exclude from audits (use sparingly!) excluded_packages: [] diff --git a/backend/secuscan/main.py b/backend/secuscan/main.py index 8e06d6638..8e4c941cd 100644 --- a/backend/secuscan/main.py +++ b/backend/secuscan/main.py @@ -7,7 +7,7 @@ import shutil from pathlib import Path from contextlib import asynccontextmanager -from .request_middleware import RequestIDMiddleware +from .request_middleware import RequestIDMiddleware, HardenCORSMiddleware from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, PlainTextResponse @@ -170,6 +170,7 @@ async def redirect_api_openapi(): allow_methods=settings.cors_allowed_methods, allow_headers=settings.cors_allowed_headers, ) +app.add_middleware(HardenCORSMiddleware) app.add_middleware(RequestIDMiddleware) @app.exception_handler(StarletteHTTPException) diff --git a/backend/secuscan/request_middleware.py b/backend/secuscan/request_middleware.py index 985b4979e..7ec7f20e9 100644 --- a/backend/secuscan/request_middleware.py +++ b/backend/secuscan/request_middleware.py @@ -1,6 +1,11 @@ from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse from fastapi import Request +import logging from .request_context import set_request_id +from .config import settings + +logger = logging.getLogger(__name__) class RequestIDMiddleware(BaseHTTPMiddleware): @@ -18,4 +23,32 @@ async def dispatch(self, request: Request, call_next): # Return ID in response headers response.headers["X-Request-ID"] = request_id - return response \ No newline at end of file + return response + + +class HardenCORSMiddleware(BaseHTTPMiddleware): + """ + Harden CORS by validating Origin headers against the allowed origins + when present. Requests without an Origin header are direct API calls + (not CORS) and are allowed through. + """ + async def dispatch(self, request: Request, call_next): + origin = request.headers.get("origin") + if not origin: + return await call_next(request) + + if request.url.path in ["/docs", "/redoc", "/openapi.json", "/"]: + return await call_next(request) + + allowed = settings.cors_allowed_origins + if origin in allowed or "*" in allowed: + return await call_next(request) + + logger.warning(f"⚠️ [CORS] Rejected cross-origin request from {origin}") + return JSONResponse( + content={ + "success": False, + "message": f"Not allowed by CORS: Origin '{origin}' is not permitted" + }, + status_code=403 + ) \ No newline at end of file diff --git a/testing/backend/integration/test_cors.py b/testing/backend/integration/test_cors.py index b226037d1..e667a241f 100644 --- a/testing/backend/integration/test_cors.py +++ b/testing/backend/integration/test_cors.py @@ -27,3 +27,23 @@ def test_cors_preflight_allows_preview_origin(test_client): assert response.status_code == 200 assert response.headers.get("access-control-allow-origin") == origin + +def test_cors_rejects_disallowed_origin(test_client): + response = test_client.get("/api/v1/health", headers={"Origin": "https://evil.com"}) + + assert response.status_code == 403 + assert response.json()["success"] is False + assert "not permitted" in response.json()["message"] + + +def test_cors_allows_no_origin(test_client): + response = test_client.get("/api/v1/health") + assert response.status_code == 200 + + +def test_cors_allows_docs_without_origin(test_client): + # Documentation endpoints should still be accessible without Origin header + # (e.g. by directly opening in browser) + for path in ["/docs", "/redoc", "/openapi.json"]: + response = test_client.get(path) + assert response.status_code == 200