From f031eaa7b5e251ad0574b90c589ef8e616ceec31 Mon Sep 17 00:00:00 2001 From: ybai08 <271969568+ybai08@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:36:44 -0500 Subject: [PATCH 1/2] fix: combine repeated request headers instead of keeping the last _decode_asgi_headers built a dict from the ASGI header pairs, so a repeated field kept only its last value: router.headers.cookie lost every cookie but the last, and client_ip came from the last x-forwarded-for field instead of the first hop. Join repeated cookie fields with "; " and every other repeated field with ", ", in order. Refs #7521 --- news/+repeated-request-headers.bugfix.md | 1 + reflex/app.py | 14 ++++++++- tests/units/test_app.py | 40 ++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 news/+repeated-request-headers.bugfix.md diff --git a/news/+repeated-request-headers.bugfix.md b/news/+repeated-request-headers.bugfix.md new file mode 100644 index 00000000000..75388bfd97a --- /dev/null +++ b/news/+repeated-request-headers.bugfix.md @@ -0,0 +1 @@ +Repeated request headers are no longer collapsed to their last value: repeated `cookie` fields are joined with `"; "` and any other repeated field with `", "`, so `router.headers.cookie` keeps every cookie and `router.session.client_ip` is taken from the first `x-forwarded-for` hop. diff --git a/reflex/app.py b/reflex/app.py index b082a56d624..e5214025ab9 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -2095,13 +2095,25 @@ def _sio_loads(data: str | bytes, **kwargs: Any) -> Any: def _decode_asgi_headers(headers: Iterable[tuple[bytes, bytes]]) -> dict[str, str]: """Decode raw ASGI scope header pairs into a str-keyed dict. + ASGI keeps a repeated header as separate pairs, so their values are combined in + order: cookies with "; " (RFC 9113 section 8.2.3), any other field with ", " + (RFC 9110 section 5.3). + Args: headers: Raw (name, value) byte pairs from the ASGI scope. Returns: A dict mapping decoded header names to decoded values. """ - return {k.decode("utf-8"): v.decode("utf-8") for (k, v) in headers} + decoded: dict[str, str] = {} + for raw_name, raw_value in headers: + name = raw_name.decode("utf-8") + value = raw_value.decode("utf-8") + if name in decoded: + decoded[name] += ("; " if name == "cookie" else ", ") + value + else: + decoded[name] = value + return decoded class EventNamespace(AsyncNamespace): diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 6ef3ceef5cf..feda7d3b7ef 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -79,7 +79,7 @@ ) from reflex.compiler.plugins import default_page_plugins from reflex.environment import environment -from reflex.istate.data import RouterData, URLData +from reflex.istate.data import HeaderData, RouterData, URLData from reflex.istate.manager.disk import StateManagerDisk from reflex.istate.manager.memory import StateManagerMemory from reflex.istate.manager.redis import StateManagerRedis @@ -4463,15 +4463,49 @@ def test_decode_asgi_headers(): "host": "example.com", "x-forwarded-for": "10.0.0.1, 10.0.0.2", } - # Later duplicates win, matching dict comprehension semantics. + # Repeated cookie fields are joined with "; " (RFC 9113 section 8.2.3). assert _decode_asgi_headers([ (b"cookie", b"a=1"), (b"cookie", b"b=2"), - ]) == {"cookie": "b=2"} + ]) == {"cookie": "a=1; b=2"} + # Any other repeated field is joined in order with ", " (RFC 9110 section 5.3). + assert _decode_asgi_headers([ + (b"x-forwarded-for", b"10.0.0.1"), + (b"accept", b"text/html"), + (b"x-forwarded-for", b"10.0.0.2, 10.0.0.3"), + ]) == { + "x-forwarded-for": "10.0.0.1, 10.0.0.2, 10.0.0.3", + "accept": "text/html", + } # Names and values are decoded as UTF-8. assert _decode_asgi_headers([(b"x-name", "café".encode())]) == {"x-name": "café"} +def test_static_router_data_keeps_every_repeated_header_field(): + """Repeated cookie and x-forwarded-for fields all reach the router data.""" + router_data = EventNamespace("/event", Mock())._build_static_router_data( + "sid1", + { + "asgi.scope": { + "headers": [ + (b"cookie", b"sid=1"), + (b"x-forwarded-for", b"203.0.113.7"), + (b"cookie", b"other=2"), + (b"x-forwarded-for", b"10.0.0.2"), + ], + "client": ("127.0.0.1", 1234), + } + }, + ) + + headers = router_data[constants.RouteVar.HEADERS] + assert headers["cookie"] == "sid=1; other=2" + assert headers["x-forwarded-for"] == "203.0.113.7, 10.0.0.2" + # The original client hop comes first, not the proxy that added the second field. + assert router_data[constants.RouteVar.CLIENT_IP] == "203.0.113.7" + assert HeaderData.from_router_data(router_data).cookie == "sid=1; other=2" + + def test_call_ignores_stale_marker_without_dev_backend_reload( compilable_app: tuple[App, Path], mocker: MockerFixture, From 0dfb96c51ee41b74f6d551c86ef3515a45c24f01 Mon Sep 17 00:00:00 2001 From: Farhan Date: Mon, 19 Oct 2026 19:08:44 +0500 Subject: [PATCH 2/2] perf: decode ASGI headers with the default codec --- reflex/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reflex/app.py b/reflex/app.py index e5214025ab9..32786123961 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -2107,8 +2107,8 @@ def _decode_asgi_headers(headers: Iterable[tuple[bytes, bytes]]) -> dict[str, st """ decoded: dict[str, str] = {} for raw_name, raw_value in headers: - name = raw_name.decode("utf-8") - value = raw_value.decode("utf-8") + name = raw_name.decode() + value = raw_value.decode() if name in decoded: decoded[name] += ("; " if name == "cookie" else ", ") + value else: