Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.06.108"
__version__ = "26.06.109"
11 changes: 7 additions & 4 deletions src/pyfly/web/adapters/fastapi/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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
Expand Down
12 changes: 8 additions & 4 deletions src/pyfly/web/adapters/starlette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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()
Expand Down
55 changes: 55 additions & 0 deletions tests/web/test_cors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading