Skip to content

Repeated request headers are collapsed to their last value (cookie, x-forwarded-for, raw_headers) #7521

Closed

Description

@masenf

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 lost

Farhan 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 several cookie fields, and they must be joined with "; " (RFC 9113 §8.2.3).
  • router.session.client_ip: comes from x-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)

  1. In _build_static_router_data (reflex/app.py), keep every decoded (name, value) pair instead of folding them into a dict.
  2. Make the single-value view RFC-correct: join cookie values with "; " and every other name with ", ". That view feeds the typed HeaderData fields (cookie, user_agent, ...) and the x-forwarded-for → client_ip unrolling, which then takes the first hop from the joined value.
  3. Back HeaderData.raw_headers (reflex/istate/data.py, currently _FrozenDictStrStr) with FormData, so raw_headers.getlist(name) / multi_items() return every field in order.

Design points to settle:

  • Indexing semantics. FormData[key] returns the last value, but for headers raw_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. HeaderData is a frozen dataclass whose generated __hash__ covers raw_headers. _FrozenDictStrStr defines __hash__, while FormData sets __hash__ = None, so a direct swap would break hashing of HeaderData/RouterData.
  • Serialization. _FrozenDictStrStr has a @serializer(to=dict). A FormData-backed raw_headers needs 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 restores raw_headers correctly.
  • 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: two cookie fields → "sid=1; other=2"; two x-forwarded-for fields → client_ip is the first hop of the joined value; a repeated generic header → ", "-joined.
  • HeaderData.raw_headers.getlist(...) returns every value in order.
  • HeaderData stays hashable, serializes to the same frontend shape, and survives a pickle round-trip.

Activity

  1. linear-code commented on Oct 8, 2026

    @linear-code
  2. ybai08 commented on Oct 17, 2026

    @ybai08

    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_headers would join repeated names in order: cookie with "; " (RFC 9113 §8.2.3), and every other name with ", " (RFC 9110 §5.3). It would still return the dict[str, str] that _build_static_router_data and RouteVar.HEADERS carry 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 joined x-forwarded-for;
    • raw_headers[name], which shows the combined value.

    Unit tests on _decode_asgi_headers would cover duplicate cookie, duplicate x-forwarded-for and single fields. A _build_static_router_data test would cover client_ip, and a HeaderData.from_router_data test the typed fields. I'd also check that the credential filtering from #7360 still drops a repeated cookie entirely, which it does on the joined dict.

    PR 2: every field in raw_headers (step 3). My answers to the design points:

    • Indexing: a header-specific FormData subclass whose __getitem__ returns the RFC-joined value, so raw_headers[name] keeps today's meaning, while getlist() / multi_items() return the fields.
    • Hashing: that subclass defines __hash__ over its ordered items, so HeaderData and RouterData stay hashable.
    • Serialization: a @serializer(to=dict) that sends the joined values, so the frontend contract stays a string per header, minus _PRIVATE_HEADERS as now.
    • Pickling: FormData.__reduce__ already round-trips, and I'd add a pickle test plus a redis-manager restore test.
    • Carrying the fields: the open question is how HeaderData.from_router_data gets 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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions