From bb226e649cd916ab8405f3b8d048abf493713c2a Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 29 Sep 2026 17:07:46 -0700 Subject: [PATCH 1/4] Keep request cookies out of frontend state Preserve server-side cookie access while omitting Cookie from serialized router headers and raw_headers. Deprecate frontend router cookie reads in favor of rx.Cookie. Tests: 411 related unit tests passed; regression tests reproduced the leak before the fix. Ruff, formatting, and targeted pyright passed. Refs: ENG-12909 --- docs/utility_methods/router_attributes.md | 10 ++--- news/+router-cookie-privacy.bugfix.md | 1 + news/+router-cookie-privacy.deprecation.md | 1 + reflex/istate/data.py | 41 ++++++++++++++++-- tests/units/istate/test_data.py | 48 +++++++++++++++++++++- tests/units/test_state.py | 30 ++++++++++---- tests/units/utils/test_format.py | 1 - 7 files changed, 114 insertions(+), 18 deletions(-) create mode 100644 news/+router-cookie-privacy.bugfix.md create mode 100644 news/+router-cookie-privacy.deprecation.md diff --git a/docs/utility_methods/router_attributes.md b/docs/utility_methods/router_attributes.md index d7185d0801f..10519871362 100644 --- a/docs/utility_methods/router_attributes.md +++ b/docs/utility_methods/router_attributes.md @@ -54,10 +54,6 @@ router_data = [ "name": "rx.State.router.headers.connection", "value": RouterState.router.headers.connection, }, - { - "name": "rx.State.router.headers.cookie", - "value": RouterState.router.headers.cookie, - }, { "name": "rx.State.router.headers.pragma", "value": RouterState.router.headers.pragma, @@ -119,7 +115,7 @@ The `self.router` attribute has several sub-attributes that provide various info - `origin`: The origin of the request. - `upgrade`: The upgrade header for websocket connections. - `connection`: The connection header. - - `cookie`: The cookie header. + - `cookie`: The cookie header, available only on the server through `self.router.headers.cookie`. - `pragma`: The pragma header. - `cache_control`: The cache control header. - `user_agent`: The user agent string of the client. @@ -128,7 +124,9 @@ The `self.router` attribute has several sub-attributes that provide various info - `sec_websocket_extensions`: The websocket extensions. - `accept_encoding`: The accepted encodings. - `accept_language`: The accepted languages. - - `raw_headers`: A mapping of all HTTP headers as a frozen dictionary. This provides access to any header that was sent with the request, not just the common ones listed above. + - `raw_headers`: A mapping of HTTP headers as a frozen dictionary. The server can access every header; the cookie header is omitted when this mapping is sent to the frontend. + +Request cookies, including `HttpOnly` cookies, are never included in router data sent to the frontend. Using `State.router.headers.cookie` in a component is deprecated and renders an empty string. Use [rx.Cookie](/docs/client-storage/overview/) for cookies that need to be accessible to the frontend. Server-side access through `self.router.headers.cookie` is unchanged. ## URL Attributes diff --git a/news/+router-cookie-privacy.bugfix.md b/news/+router-cookie-privacy.bugfix.md new file mode 100644 index 00000000000..f2e2b05df5d --- /dev/null +++ b/news/+router-cookie-privacy.bugfix.md @@ -0,0 +1 @@ +Stop sending request cookies, including HttpOnly cookies, to the frontend in router headers and raw headers. Server-side access through `self.router.headers.cookie` is unchanged. diff --git a/news/+router-cookie-privacy.deprecation.md b/news/+router-cookie-privacy.deprecation.md new file mode 100644 index 00000000000..1aa0272b923 --- /dev/null +++ b/news/+router-cookie-privacy.deprecation.md @@ -0,0 +1 @@ +Deprecate `State.router.headers.cookie` in components; it now renders an empty string. Use `rx.Cookie` for cookies that need to be accessible to the frontend. diff --git a/reflex/istate/data.py b/reflex/istate/data.py index 8b3b245d0b4..fd4838d64fc 100644 --- a/reflex/istate/data.py +++ b/reflex/istate/data.py @@ -19,7 +19,7 @@ cached_property_no_lock, ) from reflex_base.vars.object import ObjectItemOperation, ObjectVar -from reflex_base.vars.sequence import StringVar +from reflex_base.vars.sequence import LiteralStringVar, StringVar @dataclasses.dataclass(frozen=True, init=False) @@ -109,7 +109,42 @@ def from_router_data(cls, router_data: dict) -> "HeaderData": @serializer(to=dict) def _serialize_header_data(obj: HeaderData) -> dict: - return {k.name: getattr(obj, k.name) for k in dataclasses.fields(obj)} + """Serialize request headers without exposing cookies to the frontend. + + Args: + obj: The headers to serialize. + + Returns: + The headers with cookies omitted from both representations. + """ + headers = { + field.name: getattr(obj, field.name) + for field in dataclasses.fields(obj) + if field.name != "cookie" + } + headers["raw_headers"] = { + key: value for key, value in obj.raw_headers.items() if key.lower() != "cookie" + } + return headers + + +class _HeaderDataVar(ObjectVar[HeaderData], python_types=HeaderData): + """Frontend headers with deprecated cookie access.""" + + @property + def cookie(self) -> StringVar: + """Keep deprecated frontend cookie access safe to render. + + Returns: + An empty string; request cookies are available only on the server. + """ + console.deprecate( + feature_name="State.router.headers.cookie", + reason="Request cookies are no longer sent to the frontend. Use rx.Cookie for client-side cookies instead.", + deprecation_version="0.9.13", + removal_version="1.0", + ) + return LiteralStringVar.create("") @serializer(to=dict) @@ -717,7 +752,7 @@ def headers(self) -> ObjectVar[HeaderData]: Returns: ObjectVar for the ``rx_router_headers`` base var. """ - return self._headers_var.to(ObjectVar, HeaderData) + return self._headers_var.to(_HeaderDataVar, HeaderData) @property def page(self) -> ObjectVar[PageData]: diff --git a/tests/units/istate/test_data.py b/tests/units/istate/test_data.py index aabb52a162a..51cd71e2363 100644 --- a/tests/units/istate/test_data.py +++ b/tests/units/istate/test_data.py @@ -1,20 +1,66 @@ """Tests for ReflexURL parsing, serialization, and Var attribute access.""" +import json +import pickle from collections.abc import Mapping from typing import cast from urllib.parse import parse_qsl import pytest +from pytest_mock import MockerFixture from reflex_base import constants +from reflex_base.utils.format import json_dumps from reflex_base.vars.object import ObjectVar from reflex_base.vars.sequence import StringVar import reflex as rx -from reflex.istate.data import HeaderData, ReflexURL, ReflexURLCastedVar +from reflex.istate.data import HeaderData, ReflexURL, ReflexURLCastedVar, RouterData SAMPLE_URL = "https://example.com:3000/posts/123?tab=comments&sort=new#top" +@pytest.mark.parametrize("cookie_header", ["cookie", "Cookie", "COOKIE"]) +def test_header_data_does_not_serialize_cookies(cookie_header: str): + """Cookie headers stay server-side, including in nested router payloads. + + Args: + cookie_header: The case variant of the HTTP cookie header. + """ + router = RouterData.from_router_data({ + constants.RouteVar.HEADERS: { + cookie_header: "session=secret", + "x-custom-header": "public", + }, + }) + + payload = json.loads(json_dumps(router)) + + assert "cookie" not in payload["headers"] + assert payload["headers"]["raw_headers"] == {"x-custom-header": "public"} + assert "secret" not in json_dumps(router.headers) + assert router.headers.cookie == "session=secret" + assert router.headers.raw_headers[cookie_header] == "session=secret" + restored = pickle.loads(pickle.dumps(router)) + assert restored.headers == router.headers + + +def test_router_cookie_var_is_deprecated(mocker: MockerFixture): + """Frontend cookie access warns and has an empty string fallback. + + Args: + mocker: Mock fixture. + """ + deprecate = mocker.patch("reflex.istate.data.console.deprecate") + + assert str(rx.State.router.headers.cookie) == '""' + deprecate.assert_called_once() + assert "rx.Cookie" in deprecate.call_args.kwargs["reason"] + deprecate.reset_mock() + + assert HeaderData(cookie="session=secret").cookie == "session=secret" + deprecate.assert_not_called() + + def test_reflex_url_parses_components(): url = ReflexURL(SAMPLE_URL) assert str(url) == SAMPLE_URL diff --git a/tests/units/test_state.py b/tests/units/test_state.py index a5f347d94d5..6e6d42f3816 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -61,7 +61,7 @@ URLData, _FrozenDictStrStr, ) -from reflex.istate.delta import _suppress_delta_recording +from reflex.istate.delta import Delta, _suppress_delta_recording from reflex.istate.manager import StateManager from reflex.istate.manager.disk import StateManagerDisk from reflex.istate.manager.memory import StateManagerMemory @@ -70,7 +70,6 @@ from reflex.istate.proxy import MutableProxy, StateProxy from reflex.state import ( BaseState, - Delta, ImmutableStateError, OnLoadInternalState, State, @@ -120,7 +119,6 @@ "origin": "", "upgrade": "", "connection": "", - "cookie": "", "pragma": "", "cache_control": "", "user_agent": "", @@ -324,6 +322,27 @@ def test_state() -> TestState: return TestState() # pyright: ignore [reportCallIssue] +@pytest.mark.parametrize("method", ["dict", "get_delta"]) +def test_router_cookies_not_sent_to_frontend(test_state: TestState, method: str): + """Initial state and deltas omit cookies while preserving server access. + + Args: + test_state: A state. + method: The state serialization entry point. + """ + test_state.router = RouterData.from_router_data({ + RouteVar.HEADERS: {"cookie": "session=secret", "user-agent": "browser"}, + }) + + payload = json.loads(json_dumps(getattr(test_state, method)())) + headers = payload[test_state.get_full_name()]["rx_router_headers" + FIELD_MARKER] + + assert "cookie" not in headers + assert headers["raw_headers"] == {"user-agent": "browser"} + assert "secret" not in json_dumps(payload) + assert test_state.router.headers.cookie == "session=secret" + + @pytest.fixture def child_state(test_state) -> ChildState: """A child state. @@ -4462,12 +4481,9 @@ def test_router_var_dep_does_not_warn_for_the_var_form( """ # `console.deprecate` logs and dedupes rather than printing, so record the # calls instead of scraping output. - from reflex import state as state_module - deprecations: list[str] = [] monkeypatch.setattr( - state_module.console, - "deprecate", + "reflex.state.console.deprecate", lambda *, feature_name, **kwargs: deprecations.append(feature_name), ) diff --git a/tests/units/utils/test_format.py b/tests/units/utils/test_format.py index 3b89da9c455..f8ea505bfd3 100644 --- a/tests/units/utils/test_format.py +++ b/tests/units/utils/test_format.py @@ -679,7 +679,6 @@ def test_format_query_params(input, output): "origin": "", "upgrade": "", "connection": "", - "cookie": "", "pragma": "", "cache_control": "", "user_agent": "", From 7a311193dd4b22ea8be4179a4554b2b2c1e5eaec Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 8 Oct 2026 10:51:55 -0700 Subject: [PATCH 2/4] Close credential leaks in router updates and frontend events Apply the deprecated empty-string fallback to indexed cookie access. Strip server-only router metadata from outbound events, and omit standard authorization and Cloudflare/OAuth2 Proxy token headers from frontend header serialization. Preserve backend access and document missing raw header keys. Regression tests reproduced indexed access failures, HttpOnly cookie exposure in on-load events, and authorization header exposure in dev and production WebSocket frames before the fixes. --- docs/utility_methods/router_attributes.md | 4 +- news/+router-cookie-privacy.bugfix.md | 2 +- news/+router-cookie-privacy.deprecation.md | 2 +- reflex/istate/data.py | 40 ++++-- reflex/state.py | 17 ++- .../tests_playwright/test_router_query.py | 114 +++++++++++++++++- tests/units/istate/test_data.py | 43 +++++-- tests/units/test_state.py | 27 +++++ 8 files changed, 226 insertions(+), 23 deletions(-) diff --git a/docs/utility_methods/router_attributes.md b/docs/utility_methods/router_attributes.md index 10519871362..c8f140149a7 100644 --- a/docs/utility_methods/router_attributes.md +++ b/docs/utility_methods/router_attributes.md @@ -124,9 +124,9 @@ The `self.router` attribute has several sub-attributes that provide various info - `sec_websocket_extensions`: The websocket extensions. - `accept_encoding`: The accepted encodings. - `accept_language`: The accepted languages. - - `raw_headers`: A mapping of HTTP headers as a frozen dictionary. The server can access every header; the cookie header is omitted when this mapping is sent to the frontend. + - `raw_headers`: A mapping of HTTP headers as a frozen dictionary. The server can access every header. Frontend serialization omits `Cookie`, `Authorization`, `Proxy-Authorization`, `Cf-Access-Jwt-Assertion`, `X-Auth-Request-Access-Token`, and `X-Forwarded-Access-Token`, regardless of case. -Request cookies, including `HttpOnly` cookies, are never included in router data sent to the frontend. Using `State.router.headers.cookie` in a component is deprecated and renders an empty string. Use [rx.Cookie](/docs/client-storage/overview/) for cookies that need to be accessible to the frontend. Server-side access through `self.router.headers.cookie` is unchanged. +Request cookies, including `HttpOnly` cookies, are never included in router data sent to the frontend. Outbound events retain only navigation metadata; their request headers stay on the server. Using `State.router.headers.cookie` or `State.router.headers["cookie"]` in a component is deprecated and renders an empty string. Filtered keys in `State.router.headers.raw_headers`, including `["cookie"]`, are absent and indexed access evaluates to JavaScript `undefined`; use `.get("cookie", "")` for an empty string fallback. Use [rx.Cookie](/docs/client-storage/overview/) for cookies that need to be accessible to the frontend. Server-side access through `self.router.headers.cookie` is unchanged. ## URL Attributes diff --git a/news/+router-cookie-privacy.bugfix.md b/news/+router-cookie-privacy.bugfix.md index f2e2b05df5d..c7bda1c4959 100644 --- a/news/+router-cookie-privacy.bugfix.md +++ b/news/+router-cookie-privacy.bugfix.md @@ -1 +1 @@ -Stop sending request cookies, including HttpOnly cookies, to the frontend in router headers and raw headers. Server-side access through `self.router.headers.cookie` is unchanged. +Stop sending request cookies, including HttpOnly cookies, and standard authorization, Cloudflare Access, and OAuth2 Proxy credential headers to frontend router data. Outbound events include only navigation metadata; server-side access to request headers is unchanged. diff --git a/news/+router-cookie-privacy.deprecation.md b/news/+router-cookie-privacy.deprecation.md index 1aa0272b923..2cfc9f9768f 100644 --- a/news/+router-cookie-privacy.deprecation.md +++ b/news/+router-cookie-privacy.deprecation.md @@ -1 +1 @@ -Deprecate `State.router.headers.cookie` in components; it now renders an empty string. Use `rx.Cookie` for cookies that need to be accessible to the frontend. +Deprecate `State.router.headers.cookie` and `State.router.headers["cookie"]` in components; both now render an empty string. Use `rx.Cookie` for cookies that need to be accessible to the frontend. diff --git a/reflex/istate/data.py b/reflex/istate/data.py index fd4838d64fc..38bd7924ff6 100644 --- a/reflex/istate/data.py +++ b/reflex/istate/data.py @@ -3,7 +3,7 @@ import dataclasses from collections.abc import Callable, Mapping from types import MappingProxyType -from typing import TYPE_CHECKING, Any, ClassVar, Final, NoReturn +from typing import TYPE_CHECKING, Any, ClassVar, Final, NoReturn, cast from urllib.parse import _NetlocResultMixinStr, parse_qsl, urlsplit from reflex_base import constants @@ -77,6 +77,15 @@ class _HeaderData: field.name for field in dataclasses.fields(_HeaderData) ]) +_PRIVATE_HEADERS = frozenset({ + "cookie", + "authorization", + "proxy-authorization", + "cf-access-jwt-assertion", + "x-auth-request-access-token", + "x-forwarded-access-token", +}) + @dataclasses.dataclass(frozen=True) class HeaderData(_HeaderData): @@ -109,13 +118,13 @@ def from_router_data(cls, router_data: dict) -> "HeaderData": @serializer(to=dict) def _serialize_header_data(obj: HeaderData) -> dict: - """Serialize request headers without exposing cookies to the frontend. + """Serialize request headers without exposing credentials to the frontend. Args: obj: The headers to serialize. Returns: - The headers with cookies omitted from both representations. + The headers with credentials omitted from both representations. """ headers = { field.name: getattr(obj, field.name) @@ -123,7 +132,9 @@ def _serialize_header_data(obj: HeaderData) -> dict: if field.name != "cookie" } headers["raw_headers"] = { - key: value for key, value in obj.raw_headers.items() if key.lower() != "cookie" + key: value + for key, value in obj.raw_headers.items() + if key.lower() not in _PRIVATE_HEADERS } return headers @@ -131,6 +142,21 @@ def _serialize_header_data(obj: HeaderData) -> dict: class _HeaderDataVar(ObjectVar[HeaderData], python_types=HeaderData): """Frontend headers with deprecated cookie access.""" + def __getitem__(self, key: Var | Any) -> Var: + """Resolve indexed cookie access through the deprecated fallback. + + Args: + key: The requested header field. + + Returns: + The cookie fallback or the requested frontend header field. + """ + if (isinstance(key, str) and key == "cookie") or ( + isinstance(key, LiteralStringVar) and key._var_value == "cookie" + ): + return self.cookie + return ObjectVar.__getitem__(cast(ObjectVar[Any], self), key) + @property def cookie(self) -> StringVar: """Keep deprecated frontend cookie access safe to render. @@ -746,13 +772,13 @@ def session(self) -> ObjectVar[SessionData]: return self._session_var.to(ObjectVar, SessionData) @property - def headers(self) -> ObjectVar[HeaderData]: + def headers(self) -> _HeaderDataVar: """The headers of the websocket connection request. Returns: - ObjectVar for the ``rx_router_headers`` base var. + Header Var for the ``rx_router_headers`` base var. """ - return self._headers_var.to(_HeaderDataVar, HeaderData) + return cast(_HeaderDataVar, self._headers_var.to(_HeaderDataVar, HeaderData)) @property def page(self) -> ObjectVar[PageData]: diff --git a/reflex/state.py b/reflex/state.py index 4d9c1e4a7b3..6ef5d760d1b 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -2940,9 +2940,24 @@ def serialize_state_update(update: StateUpdate) -> dict: Returns: The serialized StateUpdate. """ - return { + payload = { name: value for name in _STATE_UPDATE_FIELDS if (value := getattr(update, name)) } + if update.events: + payload["events"] = [ + dataclasses.replace( + event, + router_data={ + key: value + for key, value in event.router_data.items() + if key in constants.ROUTER_DATA_INCLUDE + }, + ) + if event.router_data + else event + for event in update.events + ] + return payload def code_uses_state_contexts(javascript_code: str) -> bool: diff --git a/tests/integration/tests_playwright/test_router_query.py b/tests/integration/tests_playwright/test_router_query.py index 96a70c27d34..f2dda877682 100644 --- a/tests/integration/tests_playwright/test_router_query.py +++ b/tests/integration/tests_playwright/test_router_query.py @@ -21,9 +21,11 @@ import re from collections.abc import Generator +from urllib.parse import urlsplit import pytest from playwright.sync_api import Page, expect +from reflex_base.config import get_config from reflex_base.constants.state import FIELD_MARKER from socketio.packet import Packet @@ -46,6 +48,7 @@ class RouterQueryState(rx.State): # Incremented by an explicit, non-navigation event used to flush the # next round-trip to the backend. ping_count: int = 0 + credentials_seen: bool = False @rx.var def query_str(self) -> str: @@ -88,6 +91,19 @@ def replace_via_script(self, query: str): def ping(self): """Send an explicit, non-navigation event to the backend.""" self.ping_count += 1 + self.credentials_seen = ( + "privacy-test=" in self.router.headers.cookie + and self.router.headers.raw_headers.get("cookie") + == self.router.headers.cookie + and self.router.headers.raw_headers.get("authorization") + == "Basic AUTHSECRET" + and self.router.headers.raw_headers.get("cf-access-jwt-assertion") + == "CFJWTSECRET" + and self.router.headers.raw_headers.get("x-auth-request-access-token") + == "OAUTHSECRET" + and self.router.headers.raw_headers.get("x-forwarded-access-token") + == "FORWARDEDSECRET" + ) @rx.event def do_redirect(self, target: str): @@ -160,10 +176,36 @@ def index(): read_only=True, id="ping-count", ), + rx.input( + value=f"{RouterQueryState.credentials_seen}", + read_only=True, + id="credentials-seen", + ), + rx.input( + value=RouterQueryState.router.headers.cookie, + read_only=True, + id="cookie-attribute", + ), + rx.input( + value=RouterQueryState.router + .headers["cookie"] + .to(str) + .contains("privacy-test") + .to_string(), + read_only=True, + id="cookie-item", + ), ) app = rx.App() - app.add_page(index, route="/", on_load=RouterQueryState.on_load) + app.add_page( + index, + route="/", + on_load=[ + RouterQueryState.on_load, + rx.call_script("window.__privacy_on_load = true"), + ], + ) @pytest.fixture(scope="module") @@ -207,6 +249,7 @@ def _load(harness: AppHarness, page: Page) -> str: expect(page.locator("#token")).not_to_have_value("") # The initial page load fires on_load exactly once. expect(page.locator("#load-count")).to_have_value("1") + page.wait_for_function("window.__privacy_on_load === true") return base @@ -248,6 +291,75 @@ def test_initial_connection_dispatches_both_substates( pytest.fail("The initial on-load update did not contain both substates") +def test_request_credentials_stay_server_side(router_query_app: AppHarness, page: Page): + """Hydration and events omit credentials while server handlers can read them. + + Args: + router_query_app: Running application in dev or production mode. + page: Browser page receiving state updates. + """ + assert router_query_app.frontend_url is not None + secret = "opaque-cookie-privacy-secret" + api = urlsplit(get_config().api_url) + frontend = urlsplit(router_query_app.frontend_url) + # Use the backend hostname so the browser sends its SameSite cookie. + url = frontend._replace(netloc=f"{api.hostname}:{frontend.port}").geturl() + page.context.add_cookies([ + { + "name": "privacy-test", + "value": secret, + "url": api.geturl(), + "httpOnly": True, + "sameSite": "Lax", + } + ]) + credential_headers = { + "Authorization": "Basic AUTHSECRET", + "Cf-Access-Jwt-Assertion": "CFJWTSECRET", + "X-Auth-Request-Access-Token": "OAUTHSECRET", + "X-Forwarded-Access-Token": "FORWARDEDSECRET", + } + page.context.set_extra_http_headers(credential_headers) + frames: list[str | bytes] = [] + page.on( + "websocket", + lambda socket: socket.on("framereceived", lambda frame: frames.append(frame)), + ) + + page.goto(url) + expect(page.locator("#load-count")).to_have_value("1") + expect(page.locator("#cookie-attribute")).to_have_value("") + expect(page.locator("#cookie-item")).to_have_value("false") + page.click("#ping") + expect(page.locator("#credentials-seen")).to_have_value("true") + assert "privacy-test" not in page.evaluate("document.cookie") + + received_headers = [] + for frame in frames: + decoded = frame.decode() if isinstance(frame, bytes) else frame + assert secret not in decoded + for credential in credential_headers.values(): + assert credential not in decoded + if not isinstance(frame, str) or not frame.startswith("42"): + continue + packet = Packet(encoded_packet=frame[1:]) + assert packet.data is not None + event, update = packet.data + if event == "event": + received_headers.extend( + fields["rx_router_headers" + FIELD_MARKER] + for fields in update.get("delta", {}).values() + if "rx_router_headers" + FIELD_MARKER in fields + ) + assert received_headers, "Hydration did not include the request headers" + for headers in received_headers: + assert "cookie" not in headers + assert "cookie" not in headers["raw_headers"] + assert not {header.lower() for header in credential_headers}.intersection( + headers["raw_headers"] + ) + + def test_replace_state_is_not_reactive_but_next_event_syncs( router_query_app: AppHarness, page: Page ): diff --git a/tests/units/istate/test_data.py b/tests/units/istate/test_data.py index 51cd71e2363..4c3b5eed523 100644 --- a/tests/units/istate/test_data.py +++ b/tests/units/istate/test_data.py @@ -11,7 +11,7 @@ from reflex_base import constants from reflex_base.utils.format import json_dumps from reflex_base.vars.object import ObjectVar -from reflex_base.vars.sequence import StringVar +from reflex_base.vars.sequence import LiteralStringVar, StringVar import reflex as rx from reflex.istate.data import HeaderData, ReflexURL, ReflexURLCastedVar, RouterData @@ -19,16 +19,29 @@ SAMPLE_URL = "https://example.com:3000/posts/123?tab=comments&sort=new#top" -@pytest.mark.parametrize("cookie_header", ["cookie", "Cookie", "COOKIE"]) -def test_header_data_does_not_serialize_cookies(cookie_header: str): - """Cookie headers stay server-side, including in nested router payloads. +@pytest.mark.parametrize( + "header", + [ + "cookie", + "authorization", + "proxy-authorization", + "cf-access-jwt-assertion", + "x-auth-request-access-token", + "x-forwarded-access-token", + ], +) +@pytest.mark.parametrize("casing", ["lower", "title", "upper"]) +def test_header_data_does_not_serialize_credentials(header: str, casing: str): + """Credential headers stay server-side, including in nested router payloads. Args: - cookie_header: The case variant of the HTTP cookie header. + header: The HTTP header carrying credentials. + casing: The case variant of the header name. """ + credential_header = getattr(header, casing)() router = RouterData.from_router_data({ constants.RouteVar.HEADERS: { - cookie_header: "session=secret", + credential_header: "session=secret", "x-custom-header": "public", }, }) @@ -38,25 +51,35 @@ def test_header_data_does_not_serialize_cookies(cookie_header: str): assert "cookie" not in payload["headers"] assert payload["headers"]["raw_headers"] == {"x-custom-header": "public"} assert "secret" not in json_dumps(router.headers) - assert router.headers.cookie == "session=secret" - assert router.headers.raw_headers[cookie_header] == "session=secret" + assert router.headers.cookie == ("session=secret" if header == "cookie" else "") + assert router.headers.raw_headers[credential_header] == "session=secret" restored = pickle.loads(pickle.dumps(router)) assert restored.headers == router.headers -def test_router_cookie_var_is_deprecated(mocker: MockerFixture): +@pytest.mark.parametrize("accessor", ["attribute", "item", "literal-item"]) +def test_router_cookie_var_is_deprecated(mocker: MockerFixture, accessor: str): """Frontend cookie access warns and has an empty string fallback. Args: mocker: Mock fixture. + accessor: The frontend header access syntax. """ deprecate = mocker.patch("reflex.istate.data.console.deprecate") - assert str(rx.State.router.headers.cookie) == '""' + headers = rx.State.router.headers + if accessor == "attribute": + cookie = headers.cookie + else: + key = "cookie" if accessor == "item" else LiteralStringVar.create("cookie") + cookie = headers[key] + assert str(cookie) == '""' deprecate.assert_called_once() assert "rx.Cookie" in deprecate.call_args.kwargs["reason"] deprecate.reset_mock() + assert str(headers["user_agent"]) == str(headers.user_agent) + assert str(headers["raw_headers"]) == str(headers.raw_headers) assert HeaderData(cookie="session=secret").cookie == "session=secret" deprecate.assert_not_called() diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 6e6d42f3816..51af09016f9 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -73,6 +73,7 @@ ImmutableStateError, OnLoadInternalState, State, + StateUpdate, is_serializable, state_snapshot_hashes, ) @@ -343,6 +344,32 @@ def test_router_cookies_not_sent_to_frontend(test_state: TestState, method: str) assert test_state.router.headers.cookie == "session=secret" +def test_frontend_events_do_not_serialize_request_headers(): + """Outbound events retain navigation data without leaking request headers.""" + router_data: dict[str, Any] = { + RouteVar.PATH: "/", + RouteVar.ORIGIN: "https://example.com/", + RouteVar.QUERY: {"name": "test"}, + RouteVar.HEADERS: {"cookie": "session=secret", "user-agent": "browser"}, + } + frontend_event = Event( + name="_call_function", + router_data=router_data, + payload={"javascript_code": "window.loaded = true"}, + ) + update = StateUpdate(events=[frontend_event]) + + payload = json.loads(json_dumps(update)) + + assert "secret" not in json_dumps(payload) + assert payload["events"][0]["router_data"] == { + key: router_data[key] for key in constants.ROUTER_DATA_INCLUDE + } + assert payload["events"][0]["payload"] == frontend_event.payload + assert frontend_event.router_data is router_data + assert router_data[RouteVar.HEADERS]["cookie"] == "session=secret" + + @pytest.fixture def child_state(test_state) -> ChildState: """A child state. From e934df4b0dea5f64a47743154fd8fe00fa72c78b Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 8 Oct 2026 11:23:11 -0700 Subject: [PATCH 3/4] Keep on-load router metadata in the backend event context Stop copying state.router_data onto generated on-load events. Backend chains already inherit the originating view through EventContext, and frontend actions do not need request routing metadata. Restore the original StateUpdate serializer to remove event filtering and cloning from serialization. Replace the serializer-level regression with a producer-level test that failed before this change. State/event processor tests (458), dev/prod browser tests (10), framework type checks, and changed-file hooks pass. --- docs/utility_methods/router_attributes.md | 2 +- news/+router-cookie-privacy.bugfix.md | 2 +- reflex/state.py | 19 ++-------- tests/units/test_state.py | 43 +++++++++++++++-------- 4 files changed, 32 insertions(+), 34 deletions(-) diff --git a/docs/utility_methods/router_attributes.md b/docs/utility_methods/router_attributes.md index c8f140149a7..f5fdfdef042 100644 --- a/docs/utility_methods/router_attributes.md +++ b/docs/utility_methods/router_attributes.md @@ -126,7 +126,7 @@ The `self.router` attribute has several sub-attributes that provide various info - `accept_language`: The accepted languages. - `raw_headers`: A mapping of HTTP headers as a frozen dictionary. The server can access every header. Frontend serialization omits `Cookie`, `Authorization`, `Proxy-Authorization`, `Cf-Access-Jwt-Assertion`, `X-Auth-Request-Access-Token`, and `X-Forwarded-Access-Token`, regardless of case. -Request cookies, including `HttpOnly` cookies, are never included in router data sent to the frontend. Outbound events retain only navigation metadata; their request headers stay on the server. Using `State.router.headers.cookie` or `State.router.headers["cookie"]` in a component is deprecated and renders an empty string. Filtered keys in `State.router.headers.raw_headers`, including `["cookie"]`, are absent and indexed access evaluates to JavaScript `undefined`; use `.get("cookie", "")` for an empty string fallback. Use [rx.Cookie](/docs/client-storage/overview/) for cookies that need to be accessible to the frontend. Server-side access through `self.router.headers.cookie` is unchanged. +Request cookies, including `HttpOnly` cookies, are never included in frontend router headers. On-load events do not copy request router metadata to the frontend; chained backend handlers inherit routing through the server's event context. Using `State.router.headers.cookie` or `State.router.headers["cookie"]` in a component is deprecated and renders an empty string. Filtered keys in `State.router.headers.raw_headers`, including `["cookie"]`, are absent and indexed access evaluates to JavaScript `undefined`; use `.get("cookie", "")` for an empty string fallback. Use [rx.Cookie](/docs/client-storage/overview/) for cookies that need to be accessible to the frontend. Server-side access through `self.router.headers.cookie` is unchanged. ## URL Attributes diff --git a/news/+router-cookie-privacy.bugfix.md b/news/+router-cookie-privacy.bugfix.md index c7bda1c4959..589c74886ad 100644 --- a/news/+router-cookie-privacy.bugfix.md +++ b/news/+router-cookie-privacy.bugfix.md @@ -1 +1 @@ -Stop sending request cookies, including HttpOnly cookies, and standard authorization, Cloudflare Access, and OAuth2 Proxy credential headers to frontend router data. Outbound events include only navigation metadata; server-side access to request headers is unchanged. +Stop sending request cookies, including HttpOnly cookies, and standard authorization, Cloudflare Access, and OAuth2 Proxy credential headers to frontend router data. On-load events no longer copy request router metadata to the frontend; server-side access to request headers is unchanged. diff --git a/reflex/state.py b/reflex/state.py index 6ef5d760d1b..327cbf4299c 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -2627,7 +2627,7 @@ def _load_events_for_page( return None state.is_hydrated = False return [ - *Event.from_event_type(load_events, router_data=state.router_data), + *Event.from_event_type(load_events), OnLoadInternalState.set_is_hydrated(True), ] @@ -2940,24 +2940,9 @@ def serialize_state_update(update: StateUpdate) -> dict: Returns: The serialized StateUpdate. """ - payload = { + return { name: value for name in _STATE_UPDATE_FIELDS if (value := getattr(update, name)) } - if update.events: - payload["events"] = [ - dataclasses.replace( - event, - router_data={ - key: value - for key, value in event.router_data.items() - if key in constants.ROUTER_DATA_INCLUDE - }, - ) - if event.router_data - else event - for event in update.events - ] - return payload def code_uses_state_contexts(javascript_code: str) -> bool: diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 51af09016f9..0bf943eb4cf 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -74,6 +74,7 @@ OnLoadInternalState, State, StateUpdate, + _load_events_for_page, is_serializable, state_snapshot_hashes, ) @@ -344,29 +345,41 @@ def test_router_cookies_not_sent_to_frontend(test_state: TestState, method: str) assert test_state.router.headers.cookie == "session=secret" -def test_frontend_events_do_not_serialize_request_headers(): - """Outbound events retain navigation data without leaking request headers.""" +def test_load_events_do_not_copy_router_data(app_module_mock): + """On-load events leave routing in the server context without copying headers. + + Args: + app_module_mock: The mock module holding the app. + """ + app = app_module_mock.app = App(_state=State) + script = rx.call_script("window.loaded = true") + app.add_page( + lambda: "hello", + route="/", + on_load=[script, TestState.set_num1(1)], + ) router_data: dict[str, Any] = { RouteVar.PATH: "/", RouteVar.ORIGIN: "https://example.com/", RouteVar.QUERY: {"name": "test"}, RouteVar.HEADERS: {"cookie": "session=secret", "user-agent": "browser"}, } - frontend_event = Event( - name="_call_function", - router_data=router_data, - payload={"javascript_code": "window.loaded = true"}, - ) - update = StateUpdate(events=[frontend_event]) - - payload = json.loads(json_dumps(update)) + state = State(_reflex_internal_init=True) # pyright: ignore [reportCallIssue] + state.router_data = router_data + state.router = RouterData.from_router_data(router_data) + load_events = _load_events_for_page(state) + assert load_events is not None + assert len(load_events) == 3 + frontend_event, backend_event, _ = load_events + assert isinstance(frontend_event, Event) + assert isinstance(backend_event, Event) + assert frontend_event.router_data == {} + assert backend_event.router_data == {} + payload = json.loads(json_dumps(StateUpdate(events=[frontend_event]))) assert "secret" not in json_dumps(payload) - assert payload["events"][0]["router_data"] == { - key: router_data[key] for key in constants.ROUTER_DATA_INCLUDE - } - assert payload["events"][0]["payload"] == frontend_event.payload - assert frontend_event.router_data is router_data + assert payload["events"][0]["payload"] == Event.from_event_type(script)[0].payload + assert state.router_data is router_data assert router_data[RouteVar.HEADERS]["cookie"] == "session=secret" From d45cef9cf9554fc737bbde7d53ed03edb88fd038 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 8 Oct 2026 11:34:13 -0700 Subject: [PATCH 4/4] Withhold AWS ALB and Google IAP credentials from frontend router data --- docs/utility_methods/router_attributes.md | 2 +- news/+router-cookie-privacy.bugfix.md | 2 +- reflex/istate/data.py | 3 +++ tests/integration/tests_playwright/test_router_query.py | 9 +++++++++ tests/units/istate/test_data.py | 3 +++ 5 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/utility_methods/router_attributes.md b/docs/utility_methods/router_attributes.md index f5fdfdef042..a64c866eeb0 100644 --- a/docs/utility_methods/router_attributes.md +++ b/docs/utility_methods/router_attributes.md @@ -124,7 +124,7 @@ The `self.router` attribute has several sub-attributes that provide various info - `sec_websocket_extensions`: The websocket extensions. - `accept_encoding`: The accepted encodings. - `accept_language`: The accepted languages. - - `raw_headers`: A mapping of HTTP headers as a frozen dictionary. The server can access every header. Frontend serialization omits `Cookie`, `Authorization`, `Proxy-Authorization`, `Cf-Access-Jwt-Assertion`, `X-Auth-Request-Access-Token`, and `X-Forwarded-Access-Token`, regardless of case. + - `raw_headers`: A mapping of HTTP headers as a frozen dictionary. The server can access every header. Frontend serialization omits `Cookie`, `Authorization`, `Proxy-Authorization`, `Cf-Access-Jwt-Assertion`, `X-Auth-Request-Access-Token`, `X-Forwarded-Access-Token`, `X-Amzn-Oidc-Accesstoken`, `X-Amzn-Oidc-Data`, and `X-Goog-IAP-JWT-Assertion`, regardless of case. Request cookies, including `HttpOnly` cookies, are never included in frontend router headers. On-load events do not copy request router metadata to the frontend; chained backend handlers inherit routing through the server's event context. Using `State.router.headers.cookie` or `State.router.headers["cookie"]` in a component is deprecated and renders an empty string. Filtered keys in `State.router.headers.raw_headers`, including `["cookie"]`, are absent and indexed access evaluates to JavaScript `undefined`; use `.get("cookie", "")` for an empty string fallback. Use [rx.Cookie](/docs/client-storage/overview/) for cookies that need to be accessible to the frontend. Server-side access through `self.router.headers.cookie` is unchanged. diff --git a/news/+router-cookie-privacy.bugfix.md b/news/+router-cookie-privacy.bugfix.md index 589c74886ad..2961c092e41 100644 --- a/news/+router-cookie-privacy.bugfix.md +++ b/news/+router-cookie-privacy.bugfix.md @@ -1 +1 @@ -Stop sending request cookies, including HttpOnly cookies, and standard authorization, Cloudflare Access, and OAuth2 Proxy credential headers to frontend router data. On-load events no longer copy request router metadata to the frontend; server-side access to request headers is unchanged. +Stop sending request cookies, including HttpOnly cookies, and standard authorization, Cloudflare Access, OAuth2 Proxy, AWS ALB, and Google IAP credential headers to frontend router data. On-load events no longer copy request router metadata to the frontend; server-side access to request headers is unchanged. diff --git a/reflex/istate/data.py b/reflex/istate/data.py index 38bd7924ff6..978ccd1eb4a 100644 --- a/reflex/istate/data.py +++ b/reflex/istate/data.py @@ -84,6 +84,9 @@ class _HeaderData: "cf-access-jwt-assertion", "x-auth-request-access-token", "x-forwarded-access-token", + "x-amzn-oidc-accesstoken", + "x-amzn-oidc-data", + "x-goog-iap-jwt-assertion", }) diff --git a/tests/integration/tests_playwright/test_router_query.py b/tests/integration/tests_playwright/test_router_query.py index f2dda877682..132cd45f8e7 100644 --- a/tests/integration/tests_playwright/test_router_query.py +++ b/tests/integration/tests_playwright/test_router_query.py @@ -103,6 +103,12 @@ def ping(self): == "OAUTHSECRET" and self.router.headers.raw_headers.get("x-forwarded-access-token") == "FORWARDEDSECRET" + and self.router.headers.raw_headers.get("x-amzn-oidc-accesstoken") + == "ALBACCESSSECRET" + and self.router.headers.raw_headers.get("x-amzn-oidc-data") + == "ALBDATASECRET" + and self.router.headers.raw_headers.get("x-goog-iap-jwt-assertion") + == "IAPSECRET" ) @rx.event @@ -318,6 +324,9 @@ def test_request_credentials_stay_server_side(router_query_app: AppHarness, page "Cf-Access-Jwt-Assertion": "CFJWTSECRET", "X-Auth-Request-Access-Token": "OAUTHSECRET", "X-Forwarded-Access-Token": "FORWARDEDSECRET", + "X-Amzn-Oidc-Accesstoken": "ALBACCESSSECRET", + "X-Amzn-Oidc-Data": "ALBDATASECRET", + "X-Goog-IAP-JWT-Assertion": "IAPSECRET", } page.context.set_extra_http_headers(credential_headers) frames: list[str | bytes] = [] diff --git a/tests/units/istate/test_data.py b/tests/units/istate/test_data.py index 4c3b5eed523..5dcf2089086 100644 --- a/tests/units/istate/test_data.py +++ b/tests/units/istate/test_data.py @@ -28,6 +28,9 @@ "cf-access-jwt-assertion", "x-auth-request-access-token", "x-forwarded-access-token", + "x-amzn-oidc-accesstoken", + "x-amzn-oidc-data", + "x-goog-iap-jwt-assertion", ], ) @pytest.mark.parametrize("casing", ["lower", "title", "upper"])