Repository navigation
Repeated request headers are collapsed to their last value (cookie, x-forwarded-for, raw_headers) #7521
Description
Blocked on #7386, which adds the multi-value mapping (reflex_base.utils.form.FormData, exposed as rx.form.FormData) that this fix should reuse.
Reported by @FarhanAliRaza in #7360 (comment). The bug is already on main; #7360 doesn't cause it.
Problem
_decode_asgi_headers in reflex/app.py builds a dict with a comprehension, so when a header name repeats, the last field overwrites the earlier ones:
_decode_asgi_headers([(b"cookie", b"sid=1"), (b"cookie", b"other=2")])
# {'cookie': 'other=2'} <- sid is lostFarhan reproduced this with a raw websocket handshake carrying two cookie fields: self.router.headers.cookie was other=... and sid was gone.
Affected:
router.headers.cookie: incomplete on the server. HTTP/2 may split cookies across severalcookiefields, and they must be joined with"; "(RFC 9113 §8.2.3).router.session.client_ip: comes fromx-forwarded-for. When proxies add a second field, Reflex keeps only the last one, which loses the original client hop.router.headers.raw_headers: has no way to show that a header was sent more than once.
ASGI keeps duplicate headers as separate (name, value) tuples (spec), so the app has to combine them. Other repeated fields combine in order with ", " (RFC 9110 §5.3). Starlette does this: request.cookies merges every value from headers.getlist("cookie").
Proposed fix (once #7386 lands)
- In
_build_static_router_data(reflex/app.py), keep every decoded(name, value)pair instead of folding them into a dict. - Make the single-value view RFC-correct: join
cookievalues with"; "and every other name with", ". That view feeds the typedHeaderDatafields (cookie,user_agent, ...) and thex-forwarded-for→client_ipunrolling, which then takes the first hop from the joined value. - Back
HeaderData.raw_headers(reflex/istate/data.py, currently_FrozenDictStrStr) withFormData, soraw_headers.getlist(name)/multi_items()return every field in order.
Design points to settle:
- Indexing semantics.
FormData[key]returns the last value, but for headersraw_headers[key]should return the RFC-joined value. Either pass the joined pairs into the indexed view, or add a header-specific subclass/constructor that combines values on index. Plain last-wins would keep the bug. - Hashability.
HeaderDatais a frozen dataclass whose generated__hash__coversraw_headers._FrozenDictStrStrdefines__hash__, whileFormDatasets__hash__ = None, so a direct swap would break hashing ofHeaderData/RouterData. - Serialization.
_FrozenDictStrStrhas a@serializer(to=dict). AFormData-backedraw_headersneeds one too, and the frontend contract (State.router.headers.raw_headers["x"]→ a string) should stay the same: send the joined value, not a list. - Pickling / state persistence.
FormData.__reduce__already round-trips. Check that redis-backed state still restoresraw_headerscorrectly. - Interaction with Keep request credentials out of frontend router data #7360. Keep request credentials out of frontend router data #7360 strips
cookie(and, after review, other credential headers) from what reaches the frontend. Any filtering has to remove every field with that name, not just the last one.
Tests
- Duplicate tuples passed to
_build_static_router_data: twocookiefields →"sid=1; other=2"; twox-forwarded-forfields →client_ipis the first hop of the joined value; a repeated generic header →", "-joined. HeaderData.raw_headers.getlist(...)returns every value in order.HeaderDatastays hashable, serializes to the same frontend shape, and survives a pickle round-trip.
Activity
linear-code commentedon Oct 8, 2026
ENG-13232
ybai08 commentedon Oct 17, 2026
I'd like to take this now that #7386 has landed, unless someone on the team already has it. I'd split it into two PRs, so the data-loss fix doesn't wait on the type questions:
PR 1: combine repeated fields (steps 1 and 2).
_decode_asgi_headerswould join repeated names in order:cookiewith"; "(RFC 9113 §8.2.3), and every other name with", "(RFC 9110 §5.3). It would still return thedict[str, str]that_build_static_router_dataandRouteVar.HEADERScarry today, so nothing that reads router data changes shape. That fixes:router.headers.cookie, which then has every cookie;client_ip: the existing.partition(",")[0]then takes the first hop of the joinedx-forwarded-for;raw_headers[name], which shows the combined value.Unit tests on
_decode_asgi_headerswould cover duplicatecookie, duplicatex-forwarded-forand single fields. A_build_static_router_datatest would coverclient_ip, and aHeaderData.from_router_datatest the typed fields. I'd also check that the credential filtering from #7360 still drops a repeatedcookieentirely, which it does on the joined dict.PR 2: every field in
raw_headers(step 3). My answers to the design points:FormDatasubclass whose__getitem__returns the RFC-joined value, soraw_headers[name]keeps today's meaning, whilegetlist()/multi_items()return the fields.__hash__over its ordered items, soHeaderDataandRouterDatastay hashable.@serializer(to=dict)that sends the joined values, so the frontend contract stays a string per header, minus_PRIVATE_HEADERSas now.FormData.__reduce__already round-trips, and I'd add a pickle test plus a redis-manager restore test.HeaderData.from_router_datagets the fields. The router data only has the joined dict. I'd keep the decoded pairs next to it in the connection-scoped static router data under a private key that isn't sent to the frontend. Would you prefer a different place?Does that split work for you?
fix: combine repeated request headers instead of keeping the last (#7588