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
10 changes: 4 additions & 6 deletions docs/utility_methods/router_attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`.
Comment thread
masenf marked this conversation as resolved.
- `pragma`: The pragma header.
- `cache_control`: The cache control header.
- `user_agent`: The user agent string of the client.
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions news/+router-cookie-privacy.bugfix.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions news/+router-cookie-privacy.deprecation.md
Original file line number Diff line number Diff line change
@@ -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.
76 changes: 70 additions & 6 deletions reflex/istate/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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",
Comment thread
masenf marked this conversation as resolved.
"x-amzn-oidc-accesstoken",
"x-amzn-oidc-data",
"x-goog-iap-jwt-assertion",
})


@dataclasses.dataclass(frozen=True)
class HeaderData(_HeaderData):
Expand Down Expand Up @@ -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:
Comment thread
masenf marked this conversation as resolved.
"""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("")
Comment thread
greptile-apps[bot] marked this conversation as resolved.


@serializer(to=dict)
Expand Down Expand Up @@ -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]:
Expand Down
2 changes: 1 addition & 1 deletion reflex/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]

Expand Down
123 changes: 122 additions & 1 deletion tests/integration/tests_playwright/test_router_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
):
Expand Down
Loading
Loading