Repository navigation
Conversation
_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 reflex-dev#7521
[Medium impact] The PR appears safe to merge. SummaryThe PR preserves repeated request headers instead of keeping only the last value.
Acknowledged scope: ybai08 explicitly deferred Reviews (1) · Last reviewed commit: "fix: combine repeated request headers in..." · Reviewed by Greptile |
There was a problem hiding this comment.
2 issues found across 3 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="reflex/app.py">
<violation number="1" location="reflex/app.py:2110">
P2: `name` is used case-sensitively even though ASGI does not require servers to lowercase header names. Normalize it before aggregation; otherwise repeated `Cookie` values use `", "` and mixed-case `X-Forwarded-For` is ignored.</violation>
<violation number="2" location="reflex/app.py:2113">
P2: Joining repeated `x-forwarded-for` lines in order changes which hop `CLIENT_IP` uses. Before, the last line won, so a proxy that adds its own `X-Forwarded-For` line (for example nginx `proxy_add_header`) overrode a client-sent one. Now the client-supplied first line becomes `CLIENT_IP`, so a client can spoof it. Confirm this trust change is intended, or keep proxy-added XFF lines authoritative.</violation>
</file>
Reply with feedback, questions, or to request a fix.
View guided diff | Re-trigger cubic
| name = raw_name.decode("utf-8") | ||
| value = raw_value.decode("utf-8") | ||
| if name in decoded: | ||
| decoded[name] += ("; " if name == "cookie" else ", ") + value |
There was a problem hiding this comment.
P2: Joining repeated x-forwarded-for lines in order changes which hop CLIENT_IP uses. Before, the last line won, so a proxy that adds its own X-Forwarded-For line (for example nginx proxy_add_header) overrode a client-sent one. Now the client-supplied first line becomes CLIENT_IP, so a client can spoof it. Confirm this trust change is intended, or keep proxy-added XFF lines authoritative.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At reflex/app.py, line 2113:
<comment>Joining repeated `x-forwarded-for` lines in order changes which hop `CLIENT_IP` uses. Before, the last line won, so a proxy that adds its own `X-Forwarded-For` line (for example nginx `proxy_add_header`) overrode a client-sent one. Now the client-supplied first line becomes `CLIENT_IP`, so a client can spoof it. Confirm this trust change is intended, or keep proxy-added XFF lines authoritative.</comment>
<file context>
@@ -2095,13 +2095,25 @@ def _sio_loads(data: str | bytes, **kwargs: Any) -> Any:
+ 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
</file context>
| 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") |
There was a problem hiding this comment.
P2: name is used case-sensitively even though ASGI does not require servers to lowercase header names. Normalize it before aggregation; otherwise repeated Cookie values use ", " and mixed-case X-Forwarded-For is ignored.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At reflex/app.py, line 2110:
<comment>`name` is used case-sensitively even though ASGI does not require servers to lowercase header names. Normalize it before aggregation; otherwise repeated `Cookie` values use `", "` and mixed-case `X-Forwarded-For` is ignored.</comment>
<file context>
@@ -2095,13 +2095,25 @@ def _sio_loads(data: str | bytes, **kwargs: Any) -> Any:
- 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:
</file context>
| name = raw_name.decode("utf-8") | |
| name = raw_name.decode("utf-8").lower() |
Refs #7521. This is part 1 of the split proposed on the issue: steps 1 and 2 of the fix. It leaves
raw_headersmulti-value access (getlist, step 3) to a follow-up PR._decode_asgi_headersbuilt a dict from the ASGI header pairs, so a repeated field kept only its last value. ASGI keeps repeats as separate pairs. The function now combines them in order:cookiefields are joined with"; "(RFC 9113 §8.2.3);", "(RFC 9110 §5.3).It still returns the same
dict[str, str], soRouteVar.HEADERSand everything that reads it keep their shape. With the combined values:router.headers.cookiehas every cookie;router.session.client_ipcomes from the firstx-forwarded-forhop, because the existing.partition(",")[0]now seesfirst, second;raw_headers[name]shows the combined value.The credential filtering from #7360 works on the joined dict, so a repeated
cookieis still dropped entirely from what reaches the frontend.Tests
test_decode_asgi_headers: the old "later duplicates win" assertion now expectsa=1; b=2. A new case interleaves twox-forwarded-forfields with another header and expects10.0.0.1, 10.0.0.2, 10.0.0.3.test_static_router_data_keeps_every_repeated_header_field(new) goes throughEventNamespace._build_static_router_datawith twocookieand twox-forwarded-forfields. It checks the joined headers, thatCLIENT_IPis the first hop (203.0.113.7, not the proxy), and thatHeaderData.from_router_data(...).cookiekeeps both cookies.Both fail on
mainwith the issue's symptom ({'cookie': 'b=2'},'other=2'). With the change, all oftests/units/test_app.pypasses (194).I also ran
uv run pytest tests/units --cov --no-cov-on-fail --cov-report=: 12014 passed, 26 skipped, total coverage 80.03%.ruff check,ruff format --checkandpyrighton the two changed files are clean (0 errors).Performance
The function now runs a loop instead of a comprehension. I measured that with
timeiton the benchmark's unique-header shape on macOS arm64. It's 1.10–1.14× slower: 0.27 vs 0.25 µs for 4 headers, 4.08 vs 3.57 µs for 64. It is only called from_build_static_router_data, once per connection, not per event.Not in this PR
Step 3: keeping every field in
raw_headersforgetlist()andmulti_items(). The open questions for it are on the issue.