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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion oxyroute/cors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: ["*"])
Expand Down
9 changes: 9 additions & 0 deletions src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Py<PyAny>>,
Expand All @@ -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)?;
Expand Down
83 changes: 83 additions & 0 deletions tests/test_cors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading