diff --git a/CHANGELOG.md b/CHANGELOG.md index fc4962b..794fe12 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 1377ca9..bcd8aa0 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 506ad61..514b941 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 c60c26c..1e3e166 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 c6799a6..7c712ab 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 fe7ef76..2eb8f49 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 6a8a927..9e785a1 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" },