diff --git a/docs/utility_methods/router_attributes.md b/docs/utility_methods/router_attributes.md index d7185d0801f..a64c866eeb0 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. 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. ## URL Attributes diff --git a/news/+router-cookie-privacy.bugfix.md b/news/+router-cookie-privacy.bugfix.md new file mode 100644 index 00000000000..2961c092e41 --- /dev/null +++ b/news/+router-cookie-privacy.bugfix.md @@ -0,0 +1 @@ +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/news/+router-cookie-privacy.deprecation.md b/news/+router-cookie-privacy.deprecation.md new file mode 100644 index 00000000000..2cfc9f9768f --- /dev/null +++ b/news/+router-cookie-privacy.deprecation.md @@ -0,0 +1 @@ +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 8b3b245d0b4..978ccd1eb4a 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 @@ -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) @@ -77,6 +77,18 @@ 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", + "x-amzn-oidc-accesstoken", + "x-amzn-oidc-data", + "x-goog-iap-jwt-assertion", +}) + @dataclasses.dataclass(frozen=True) class HeaderData(_HeaderData): @@ -109,7 +121,59 @@ 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 credentials to the frontend. + + Args: + obj: The headers to serialize. + + Returns: + The headers with credentials 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() not in _PRIVATE_HEADERS + } + return headers + + +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. + + 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) @@ -711,13 +775,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(ObjectVar, 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..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), ] diff --git a/tests/integration/tests_playwright/test_router_query.py b/tests/integration/tests_playwright/test_router_query.py index 96a70c27d34..132cd45f8e7 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,25 @@ 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" + 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 def do_redirect(self, target: str): @@ -160,10 +182,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 +255,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 +297,78 @@ 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", + "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] = [] + 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 aabb52a162a..5dcf2089086 100644 --- a/tests/units/istate/test_data.py +++ b/tests/units/istate/test_data.py @@ -1,20 +1,92 @@ """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 +from reflex_base.vars.sequence import LiteralStringVar, 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( + "header", + [ + "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", + "x-goog-iap-jwt-assertion", + ], +) +@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: + 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: { + credential_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" if header == "cookie" else "") + assert router.headers.raw_headers[credential_header] == "session=secret" + restored = pickle.loads(pickle.dumps(router)) + assert restored.headers == router.headers + + +@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") + + 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() + + 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..0bf943eb4cf 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,10 +70,11 @@ from reflex.istate.proxy import MutableProxy, StateProxy from reflex.state import ( BaseState, - Delta, ImmutableStateError, OnLoadInternalState, State, + StateUpdate, + _load_events_for_page, is_serializable, state_snapshot_hashes, ) @@ -120,7 +121,6 @@ "origin": "", "upgrade": "", "connection": "", - "cookie": "", "pragma": "", "cache_control": "", "user_agent": "", @@ -324,6 +324,65 @@ 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" + + +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"}, + } + 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]["payload"] == Event.from_event_type(script)[0].payload + assert state.router_data is router_data + assert router_data[RouteVar.HEADERS]["cookie"] == "session=secret" + + @pytest.fixture def child_state(test_state) -> ChildState: """A child state. @@ -4462,12 +4521,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": "",