diff --git a/app.py b/app.py index e2dbe6e43..e57e85706 100644 --- a/app.py +++ b/app.py @@ -54,7 +54,7 @@ from core.constants import ( REQUEST_TIMEOUT, OPENAI_API_KEY, ) from core.database import SessionLocal, ApiToken -from core.middleware import SecurityHeadersMiddleware +from core.middleware import SecurityHeadersMiddleware, is_cors_preflight from core.auth import AuthManager from core.exceptions import ( SessionNotFoundError, InvalidFileUploadError, @@ -253,6 +253,15 @@ if AUTH_ENABLED: class AuthMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): path = request.url.path + # A genuine CORS preflight (OPTIONS + Access-Control-Request-Method) + # carries no credentials by design and must reach CORSMiddleware to be + # answered. AuthMiddleware is the outermost middleware, so gating the + # preflight on auth 401s it before CORS can respond -- which blocks + # every cross-origin browser/WebView client before the real request + # is sent. Let real preflights through (only OPTIONS w/ the ACRM + # header; never a credentialed request). + if is_cors_preflight(request.method, request.headers): + return await call_next(request) if _is_auth_exempt(path): return await call_next(request) # In-process internal-tool token bypass. Used by the agent diff --git a/core/middleware.py b/core/middleware.py index a0b7cd8b7..b3775e812 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -17,6 +17,15 @@ INTERNAL_TOOL_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token" +def is_cors_preflight(method: str, headers) -> bool: + """True for a genuine CORS preflight: an OPTIONS request carrying the + Access-Control-Request-Method header. Such requests are credential-less by + design and must reach CORSMiddleware to be answered -- gating them on auth + 401s the preflight and breaks every cross-origin browser/WebView client. + Pure so it can be unit-tested without standing up the app.""" + return method == "OPTIONS" and "access-control-request-method" in headers + + def require_admin(request: Request): """Raise 403 if the current user isn't an admin. Allows access when auth is explicitly disabled, or when the request carries diff --git a/tests/test_cors_preflight.py b/tests/test_cors_preflight.py new file mode 100644 index 000000000..24f69290b --- /dev/null +++ b/tests/test_cors_preflight.py @@ -0,0 +1,30 @@ +"""Regression test for the CORS-preflight auth bypass. + +AuthMiddleware is the outermost middleware, so it used to 401 the credential-less +OPTIONS preflight before CORSMiddleware could answer it -- which blocks every +cross-origin browser/WebView client before the real request is ever sent. The +fix lets a genuine preflight through; `is_cors_preflight` is the pure predicate +it uses. Guard it so the bypass can't silently regress. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.middleware import is_cors_preflight + + +def test_genuine_preflight_is_detected(): + assert is_cors_preflight("OPTIONS", {"access-control-request-method": "POST"}) is True + + +def test_bare_options_is_not_a_preflight(): + # OPTIONS without Access-Control-Request-Method must NOT bypass auth. + assert is_cors_preflight("OPTIONS", {}) is False + + +def test_real_methods_are_never_preflight(): + headers = {"access-control-request-method": "POST"} + for method in ("GET", "POST", "PUT", "DELETE", "PATCH"): + assert is_cors_preflight(method, headers) is False