Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/+7600-header-invalid-utf8.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Websocket connections now accept request header values containing bytes that are not valid UTF-8.
8 changes: 6 additions & 2 deletions reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2103,7 +2103,8 @@ def _decode_asgi_headers(headers: Iterable[tuple[bytes, bytes]]) -> dict[str, st

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).
(RFC 9110 section 5.3). Header values that are not valid UTF-8 are decoded as
Latin-1.

Args:
headers: Raw (name, value) byte pairs from the ASGI scope.
Expand All @@ -2114,7 +2115,10 @@ 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()
value = raw_value.decode()
try:
value = raw_value.decode()
except UnicodeDecodeError:
value = raw_value.decode("latin-1")
if name in decoded:
decoded[name] += ("; " if name == "cookie" else ", ") + value
else:
Expand Down
32 changes: 32 additions & 0 deletions tests/units/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4479,6 +4479,8 @@ def test_decode_asgi_headers():
}
# Names and values are decoded as UTF-8.
assert _decode_asgi_headers([(b"x-name", "café".encode())]) == {"x-name": "café"}
# Invalid UTF-8 values fall back to Latin-1 instead of aborting the connect.
assert _decode_asgi_headers([(b"x-name", b"\xe9")]) == {"x-name": "\u00e9"}


def test_static_router_data_keeps_every_repeated_header_field():
Expand Down Expand Up @@ -5146,6 +5148,36 @@ async def test_on_connect_processes_boot_event_from_auth(
assert state.router_data[constants.RouteVar.CLIENT_TOKEN] == "abc"


@pytest.mark.asyncio
async def test_on_connect_accepts_non_utf8_header_value(
event_namespace: EventNamespace,
):
"""A non-UTF-8 request header value does not abort websocket connect.

Args:
event_namespace: The event namespace.
"""
event_namespace._token_manager = Mock()
event_namespace._token_manager.link_token_to_sid = AsyncMock(return_value=None)
event_namespace.on_event = AsyncMock()

await event_namespace.on_connect(
"new_sid",
{
"QUERY_STRING": "token=abc",
"asgi.scope": {"headers": [(b"x-name", b"\xe9")]},
},
{"event": {"name": "state.hydrate_and_load", "payload": {}}},
)

assert (
event_namespace._static_router_data["new_sid"][constants.RouteVar.HEADERS][
"x-name"
]
== "é"
)


def _schema_checking_namespace(
event_namespace: EventNamespace, backend_schema: str
) -> tuple[AsyncMock, AsyncMock]:
Expand Down
Loading