Skip to content

Commit f96cef3

Browse files
fix: combine repeated request headers instead of keeping the last (#7588)
* 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 * perf: decode ASGI headers with the default codec --------- Co-authored-by: ybai08 <271969568+ybai08@users.noreply.github.com> Co-authored-by: Farhan <www.mfarvirus@gmail.com>
1 parent f8a3ee8 commit f96cef3

3 files changed

Lines changed: 51 additions & 4 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

‎reflex/app.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2101,13 +2101,25 @@ def _sio_loads(data: str | bytes, **kwargs: Any) -> Any:
21012101
def _decode_asgi_headers(headers: Iterable[tuple[bytes, bytes]]) -> dict[str, str]:
21022102
"""Decode raw ASGI scope header pairs into a str-keyed dict.
21032103
2104+
ASGI keeps a repeated header as separate pairs, so their values are combined in
2105+
order: cookies with "; " (RFC 9113 section 8.2.3), any other field with ", "
2106+
(RFC 9110 section 5.3).
2107+
21042108
Args:
21052109
headers: Raw (name, value) byte pairs from the ASGI scope.
21062110
21072111
Returns:
21082112
A dict mapping decoded header names to decoded values.
21092113
"""
2110-
return {k.decode("utf-8"): v.decode("utf-8") for (k, v) in headers}
2114+
decoded: dict[str, str] = {}
2115+
for raw_name, raw_value in headers:
2116+
name = raw_name.decode()
2117+
value = raw_value.decode()
2118+
if name in decoded:
2119+
decoded[name] += ("; " if name == "cookie" else ", ") + value
2120+
else:
2121+
decoded[name] = value
2122+
return decoded
21112123

21122124

21132125
class EventNamespace(AsyncNamespace):

‎tests/units/test_app.py‎

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979
)
8080
from reflex.compiler.plugins import default_page_plugins
8181
from reflex.environment import environment
82-
from reflex.istate.data import RouterData, URLData
82+
from reflex.istate.data import HeaderData, RouterData, URLData
8383
from reflex.istate.manager.disk import StateManagerDisk
8484
from reflex.istate.manager.memory import StateManagerMemory
8585
from reflex.istate.manager.redis import StateManagerRedis
@@ -4463,15 +4463,49 @@ def test_decode_asgi_headers():
44634463
"host": "example.com",
44644464
"x-forwarded-for": "10.0.0.1, 10.0.0.2",
44654465
}
4466-
# Later duplicates win, matching dict comprehension semantics.
4466+
# Repeated cookie fields are joined with "; " (RFC 9113 section 8.2.3).
44674467
assert _decode_asgi_headers([
44684468
(b"cookie", b"a=1"),
44694469
(b"cookie", b"b=2"),
4470-
]) == {"cookie": "b=2"}
4470+
]) == {"cookie": "a=1; b=2"}
4471+
# Any other repeated field is joined in order with ", " (RFC 9110 section 5.3).
4472+
assert _decode_asgi_headers([
4473+
(b"x-forwarded-for", b"10.0.0.1"),
4474+
(b"accept", b"text/html"),
4475+
(b"x-forwarded-for", b"10.0.0.2, 10.0.0.3"),
4476+
]) == {
4477+
"x-forwarded-for": "10.0.0.1, 10.0.0.2, 10.0.0.3",
4478+
"accept": "text/html",
4479+
}
44714480
# Names and values are decoded as UTF-8.
44724481
assert _decode_asgi_headers([(b"x-name", "café".encode())]) == {"x-name": "café"}
44734482

44744483

4484+
def test_static_router_data_keeps_every_repeated_header_field():
4485+
"""Repeated cookie and x-forwarded-for fields all reach the router data."""
4486+
router_data = EventNamespace("/event", Mock())._build_static_router_data(
4487+
"sid1",
4488+
{
4489+
"asgi.scope": {
4490+
"headers": [
4491+
(b"cookie", b"sid=1"),
4492+
(b"x-forwarded-for", b"203.0.113.7"),
4493+
(b"cookie", b"other=2"),
4494+
(b"x-forwarded-for", b"10.0.0.2"),
4495+
],
4496+
"client": ("127.0.0.1", 1234),
4497+
}
4498+
},
4499+
)
4500+
4501+
headers = router_data[constants.RouteVar.HEADERS]
4502+
assert headers["cookie"] == "sid=1; other=2"
4503+
assert headers["x-forwarded-for"] == "203.0.113.7, 10.0.0.2"
4504+
# The original client hop comes first, not the proxy that added the second field.
4505+
assert router_data[constants.RouteVar.CLIENT_IP] == "203.0.113.7"
4506+
assert HeaderData.from_router_data(router_data).cookie == "sid=1; other=2"
4507+
4508+
44754509
def test_call_ignores_stale_marker_without_dev_backend_reload(
44764510
compilable_app: tuple[App, Path],
44774511
mocker: MockerFixture,

0 commit comments

Comments
 (0)