From 4daab000636268738eab23930831fba9046be8b5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Apr 2026 12:50:46 +0300 Subject: [PATCH 1/2] security(dispatch): reject CRLF in response headers and cookies Validate structured response headers and Set-Cookie lines at the framework boundary and fail safely instead of forwarding potentially injected header content. --- src/dispatch.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/dispatch.rs b/src/dispatch.rs index fec5c40..67529aa 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -44,6 +44,14 @@ fn oxyroute_debug() -> bool { .unwrap_or(false) } +fn contains_crlf(s: &str) -> bool { + s.contains('\r') || s.contains('\n') +} + +fn is_unsafe_cookie_line(s: &str) -> bool { + s.chars().any(|c| c == '\r' || c == '\n' || c.is_control()) +} + /// Map Python `PyErr` to HTTP 500 with a JSON body (no exception text unless `OXYROUTE_DEBUG=1`). async fn send_internal_error( protocol: &Py, @@ -98,6 +106,9 @@ async fn try_http_exception(protocol: &Py, err: &PyErr) -> PyResult()?; let k: String = pair.get_item(0)?.extract()?; let v: String = pair.get_item(1)?.extract()?; + if contains_crlf(&k) || contains_crlf(&v) { + return Ok(None); + } headers.push((k, v)); } Ok(Some((status, body, headers))) @@ -994,6 +1005,11 @@ fn structured_from_status_body( for (k, v) in d.iter() { let key: String = k.extract()?; let val: String = v.extract()?; + if contains_crlf(&key) || contains_crlf(&val) { + return Err(pyo3::exceptions::PyValueError::new_err( + "unsafe response header contains CR or LF", + )); + } if key.eq_ignore_ascii_case("content-type") { has_ct = true; } @@ -1008,6 +1024,11 @@ fn structured_from_status_body( if !c.is_none() { for item in c.downcast::()?.iter() { let s: String = item.extract()?; + if is_unsafe_cookie_line(&s) { + return Err(pyo3::exceptions::PyValueError::new_err( + "unsafe set-cookie value contains control characters", + )); + } pairs.push(("set-cookie".to_string(), s)); } } From d454305161b686b4e9844cc2b7a9458c36bc7dca Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Apr 2026 12:50:46 +0300 Subject: [PATCH 2/2] test(security): cover CRLF header and cookie sanitization paths Add regression tests that assert malicious header and cookie payloads are rejected with safe 500 responses, including HTTPException header payloads. --- tests/test_header_sanitization.py | 61 +++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/test_header_sanitization.py diff --git a/tests/test_header_sanitization.py b/tests/test_header_sanitization.py new file mode 100644 index 0000000..1f61261 --- /dev/null +++ b/tests/test_header_sanitization.py @@ -0,0 +1,61 @@ +"""Issue #72: reject unsafe CR/LF control chars in response headers/cookies.""" + +from __future__ import annotations + +import asyncio + +import httpx +from oxyroute import App, HTTPException, Response + + +def test_response_header_crlf_is_rejected_with_500() -> None: + app = App() + + @app.get("/h") + def h() -> Response: + return Response(status=200, body="ok", headers={"X-Bad\r\nInjected": "1"}) + + async def _run() -> None: + tr = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=tr, base_url="http://test") as c: + r = await c.get("/h") + assert r.status_code == 500 + assert r.headers.get("x-bad") is None + assert r.json().get("error") == "internal server error" + + asyncio.run(_run()) + + +def test_response_cookie_control_chars_is_rejected_with_500() -> None: + app = App() + + @app.get("/c") + def c() -> Response: + return Response(status=200, body="ok", cookies=["a=1\r\nX: y"]) + + async def _run() -> None: + tr = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=tr, base_url="http://test") as c: + r = await c.get("/c") + assert r.status_code == 500 + assert r.json().get("error") == "internal server error" + + asyncio.run(_run()) + + +def test_http_exception_header_crlf_is_safely_downgraded_to_500() -> None: + app = App() + + @app.get("/e") + def e() -> str: + raise HTTPException(400, "bad", headers={"X-Err\nInject": "1"}) + + async def _run() -> None: + tr = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=tr, base_url="http://test") as c: + r = await c.get("/e") + assert r.status_code == 500 + assert r.headers.get("x-err") is None + assert r.json().get("error") == "internal server error" + + asyncio.run(_run())