diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ab4491..6b57b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `:param` strings in `openapi_json()`). - JWT hot path reuses prebuilt `DecodingKey` and `Validation` per route (no per-request rebuild) ([#109](https://github.com/QueryaHub/OxyRoute/issues/109)). +- CORS response merge skips the Python `response_header_pairs` call when the request has + no `Origin` header ([#108](https://github.com/QueryaHub/OxyRoute/issues/108)). ## [0.3.0] - 2026-04-27 diff --git a/oxyroute/cors.py b/oxyroute/cors.py index a77d200..c119cea 100644 --- a/oxyroute/cors.py +++ b/oxyroute/cors.py @@ -22,7 +22,8 @@ class CORSConfig: response header merging without the built-in ``OPTIONS`` handler. ``response_header_pairs`` is called from Rust to merge CORS headers into normal responses - (after a route or middleware that returns a body). + (after a route or middleware that returns a body). The native layer skips this call when + the request has no ``Origin`` header (same outcome as an empty pair list). """ allow_origins: list[str] = field(default_factory=lambda: ["*"]) diff --git a/src/dispatch.rs b/src/dispatch.rs index 95929bd..15e3502 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -1502,6 +1502,9 @@ fn send_handler_map_inline( /// `if_absent`: only add a header if no same-name (case-insensitive) header is already present /// (``security`` preset). `false` replaces/merges like CORS (``replace`` / duplicate header names). +/// +/// For CORS (`if_absent == false`), skip the Python `response_header_pairs` call when the +/// request has no ``Origin`` header (issue #108) — same outcome as an empty pair list. fn merge_config_response_headers( py: Python<'_>, config: &Option>, @@ -1512,6 +1515,12 @@ fn merge_config_response_headers( let Some(c) = config else { return Ok(mapped); }; + if !if_absent { + let headers = scope.getattr("headers")?; + if header_get_lax(&headers, "origin").is_none() { + return Ok(mapped); + } + } let pairs: Vec<(String, String)> = c .call_method1(py, "response_header_pairs", (&scope,))? .extract(py)?; diff --git a/tests/test_cors.py b/tests/test_cors.py index 3089e6e..739168b 100644 --- a/tests/test_cors.py +++ b/tests/test_cors.py @@ -57,3 +57,86 @@ async def _run() -> None: assert r.headers.get("access-control-allow-origin") == "https://a.example" asyncio.run(_run()) + + +class _CountingCors(CORSConfig): + """Tracks ``response_header_pairs`` calls from the native layer (issue #108).""" + + pairs_calls: int + + def __init__(self, **kwargs: object) -> None: + super().__init__(**kwargs) # type: ignore[arg-type] + self.pairs_calls = 0 + + def response_header_pairs(self, scope: object) -> list[tuple[str, str]]: + self.pairs_calls += 1 + return super().response_header_pairs(scope) + + +def test_cors_without_origin_skips_python_pairs_call() -> None: + cfg = _CountingCors(allow_origins=["https://app.example"]) + app = App() + apply_cors(app, cfg) + + @app.get("/n") + def _n() -> str: + return "ok" + + async def _run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/n") + assert r.status_code == 200 + assert r.text == "ok" + assert r.headers.get("access-control-allow-origin") is None + + asyncio.run(_run()) + assert cfg.pairs_calls == 0 + + +def test_cors_wildcard_without_origin_skips_pairs_with_origin_calls() -> None: + cfg = _CountingCors(allow_origins=["*"], allow_credentials=False) + app = App() + apply_cors(app, cfg) + + @app.get("/w") + def _w() -> str: + return "w" + + async def _run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + bare = await c.get("/w") + starred = await c.get("/w", headers={"origin": "https://any.example"}) + assert bare.status_code == 200 + assert bare.headers.get("access-control-allow-origin") is None + assert starred.status_code == 200 + assert starred.headers.get("access-control-allow-origin") == "*" + + asyncio.run(_run()) + assert cfg.pairs_calls == 1 + + +def test_cors_credentials_with_origin_still_merges() -> None: + cfg = _CountingCors( + allow_origins=["https://app.example"], + allow_credentials=True, + ) + app = App() + apply_cors(app, cfg) + + @app.get("/c") + def _c() -> str: + return "c" + + async def _run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + no_o = await c.get("/c") + with_o = await c.get("/c", headers={"origin": "https://app.example"}) + assert no_o.headers.get("access-control-allow-origin") is None + assert with_o.headers.get("access-control-allow-origin") == "https://app.example" + assert with_o.headers.get("access-control-allow-credentials") == "true" + + asyncio.run(_run()) + assert cfg.pairs_calls == 1