From e5e114b41e53b65db5daea5be8eb8cbbf687def1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Tue, 16 Jun 2026 17:26:21 +0200 Subject: [PATCH] fix(web): CORS middleware outermost so preflight bypasses the security gate (v26.06.109) The CORSMiddleware is now prepended ahead of the WebFilterChain (which holds the HttpSecurity gate) on both the Starlette and FastAPI adapters. Previously the filter chain wrapped CORS, so a browser OPTIONS preflight (no credentials) to a gated route was answered 401 without Access-Control-* headers and the browser blocked the real request ('Load failed'). Regression test added. --- CHANGELOG.md | 16 +++++++ pyproject.toml | 2 +- src/pyfly/__init__.py | 2 +- src/pyfly/web/adapters/fastapi/app.py | 11 +++-- src/pyfly/web/adapters/starlette/app.py | 12 ++++-- tests/web/test_cors.py | 55 +++++++++++++++++++++++++ uv.lock | 2 +- 7 files changed, 89 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc4962b9..794fe125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.109 (2026-06-16) + +### Fixed + +- **CORS preflight is no longer rejected by the security gate.** The + ``CORSMiddleware`` is now the **outermost** middleware (ahead of the + ``WebFilterChain`` that holds the ``HttpSecurity`` gate) on both the Starlette + and FastAPI adapters. Previously the filter chain wrapped CORS, so a browser + ``OPTIONS`` preflight (which carries no credentials) to a gated route was + answered with ``401`` and *without* ``Access-Control-*`` headers — the browser + then blocked the real request ("Load failed"/"Failed to fetch"). The preflight + is now answered by CORS before the gate runs, and ``Access-Control-*`` headers + are added to every response. + +--- + ## v26.06.108 (2026-06-16) ### Added diff --git a/pyproject.toml b/pyproject.toml index 1377ca97..bcd8aa05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.6.108" +version = "26.6.109" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 506ad617..514b941c 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.06.108" +__version__ = "26.06.109" diff --git a/src/pyfly/web/adapters/fastapi/app.py b/src/pyfly/web/adapters/fastapi/app.py index c60c26c1..1e3e166e 100644 --- a/src/pyfly/web/adapters/fastapi/app.py +++ b/src/pyfly/web/adapters/fastapi/app.py @@ -206,10 +206,6 @@ def _install_user_filters() -> None: _install_user_filters() - middleware: list[Middleware] = [ - Middleware(WebFilterChainMiddleware, filters=filters), - ] - # CORS auto-configuration (audit #204): build a CORSConfig from # ``pyfly.web.cors.*`` when none is passed, matching Spring's # CorsAutoConfiguration. Secure-by-default: disabled unless opted in. @@ -218,6 +214,12 @@ def _install_user_filters() -> None: cors = _CORSConfig.from_config(context.config) + # CORS middleware must be the OUTERMOST middleware (first in the list) so it + # answers the OPTIONS preflight and adds the Access-Control-* headers BEFORE + # the WebFilterChain (which holds the security gate) — otherwise the gate + # rejects the credential-less preflight with 401 and the browser blocks the + # real request. Starlette applies middleware outermost first. + middleware: list[Middleware] = [] if cors is not None: from starlette.middleware.cors import CORSMiddleware @@ -232,6 +234,7 @@ def _install_user_filters() -> None: max_age=cors.max_age, ) ) + middleware.append(Middleware(WebFilterChainMiddleware, filters=filters)) # Configure OpenAPI docs URLs (None disables them) docs_url = "/docs" if docs_enabled else None diff --git a/src/pyfly/web/adapters/starlette/app.py b/src/pyfly/web/adapters/starlette/app.py index c6799a6e..7c712ab7 100644 --- a/src/pyfly/web/adapters/starlette/app.py +++ b/src/pyfly/web/adapters/starlette/app.py @@ -230,10 +230,6 @@ def _install_user_filters() -> int: _install_user_filters() - middleware: list[Middleware] = [ - Middleware(WebFilterChainMiddleware, filters=filters), - ] - # CORS auto-configuration (audit #204): when no explicit CORSConfig is passed, # build one from ``pyfly.web.cors.*`` so CORS is enabled purely via YAML, like # Spring's CorsAutoConfiguration. Secure-by-default: disabled unless opted in. @@ -242,6 +238,13 @@ def _install_user_filters() -> int: cors = _CORSConfig.from_config(context.config) + # CORS middleware must be the OUTERMOST middleware (first in the list) so it + # answers the OPTIONS preflight itself — and adds the Access-Control-* headers + # — BEFORE the WebFilterChain (which holds the security gate). Otherwise the + # gate rejects the credential-less preflight with 401 and the browser blocks + # the real request ("Load failed"). Starlette applies middleware outermost + # first, so CORS is prepended ahead of WebFilterChainMiddleware. + middleware: list[Middleware] = [] if cors is not None: from starlette.middleware.cors import CORSMiddleware @@ -256,6 +259,7 @@ def _install_user_filters() -> int: max_age=cors.max_age, ) ) + middleware.append(Middleware(WebFilterChainMiddleware, filters=filters)) routes: list[Route] = [] registrar = ControllerRegistrar() diff --git a/tests/web/test_cors.py b/tests/web/test_cors.py index fe7ef768..2eb8f49e 100644 --- a/tests/web/test_cors.py +++ b/tests/web/test_cors.py @@ -145,6 +145,61 @@ def test_cors_simple_request(self): assert resp.headers["access-control-allow-origin"] == "http://example.com" +class TestCORSPreflightBypassesSecurityGate: + """A CORS preflight (OPTIONS, no credentials) must NOT be rejected by a + security-gate WebFilter: CORS middleware is the OUTERMOST middleware and + answers the preflight before the filter chain runs. Regression for the + browser "Load failed" when a 401-ing gate sat in front of CORS.""" + + def setup_method(self): + import contextlib + from collections.abc import AsyncIterator + from typing import Any + + from starlette.responses import PlainTextResponse + + from pyfly.container.ordering import HIGHEST_PRECEDENCE, order + from pyfly.context.application_context import ApplicationContext + from pyfly.core.config import Config + from pyfly.web.filters import OncePerRequestFilter + from pyfly.web.ports.filter import WebFilter + + @order(HIGHEST_PRECEDENCE + 350) + class _DenyAll(OncePerRequestFilter): + async def do_filter(self, request, call_next): # type: ignore[no-untyped-def] + return PlainTextResponse("denied", status_code=401) + + ctx = ApplicationContext(Config({})) + ctx.container.register_instance(WebFilter, _DenyAll(), name="deny_all") + + @contextlib.asynccontextmanager + async def _ls(app_: Any) -> AsyncIterator[None]: + await ctx.start() + app_.state.pyfly_install_dynamic_wiring() + yield + await ctx.stop() + + self.app = create_app( + title="test", + context=ctx, + cors=CORSConfig(allowed_origins=["http://example.com"], allowed_methods=["GET", "POST"]), + extra_routes=[HELLO_ROUTE], + lifespan=_ls, + ) + + def test_preflight_bypasses_gate(self): + with TestClient(self.app) as client: + # Preflight is answered by CORS (200 + ACAO), NOT 401'd by the gate. + pre = client.options( + "/hello", + headers={"Origin": "http://example.com", "Access-Control-Request-Method": "POST"}, + ) + assert pre.status_code == 200 + assert pre.headers["access-control-allow-origin"] == "http://example.com" + # The gate is still active for real requests (proves it IS wired). + assert client.get("/hello", headers={"Origin": "http://example.com"}).status_code == 401 + + class TestNoCORSWhenNotConfigured: """create_app() without cors param has no CORS headers.""" diff --git a/uv.lock b/uv.lock index 6a8a927d..9e785a10 100644 --- a/uv.lock +++ b/uv.lock @@ -2160,7 +2160,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.108" +version = "26.6.109" source = { editable = "." } dependencies = [ { name = "pydantic" },