From 94c6038dc1d948d13a9f2ab733aea08d7295befe Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 29 Sep 2026 17:14:09 -0700 Subject: [PATCH 1/4] Add signed browser sessions and session-bound client tokens Introduce a stdlib HS256 session codec, environment-only signing keys with rotation, and persistent local secrets excluded from exports. Preserve the local secret during web reinitialization. Validation: 421 affected tests passed, plus unknown-key diagnostic coverage; Ruff and targeted pyright passed. Refs: ENG-12910 --- news/+session-secret-persistence.bugfix.md | 1 + .../news/+session-token-primitives.feature.md | 1 + .../reflex-base/src/reflex_base/config.py | 6 + .../src/reflex_base/environment.py | 8 + .../reflex-base/src/reflex_base/session.py | 349 ++++++++++++++++++ reflex/utils/build.py | 8 +- reflex/utils/frontend_skeleton.py | 12 +- tests/units/reflex_base/test_session.py | 312 ++++++++++++++++ tests/units/test_prerequisites.py | 31 ++ tests/units/utils/test_build.py | 15 + 10 files changed, 741 insertions(+), 2 deletions(-) create mode 100644 news/+session-secret-persistence.bugfix.md create mode 100644 packages/reflex-base/news/+session-token-primitives.feature.md create mode 100644 packages/reflex-base/src/reflex_base/session.py create mode 100644 tests/units/reflex_base/test_session.py diff --git a/news/+session-secret-persistence.bugfix.md b/news/+session-secret-persistence.bugfix.md new file mode 100644 index 00000000000..fd49d2ea51d --- /dev/null +++ b/news/+session-secret-persistence.bugfix.md @@ -0,0 +1 @@ +Preserve the local session signing secret when regenerating frontend files and exclude it from exported app archives. diff --git a/packages/reflex-base/news/+session-token-primitives.feature.md b/packages/reflex-base/news/+session-token-primitives.feature.md new file mode 100644 index 00000000000..1327db60544 --- /dev/null +++ b/packages/reflex-base/news/+session-token-primitives.feature.md @@ -0,0 +1 @@ +Add signed browser sessions and session-bound client tokens, with secret rotation through `REFLEX_SESSION_SECRET` and configurable session lifetimes. diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index b50210587e5..6f4b98dd378 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -183,6 +183,8 @@ class BaseConfig: redis_lock_expiration: Maximum expiration lock time for redis state manager. redis_lock_warning_threshold: Maximum lock time before warning for redis state manager. redis_token_expiration: Token expiration time for redis state manager. + session_token_ttl: Browser session lifetime in seconds, defaulting to seven days. + session_token_refresh_interval: Session age in seconds before refreshing the cookie. None uses half the session TTL. env_file: Path to file containing key-values pairs to load into the environment; Dotenv format. Multiple files may be separated by os.pathsep. Requires the python-dotenv package. state_auto_setters: Whether to automatically create setters for state base vars. default_color_mode: The default color mode for the app: "system" (follow the OS preference), "light", or "dark". Applies to the built-in color mode switcher and `color_mode_cond` without requiring a radix theme. @@ -257,6 +259,10 @@ class BaseConfig: redis_token_expiration: int = constants.Expiration.TOKEN + session_token_ttl: int = 7 * 24 * 60 * 60 + + session_token_refresh_interval: int | None = None + # Attributes that were explicitly set by the user. _non_default_attributes: set[str] = dataclasses.field( default_factory=set, init=False diff --git a/packages/reflex-base/src/reflex_base/environment.py b/packages/reflex-base/src/reflex_base/environment.py index 00e38b9e882..dac6f93a964 100644 --- a/packages/reflex-base/src/reflex_base/environment.py +++ b/packages/reflex-base/src/reflex_base/environment.py @@ -731,6 +731,14 @@ class EnvironmentVariables: # This env var stores the execution mode of the app REFLEX_ENV_MODE: EnvVar[constants.Env] = env_var(constants.Env.DEV) + # Signing keys for session cookies, newest first, separated by commas. + REFLEX_SESSION_SECRET: EnvVar[str | None] = env_var(None) + + # Rollout mode for session-bound client tokens. + REFLEX_SESSION_TOKEN_MODE: EnvVar[Literal["off", "warn", "enforce"]] = env_var( + "warn" + ) + # Whether to keep React's development-build owner-stack capture in dev mode. # Reflex disables it by default because the per-element Error() capture # dominates dev-mode render CPU on large pages; enable it to restore full diff --git a/packages/reflex-base/src/reflex_base/session.py b/packages/reflex-base/src/reflex_base/session.py new file mode 100644 index 00000000000..b8d84c82a73 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/session.py @@ -0,0 +1,349 @@ +"""Signed browser sessions and the client tokens bound to them.""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import json +import logging +import os +import re +import secrets +import tempfile +import time +from contextlib import suppress +from dataclasses import dataclass, field +from functools import cached_property +from typing import ClassVar + +from reflex_base import constants +from reflex_base.environment import environment + +logger = logging.getLogger(__name__) + +SESSION_SECRET_FILENAME = "session_secret" +_SID = re.compile(r"[0-9a-f]{32}\Z") +_CLIENT_TOKEN = re.compile(r"([0-9a-f]{32})\.([0-9a-f]{32})\Z") +_BASE64URL = re.compile(r"[A-Za-z0-9_-]+\Z") +_AUTH_CACHE_SIZE = 128 + + +def _base64url(value: bytes) -> str: + """Encode bytes without base64url padding. + + Args: + value: Bytes to encode. + + Returns: + Encoded bytes as ASCII text. + """ + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + +def _decode_base64url(value: str) -> bytes: + """Decode a JWT segment using only the base64url alphabet. + + Args: + value: Encoded segment. + + Returns: + The decoded bytes. + + Raises: + ValueError: If the segment is malformed. + """ + if not _BASE64URL.fullmatch(value): + msg = "Invalid session token encoding" + raise ValueError(msg) + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _client_signature(sid: str, nonce: str) -> str: + """Sign a client token with an unambiguous session binding. + + Args: + sid: Private session identifier. + nonce: Random client identifier. + + Returns: + A 128-bit hexadecimal authentication tag. + """ + return hmac.digest( + bytes.fromhex(sid), f"reflex-client-token:{nonce}".encode("ascii"), "sha256" + )[:16].hex() + + +def _load_secrets() -> tuple[bytes, ...]: + """Load the configured key ring or atomically create a local signing key. + + Returns: + Keys in signing preference order. + + Raises: + ValueError: If configured or persisted keys are invalid. + """ + configured = environment.REFLEX_SESSION_SECRET.get() + if configured is not None: + keys = tuple(key.strip().encode() for key in configured.split(",")) + if any(len(key) < 32 for key in keys): + msg = "REFLEX_SESSION_SECRET keys must each be at least 32 bytes" + raise ValueError(msg) + return keys + + path = ( + environment.REFLEX_WEB_WORKDIR.get() + / constants.Dirs.BACKEND + / SESSION_SECRET_FILENAME + ) + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists(): + # Link a complete 0600 file into place, so racing workers cannot read a + # partially written key or replace the winner's key. + with tempfile.NamedTemporaryFile(dir=path.parent) as candidate: + candidate.write(secrets.token_hex(32).encode("ascii")) + candidate.flush() + with suppress(FileExistsError): + os.link(candidate.name, path) + try: + key = bytes.fromhex(path.read_text(encoding="ascii")) + except (ValueError, UnicodeError) as error: + msg = "Invalid persisted session signing secret" + raise ValueError(msg) from error + if len(key) != 32: + msg = "Invalid persisted session signing secret" + raise ValueError(msg) + if environment.REFLEX_ENV_MODE.get() == constants.Env.PROD: + logger.warning( + "Using a local session signing secret. Set REFLEX_SESSION_SECRET to " + "share sessions across production replicas and deployments." + ) + return (key,) + + +@dataclass(frozen=True) +class SessionToken: + """A verified browser session, distinct from an application's user identity. + + Attributes: + id: A non-secret hash suitable for correlation in logs. + issued_at: Unix timestamp when this session credential was issued. + expires_at: Unix timestamp when this session credential expires. + SYSTEM: Sentinel for trusted operations without a browser session. + """ + + issued_at: int + expires_at: int + _sid: str = field(repr=False) + id: str = field(init=False) + _authorized: set[str] = field(default_factory=set, repr=False, compare=False) + SYSTEM: ClassVar[SessionToken] + + def __post_init__(self) -> None: + """Derive the public identifier without exposing the private session ID.""" + object.__setattr__(self, "id", hashlib.sha256(self._sid.encode()).hexdigest()) + + def authorizes(self, client_token: str) -> bool: + """Check a client's binding to this session, caching verified successes. + + Expiration is checked when decoding the session credential; a connected + transport controls when to refresh or revalidate that credential. + + Args: + client_token: The client token to verify. + + Returns: + Whether this session owns the client token. + """ + if self is SessionToken.SYSTEM or client_token in self._authorized: + return True + match = _CLIENT_TOKEN.fullmatch(client_token) + if match is None: + return False + nonce, signature = match.groups() + if not hmac.compare_digest(signature, _client_signature(self._sid, nonce)): + return False + if len(self._authorized) >= _AUTH_CACHE_SIZE: + self._authorized.clear() + self._authorized.add(client_token) + return True + + +SessionToken.SYSTEM = SessionToken(issued_at=0, expires_at=0, _sid="") + + +class SessionTokenManager: + """Internal codec for browser session cookies and their client tokens.""" + + def __init__( + self, + app_name: str, + ttl: int = 7 * 24 * 60 * 60, + refresh_interval: int | None = None, + secrets: tuple[bytes, ...] | None = None, + ) -> None: + """Configure the codec without reading secrets until first use. + + Args: + app_name: Stable app name used to isolate its cookie. + ttl: Session lifetime in seconds. + refresh_interval: Age in seconds to refresh, defaulting to half the TTL. + secrets: Optional explicit key ring, primarily for isolated tests. + + Raises: + ValueError: If the session lifetime or refresh interval is invalid. + """ + if ttl <= 0 or ( + refresh_interval is not None and not 0 < refresh_interval < ttl + ): + msg = ( + "Session TTL must be positive and refresh interval between zero and TTL" + ) + raise ValueError(msg) + self.ttl = ttl + self.refresh_interval = ( + ttl / 2 if refresh_interval is None else refresh_interval + ) + self.cookie_name = ( + f"reflex_session_{hashlib.sha256(app_name.encode()).hexdigest()[:12]}" + ) + self._configured_secrets = secrets + self._unknown_key_warned = False + + @cached_property + def _keys(self) -> dict[str, bytes]: + """Read the key ring once and index it by its non-secret fingerprint. + + Returns: + Signing keys indexed by key ID, with the active signing key first. + """ + return { + hashlib.sha256(key).hexdigest()[:16]: key + for key in self._configured_secrets or _load_secrets() + } + + def create(self) -> SessionToken: + """Create a new random session. + + Returns: + A new session with the configured lifetime. + """ + now = int(time.time()) + return SessionToken(now, now + self.ttl, secrets.token_hex(16)) + + def encode(self, session: SessionToken) -> str: + """Encode a session as an HS256 JWT using the active signing key. + + Args: + session: The session to encode. + + Returns: + A signed JWT suitable for an HttpOnly cookie. + """ + kid, key = next(iter(self._keys.items())) + header = _base64url( + json.dumps( + {"alg": "HS256", "typ": "JWT", "kid": kid}, separators=(",", ":") + ).encode() + ) + payload = _base64url( + json.dumps( + { + "v": 1, + "sid": session._sid, + "iat": session.issued_at, + "exp": session.expires_at, + }, + separators=(",", ":"), + ).encode() + ) + message = f"{header}.{payload}" + return f"{message}.{_base64url(hmac.digest(key, message.encode('ascii'), 'sha256'))}" + + def decode(self, token: str) -> SessionToken | None: + """Verify the signature, schema, version, and lifetime of a session JWT. + + Args: + token: An untrusted cookie value. + + Returns: + The verified session, or None for invalid or expired credentials. + """ + if len(token) > 4096: + return None + try: + header, payload, signature = token.split(".") + metadata = json.loads(_decode_base64url(header)) + if not isinstance(metadata, dict) or metadata.get("alg") != "HS256": + return None + kid = metadata.get("kid") + if not isinstance(kid, str): + return None + if (key := self._keys.get(kid)) is None: + if not self._unknown_key_warned: + logger.warning( + "Unrecognized session signing key. Backend instances may be " + "using different REFLEX_SESSION_SECRET values." + ) + self._unknown_key_warned = True + return None + expected = hmac.digest(key, f"{header}.{payload}".encode("ascii"), "sha256") + if not hmac.compare_digest(expected, _decode_base64url(signature)): + return None + claims = json.loads(_decode_base64url(payload)) + if not isinstance(claims, dict): + return None + sid, issued_at, expires_at = ( + claims.get("sid"), + claims.get("iat"), + claims.get("exp"), + ) + if ( + type(claims.get("v")) is not int + or claims["v"] != 1 + or not isinstance(sid, str) + or not _SID.fullmatch(sid) + or type(issued_at) is not int + or type(expires_at) is not int + or not issued_at <= int(time.time()) < expires_at + ): + return None + return SessionToken(issued_at, expires_at, sid) + except (ValueError, UnicodeError, binascii.Error, RecursionError): + return None + + def should_refresh(self, session: SessionToken) -> bool: + """Check whether a session has reached its configured refresh age. + + Args: + session: The verified session to check. + + Returns: + Whether to issue a refreshed cookie. + """ + return time.time() - session.issued_at >= self.refresh_interval + + def refresh(self, session: SessionToken) -> SessionToken: + """Renew a session while preserving its client-token bindings. + + Args: + session: The verified session to renew. + + Returns: + The session with updated issuance and expiration times. + """ + now = int(time.time()) + return SessionToken(now, now + self.ttl, session._sid) + + def create_client_token(self, session: SessionToken) -> str: + """Create a random client token cryptographically bound to a session. + + Args: + session: The verified session that owns the new client. + + Returns: + An underscore-free client identifier and its authentication tag. + """ + nonce = secrets.token_hex(16) + return f"{nonce}.{_client_signature(session._sid, nonce)}" diff --git a/reflex/utils/build.py b/reflex/utils/build.py index 45bd2895a71..62055e788b9 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -9,6 +9,7 @@ from reflex_base import constants from reflex_base.config import get_config +from reflex_base.session import SESSION_SECRET_FILENAME from reflex.utils import console, js_runtimes, path_ops, prerequisites, processes from reflex.utils.exec import frontend_env, is_in_app_harness @@ -147,7 +148,11 @@ def _zip( files_to_zip += [ file for file in root_directory.glob(glob) - if file.name not in files_to_exclude + if not any( + file.samefile(excluded_file) + for excluded_file in files_to_exclude + if excluded_file.exists() + ) ] # Create a progress bar for zipping the component. progress = console.progress() @@ -187,6 +192,7 @@ def zip_app( files_to_exclude = { Path(constants.ComponentName.FRONTEND.zip()).resolve(), Path(constants.ComponentName.BACKEND.zip()).resolve(), + prerequisites.get_backend_dir() / SESSION_SECRET_FILENAME, } if frontend: diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index f0b894b9251..3da0b5f34b6 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -2,6 +2,7 @@ import json import logging +import shutil import uuid from pathlib import Path from typing import Literal @@ -10,6 +11,7 @@ from reflex_base.config import Config, get_config from reflex_base.environment import environment from reflex_base.plugins.embed import get_embed_plugin +from reflex_base.session import SESSION_SECRET_FILENAME from reflex.compiler import templates from reflex.utils import net, path_ops @@ -464,7 +466,15 @@ def initialize_web_directory(): project_hash = get_project_hash() logger.debug(f"Copying {constants.Templates.Dirs.WEB_TEMPLATE} to {web_dir}") - path_ops.copy_tree(constants.Templates.Dirs.WEB_TEMPLATE, str(web_dir)) + if web_dir.exists(): + for item in web_dir.iterdir(): + if item.name == constants.Dirs.BACKEND and item.is_dir(): + for backend_file in item.iterdir(): + if backend_file.name != SESSION_SECRET_FILENAME: + path_ops.rm(backend_file) + else: + path_ops.rm(item) + shutil.copytree(constants.Templates.Dirs.WEB_TEMPLATE, web_dir, dirs_exist_ok=True) logger.debug("Restoring lockfiles.") sync_root_lockfiles_to_web() diff --git a/tests/units/reflex_base/test_session.py b/tests/units/reflex_base/test_session.py new file mode 100644 index 00000000000..ac3a776849e --- /dev/null +++ b/tests/units/reflex_base/test_session.py @@ -0,0 +1,312 @@ +"""Tests for signed session credentials and client bindings.""" + +import base64 +import hashlib +import hmac +import json +from concurrent.futures import ThreadPoolExecutor +from dataclasses import FrozenInstanceError +from unittest.mock import patch + +import pytest +from reflex_base.config import Config +from reflex_base.environment import environment +from reflex_base.session import ( + _AUTH_CACHE_SIZE, + SessionToken, + SessionTokenManager, + _load_secrets, +) +from reflex_base.utils.exceptions import EnvironmentVarValueError + +SECRET = b"a" * 32 +OTHER_SECRET = b"b" * 32 + + +@pytest.fixture +def manager(monkeypatch): + """Create an isolated codec with a fixed clock. + + Returns: + A codec with a 100-second session lifetime. + """ + monkeypatch.setattr("reflex_base.session.time.time", lambda: 1000) + return SessionTokenManager("test", ttl=100, secrets=(SECRET,)) + + +def _signed_token(header, claims, secret=SECRET): + """Construct independently signed inputs to exercise strict decoding. + + Returns: + A signed JWT containing the supplied header and claims. + """ + segments = [ + base64.urlsafe_b64encode(json.dumps(value).encode()).rstrip(b"=") + for value in (header, claims) + ] + message = b".".join(segments) + signature = base64.urlsafe_b64encode(hmac.digest(secret, message, "sha256")).rstrip( + b"=" + ) + return (message + b"." + signature).decode() + + +def test_round_trip_and_public_session(manager): + session = manager.create() + encoded = manager.encode(session) + decoded = manager.decode(encoded) + assert decoded == session + assert session.issued_at == 1000 + assert session.expires_at == 1100 + assert session.id == hashlib.sha256(session._sid.encode()).hexdigest() + assert len(session._sid) == 32 + assert session._sid not in repr(session) + assert SECRET.decode() not in repr(session) + with pytest.raises(FrozenInstanceError): + session.issued_at = 0 + assert manager.create().id != session.id + header = json.loads(base64.urlsafe_b64decode(encoded.split(".")[0] + "==")) + assert header == { + "alg": "HS256", + "typ": "JWT", + "kid": hashlib.sha256(SECRET).hexdigest()[:16], + } + + +@pytest.mark.parametrize( + "token", ["", "x", "x.y", "x.y.z.w", "x.y.z", "!.e30.AA", "e30.é.AA", "a" * 4097] +) +def test_decode_malformed(manager, token): + assert manager.decode(token) is None + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("v", 2), + ("v", True), + ("v", None), + ("sid", ""), + ("sid", "g" * 32), + ("sid", 123), + ("sid", "a" * 31), + ("iat", 1001), + ("iat", "1000"), + ("iat", True), + ("iat", None), + ("exp", 1000), + ("exp", 999), + ("exp", "1100"), + ("exp", True), + ], +) +def test_decode_rejects_invalid_claims(manager, field, value): + claims = {"v": 1, "sid": "a" * 32, "iat": 1000, "exp": 1100} + claims[field] = value + header = {"alg": "HS256", "kid": next(iter(manager._keys))} + assert manager.decode(_signed_token(header, claims)) is None + + +@pytest.mark.parametrize( + "header", + [ + {"alg": "none"}, + {"alg": "HS384"}, + {"alg": "HS256", "kid": "unknown"}, + {"alg": "HS256", "kid": []}, + [], + None, + ], +) +def test_decode_rejects_wrong_algorithm_or_key(manager, header): + claims = {"v": 1, "sid": "a" * 32, "iat": 1000, "exp": 1100} + assert manager.decode(_signed_token(header, claims)) is None + + +def test_decode_rejects_bad_signature_and_nonobject_claims(manager): + header = {"alg": "HS256", "kid": next(iter(manager._keys))} + claims = {"v": 1, "sid": "a" * 32, "iat": 1000, "exp": 1100} + assert manager.decode(_signed_token(header, claims, OTHER_SECRET)) is None + assert manager.decode(_signed_token(header, [])) is None + + +def test_refresh_keeps_existing_client_bindings(manager, monkeypatch): + session = manager.create() + client_token = manager.create_client_token(session) + monkeypatch.setattr("reflex_base.session.time.time", lambda: 1049) + assert not manager.should_refresh(session) + monkeypatch.setattr("reflex_base.session.time.time", lambda: 1050) + assert manager.should_refresh(session) + refreshed = manager.refresh(session) + assert refreshed.id == session.id + assert refreshed.issued_at == 1050 + assert refreshed.expires_at == 1150 + assert refreshed.authorizes(client_token) + monkeypatch.setattr("reflex_base.session.time.time", lambda: 1100) + assert manager.decode(manager.encode(session)) is None + assert manager.decode(manager.encode(refreshed)) == refreshed + + +def test_client_token_binding_and_format(manager): + session, other_session = manager.create(), manager.create() + token = manager.create_client_token(session) + nonce, signature = token.split(".") + assert len(nonce) == len(signature) == 32 + assert "_" not in token + assert session.authorizes(token) + assert not other_session.authorizes(token) + assert not session.authorizes(nonce + "." + "0" * 32) + assert SessionToken.SYSTEM.authorizes("trusted-internal-token") + + +@pytest.mark.parametrize( + "token", + ["", "abc", "legacy_uuid", "a" * 32 + "." + "b" * 31, "é" * 65, "a" * 10_000], +) +def test_invalid_client_tokens_are_not_cached(manager, token): + session = manager.create() + assert not session.authorizes(token) + assert not session._authorized + + +def test_authorization_cache_is_bounded_and_skips_hmac(manager): + session = manager.create() + token = manager.create_client_token(session) + assert session.authorizes(token) + with patch( + "reflex_base.session._client_signature", side_effect=AssertionError("cached") + ): + assert session.authorizes(token) + for _ in range(_AUTH_CACHE_SIZE + 1): + assert session.authorizes(manager.create_client_token(session)) + assert len(session._authorized) <= _AUTH_CACHE_SIZE + assert session.authorizes(token) + + +def test_key_rotation(manager): + session = manager.create() + old_cookie = manager.encode(session) + old_client = manager.create_client_token(session) + rotated = SessionTokenManager("test", secrets=(OTHER_SECRET, SECRET)) + rotated_session = rotated.decode(old_cookie) + assert rotated_session is not None + assert rotated_session.authorizes(old_client) + assert rotated_session.authorizes(rotated.create_client_token(rotated_session)) + new_cookie = rotated.encode(rotated_session) + assert manager.decode(new_cookie) is None + new_only = SessionTokenManager("test", secrets=(OTHER_SECRET,)) + assert new_only.decode(old_cookie) is None + renewed = new_only.decode(new_cookie) + assert renewed is not None + assert renewed.authorizes(old_client) + + +def test_secrets_load_lazily_and_cookies_are_app_specific(monkeypatch): + with patch( + "reflex_base.session._load_secrets", + side_effect=AssertionError("eager secret read"), + ): + first = SessionTokenManager("first") + second = SessionTokenManager("second") + assert first.cookie_name != second.cookie_name + assert first.cookie_name == SessionTokenManager("first").cookie_name + + +def test_unknown_signing_key_warns_once(manager, caplog): + """Different instance secrets produce one diagnostic without credentials. + + Args: + manager: The session codec fixture. + caplog: The captured log records. + """ + other = SessionTokenManager("test", secrets=(OTHER_SECRET,)) + credential = other.encode(other.create()) + for _ in range(2): + assert manager.decode(credential) is None + assert caplog.text.count("Backend instances may be") == 1 + assert credential not in caplog.text + assert OTHER_SECRET.decode() not in caplog.text + + +def test_environment_key_ring_does_not_write_files(tmp_path, monkeypatch): + monkeypatch.setenv("REFLEX_SESSION_SECRET", "a" * 32 + ", " + "b" * 32) + monkeypatch.setenv("REFLEX_WEB_WORKDIR", str(tmp_path / ".web")) + assert _load_secrets() == (SECRET, OTHER_SECRET) + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize("value", ["short", "a" * 32 + ",", "," + "b" * 32]) +def test_invalid_secret_never_discloses_its_value(monkeypatch, value): + monkeypatch.setenv("REFLEX_SESSION_SECRET", value) + with pytest.raises(ValueError, match="at least 32 bytes") as error: + _load_secrets() + assert value not in str(error.value) + + +def test_persisted_secret_is_atomic_private_and_reused(tmp_path, monkeypatch): + monkeypatch.delenv("REFLEX_SESSION_SECRET", raising=False) + monkeypatch.setenv("REFLEX_WEB_WORKDIR", str(tmp_path / ".web")) + with ThreadPoolExecutor(max_workers=16) as executor: + results = list(executor.map(lambda _: _load_secrets(), range(64))) + assert len(set(results)) == 1 + assert len(results[0][0]) == 32 + assert _load_secrets() == results[0] + path = tmp_path / ".web" / "backend" / "session_secret" + assert path.stat().st_mode & 0o777 == 0o600 + assert list(path.parent.iterdir()) == [path] + + +def test_production_fallback_warning_does_not_contain_secret( + tmp_path, monkeypatch, caplog +): + monkeypatch.delenv("REFLEX_SESSION_SECRET", raising=False) + monkeypatch.setenv("REFLEX_WEB_WORKDIR", str(tmp_path / ".web")) + monkeypatch.setenv("REFLEX_ENV_MODE", "prod") + key = _load_secrets()[0] + assert "Set REFLEX_SESSION_SECRET" in caplog.text + assert key.hex() not in caplog.text + + +@pytest.mark.parametrize("value", ["not-hex", "aa", "é"]) +def test_invalid_persisted_secret_is_not_replaced(tmp_path, monkeypatch, value): + monkeypatch.delenv("REFLEX_SESSION_SECRET", raising=False) + monkeypatch.setenv("REFLEX_WEB_WORKDIR", str(tmp_path)) + path = tmp_path / "backend" / "session_secret" + path.parent.mkdir() + path.write_text(value) + with pytest.raises(ValueError, match="Invalid persisted"): + _load_secrets() + assert path.read_text() == value + + +@pytest.mark.parametrize( + ("ttl", "refresh_interval"), + [(0, None), (-1, None), (100, 0), (100, 100), (100, -1)], +) +def test_invalid_lifetimes(ttl, refresh_interval): + with pytest.raises(ValueError, match="Session TTL"): + SessionTokenManager("app", ttl=ttl, refresh_interval=refresh_interval) + + +def test_config_defaults_and_secret_is_not_config(monkeypatch): + monkeypatch.setenv("REFLEX_SESSION_SECRET", SECRET.decode()) + config = Config(app_name="test") + assert config.session_token_ttl == 604800 + assert config.session_token_refresh_interval is None + assert "session_secret" not in config.class_fields() + monkeypatch.setenv("REFLEX_SESSION_TOKEN_TTL", "100") + monkeypatch.setenv("REFLEX_SESSION_TOKEN_REFRESH_INTERVAL", "25") + config = Config(app_name="test") + assert config.session_token_ttl == 100 + assert config.session_token_refresh_interval == 25 + + +@pytest.mark.parametrize("mode", ["off", "warn", "enforce"]) +def test_session_rollout_mode(monkeypatch, mode): + monkeypatch.delenv("REFLEX_SESSION_TOKEN_MODE", raising=False) + assert environment.REFLEX_SESSION_TOKEN_MODE.get() == "warn" + monkeypatch.setenv("REFLEX_SESSION_TOKEN_MODE", mode) + assert environment.REFLEX_SESSION_TOKEN_MODE.get() == mode + monkeypatch.setenv("REFLEX_SESSION_TOKEN_MODE", "invalid") + with pytest.raises(EnvironmentVarValueError): + environment.REFLEX_SESSION_TOKEN_MODE.get() diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index 1cb231e2ee7..d76070b5956 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -567,6 +567,37 @@ def test_react_compiler_vite_config(): ) +@pytest.mark.usefixtures("_stub_skeleton_initializers") +def test_initialize_web_directory_preserves_session_secret(tmp_path, monkeypatch): + """Reinitializing frontend files keeps the signing secret and removes old markers.""" + template_dir = tmp_path / "template" + template_dir.mkdir() + (template_dir / "new-file").write_text("new") + monkeypatch.setattr( + frontend_skeleton.constants.Templates.Dirs, "WEB_TEMPLATE", template_dir + ) + web_dir = tmp_path / constants.Dirs.WEB + backend_dir = web_dir / constants.Dirs.BACKEND + backend_dir.mkdir(parents=True) + secret = backend_dir / "session_secret" + secret.write_text("persistent-secret") + secret.chmod(0o600) + inode = secret.stat().st_ino + (backend_dir / "stale-marker").write_text("stale") + (web_dir / "stale-file").write_text("stale") + _patch_web_dir(monkeypatch, web_dir) + + with chdir(tmp_path): + frontend_skeleton.initialize_web_directory() + + assert secret.read_text() == "persistent-secret" + assert secret.stat().st_ino == inode + assert secret.stat().st_mode & 0o777 == 0o600 + assert not (backend_dir / "stale-marker").exists() + assert not (web_dir / "stale-file").exists() + assert (web_dir / "new-file").read_text() == "new" + + @pytest.mark.usefixtures("_stub_skeleton_initializers") def test_initialize_web_directory_restores_root_bun_lock(tmp_path, monkeypatch): template_dir = tmp_path / "template" diff --git a/tests/units/utils/test_build.py b/tests/units/utils/test_build.py index 9128d931ce5..5a773876d9b 100644 --- a/tests/units/utils/test_build.py +++ b/tests/units/utils/test_build.py @@ -97,6 +97,21 @@ def test_zip_excludes_directory_symlink_aliases(tmp_path: Path): assert archive.namelist() == [] +def test_zip_app_excludes_session_secret(tmp_path, monkeypatch): + """Exported apps include backend markers but never the local signing secret.""" + monkeypatch.chdir(tmp_path) + backend_dir = tmp_path / ".web" / "backend" + backend_dir.mkdir(parents=True) + (backend_dir / "session_secret").write_text("private signing secret") + (backend_dir / "stateful_pages.json").write_text("[]") + + build.zip_app(frontend=False) + + with zipfile.ZipFile(tmp_path / "backend.zip") as archive: + assert ".web/backend/stateful_pages.json" in archive.namelist() + assert ".web/backend/session_secret" not in archive.namelist() + + def test_compress_static_output_overwrites_stale_sidecars( tmp_path: Path, mocker: MockerFixture ): From a987ce7ba997102a93ccd6dc2d987f1d344b9842 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 29 Sep 2026 17:41:05 -0700 Subject: [PATCH 2/4] Document session primitive regression tests --- tests/units/reflex_base/test_session.py | 110 ++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/units/reflex_base/test_session.py b/tests/units/reflex_base/test_session.py index ac3a776849e..8d88f4f5217 100644 --- a/tests/units/reflex_base/test_session.py +++ b/tests/units/reflex_base/test_session.py @@ -52,6 +52,11 @@ def _signed_token(header, claims, secret=SECRET): def test_round_trip_and_public_session(manager): + """Round-trip immutable sessions without exposing private identifiers or keys. + + Args: + manager: The session codec fixture. + """ session = manager.create() encoded = manager.encode(session) decoded = manager.decode(encoded) @@ -77,6 +82,12 @@ def test_round_trip_and_public_session(manager): "token", ["", "x", "x.y", "x.y.z.w", "x.y.z", "!.e30.AA", "e30.é.AA", "a" * 4097] ) def test_decode_malformed(manager, token): + """Reject malformed session credentials. + + Args: + manager: The session codec fixture. + token: The malformed credential. + """ assert manager.decode(token) is None @@ -101,6 +112,13 @@ def test_decode_malformed(manager, token): ], ) def test_decode_rejects_invalid_claims(manager, field, value): + """Reject signed credentials with invalid claim values. + + Args: + manager: The session codec fixture. + field: The claim to replace. + value: The invalid claim value. + """ claims = {"v": 1, "sid": "a" * 32, "iat": 1000, "exp": 1100} claims[field] = value header = {"alg": "HS256", "kid": next(iter(manager._keys))} @@ -119,11 +137,22 @@ def test_decode_rejects_invalid_claims(manager, field, value): ], ) def test_decode_rejects_wrong_algorithm_or_key(manager, header): + """Reject unsupported algorithms, unknown keys, and malformed headers. + + Args: + manager: The session codec fixture. + header: The invalid JWT header. + """ claims = {"v": 1, "sid": "a" * 32, "iat": 1000, "exp": 1100} assert manager.decode(_signed_token(header, claims)) is None def test_decode_rejects_bad_signature_and_nonobject_claims(manager): + """Reject incorrect signatures and claims that are not objects. + + Args: + manager: The session codec fixture. + """ header = {"alg": "HS256", "kid": next(iter(manager._keys))} claims = {"v": 1, "sid": "a" * 32, "iat": 1000, "exp": 1100} assert manager.decode(_signed_token(header, claims, OTHER_SECRET)) is None @@ -131,6 +160,12 @@ def test_decode_rejects_bad_signature_and_nonobject_claims(manager): def test_refresh_keeps_existing_client_bindings(manager, monkeypatch): + """Refresh at the configured age without changing client token ownership. + + Args: + manager: The session codec fixture. + monkeypatch: The clock override fixture. + """ session = manager.create() client_token = manager.create_client_token(session) monkeypatch.setattr("reflex_base.session.time.time", lambda: 1049) @@ -148,6 +183,11 @@ def test_refresh_keeps_existing_client_bindings(manager, monkeypatch): def test_client_token_binding_and_format(manager): + """Verify client token formatting, session ownership, and trusted access. + + Args: + manager: The session codec fixture. + """ session, other_session = manager.create(), manager.create() token = manager.create_client_token(session) nonce, signature = token.split(".") @@ -164,12 +204,23 @@ def test_client_token_binding_and_format(manager): ["", "abc", "legacy_uuid", "a" * 32 + "." + "b" * 31, "é" * 65, "a" * 10_000], ) def test_invalid_client_tokens_are_not_cached(manager, token): + """Keep invalid client tokens out of the authorization cache. + + Args: + manager: The session codec fixture. + token: The invalid client token. + """ session = manager.create() assert not session.authorizes(token) assert not session._authorized def test_authorization_cache_is_bounded_and_skips_hmac(manager): + """Bound successful authorization caching and reuse verified signatures. + + Args: + manager: The session codec fixture. + """ session = manager.create() token = manager.create_client_token(session) assert session.authorizes(token) @@ -184,6 +235,11 @@ def test_authorization_cache_is_bounded_and_skips_hmac(manager): def test_key_rotation(manager): + """Rotate signing keys while retaining verified client token bindings. + + Args: + manager: The session codec fixture using the original key. + """ session = manager.create() old_cookie = manager.encode(session) old_client = manager.create_client_token(session) @@ -202,6 +258,11 @@ def test_key_rotation(manager): def test_secrets_load_lazily_and_cookies_are_app_specific(monkeypatch): + """Isolate cookie names by app without eagerly reading signing secrets. + + Args: + monkeypatch: The test environment fixture. + """ with patch( "reflex_base.session._load_secrets", side_effect=AssertionError("eager secret read"), @@ -229,6 +290,12 @@ def test_unknown_signing_key_warns_once(manager, caplog): def test_environment_key_ring_does_not_write_files(tmp_path, monkeypatch): + """Use configured signing keys without creating a persisted secret. + + Args: + tmp_path: The isolated working directory. + monkeypatch: The environment override fixture. + """ monkeypatch.setenv("REFLEX_SESSION_SECRET", "a" * 32 + ", " + "b" * 32) monkeypatch.setenv("REFLEX_WEB_WORKDIR", str(tmp_path / ".web")) assert _load_secrets() == (SECRET, OTHER_SECRET) @@ -237,6 +304,12 @@ def test_environment_key_ring_does_not_write_files(tmp_path, monkeypatch): @pytest.mark.parametrize("value", ["short", "a" * 32 + ",", "," + "b" * 32]) def test_invalid_secret_never_discloses_its_value(monkeypatch, value): + """Reject invalid configured secrets without including them in errors. + + Args: + monkeypatch: The environment override fixture. + value: The invalid signing key configuration. + """ monkeypatch.setenv("REFLEX_SESSION_SECRET", value) with pytest.raises(ValueError, match="at least 32 bytes") as error: _load_secrets() @@ -244,6 +317,12 @@ def test_invalid_secret_never_discloses_its_value(monkeypatch, value): def test_persisted_secret_is_atomic_private_and_reused(tmp_path, monkeypatch): + """Concurrent initialization shares one secret with private file permissions. + + Args: + tmp_path: The isolated working directory. + monkeypatch: The environment override fixture. + """ monkeypatch.delenv("REFLEX_SESSION_SECRET", raising=False) monkeypatch.setenv("REFLEX_WEB_WORKDIR", str(tmp_path / ".web")) with ThreadPoolExecutor(max_workers=16) as executor: @@ -259,6 +338,13 @@ def test_persisted_secret_is_atomic_private_and_reused(tmp_path, monkeypatch): def test_production_fallback_warning_does_not_contain_secret( tmp_path, monkeypatch, caplog ): + """Warn about production fallback keys without disclosing their contents. + + Args: + tmp_path: The isolated working directory. + monkeypatch: The environment override fixture. + caplog: The captured log records. + """ monkeypatch.delenv("REFLEX_SESSION_SECRET", raising=False) monkeypatch.setenv("REFLEX_WEB_WORKDIR", str(tmp_path / ".web")) monkeypatch.setenv("REFLEX_ENV_MODE", "prod") @@ -269,6 +355,13 @@ def test_production_fallback_warning_does_not_contain_secret( @pytest.mark.parametrize("value", ["not-hex", "aa", "é"]) def test_invalid_persisted_secret_is_not_replaced(tmp_path, monkeypatch, value): + """Reject an invalid persisted secret without replacing its contents. + + Args: + tmp_path: The isolated working directory. + monkeypatch: The environment override fixture. + value: The invalid persisted key contents. + """ monkeypatch.delenv("REFLEX_SESSION_SECRET", raising=False) monkeypatch.setenv("REFLEX_WEB_WORKDIR", str(tmp_path)) path = tmp_path / "backend" / "session_secret" @@ -284,11 +377,22 @@ def test_invalid_persisted_secret_is_not_replaced(tmp_path, monkeypatch, value): [(0, None), (-1, None), (100, 0), (100, 100), (100, -1)], ) def test_invalid_lifetimes(ttl, refresh_interval): + """Reject session lifetimes and refresh intervals outside their valid ranges. + + Args: + ttl: The requested session lifetime. + refresh_interval: The requested refresh interval. + """ with pytest.raises(ValueError, match="Session TTL"): SessionTokenManager("app", ttl=ttl, refresh_interval=refresh_interval) def test_config_defaults_and_secret_is_not_config(monkeypatch): + """Expose session timing configuration while keeping signing secrets separate. + + Args: + monkeypatch: The environment override fixture. + """ monkeypatch.setenv("REFLEX_SESSION_SECRET", SECRET.decode()) config = Config(app_name="test") assert config.session_token_ttl == 604800 @@ -303,6 +407,12 @@ def test_config_defaults_and_secret_is_not_config(monkeypatch): @pytest.mark.parametrize("mode", ["off", "warn", "enforce"]) def test_session_rollout_mode(monkeypatch, mode): + """Default to warnings and accept only supported rollout modes. + + Args: + monkeypatch: The environment override fixture. + mode: The supported rollout mode to select. + """ monkeypatch.delenv("REFLEX_SESSION_TOKEN_MODE", raising=False) assert environment.REFLEX_SESSION_TOKEN_MODE.get() == "warn" monkeypatch.setenv("REFLEX_SESSION_TOKEN_MODE", mode) From b592c65c1408f2f6aebf4ca9844c2ed45cbf431b Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 14 Oct 2026 02:14:08 -0700 Subject: [PATCH 3/4] Scope signed sessions to their issuing app and fix portable secret tests --- .../news/+session-token-primitives.feature.md | 2 +- .../reflex-base/src/reflex_base/config.py | 2 +- .../reflex-base/src/reflex_base/session.py | 7 ++-- reflex/utils/frontend_skeleton.py | 4 ++- tests/units/reflex_base/test_session.py | 32 ++++++++++++++++--- tests/units/test_prerequisites.py | 3 +- 6 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/reflex-base/news/+session-token-primitives.feature.md b/packages/reflex-base/news/+session-token-primitives.feature.md index 1327db60544..2ac92c3ed77 100644 --- a/packages/reflex-base/news/+session-token-primitives.feature.md +++ b/packages/reflex-base/news/+session-token-primitives.feature.md @@ -1 +1 @@ -Add signed browser sessions and session-bound client tokens, with secret rotation through `REFLEX_SESSION_SECRET` and configurable session lifetimes. +Add signed browser sessions and session-bound client tokens, with secret rotation through `REFLEX_SESSION_SECRET` and configurable session lifetimes. Signed session credentials are scoped to the app name, even when apps share signing keys. diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 6f4b98dd378..851fa505561 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -259,7 +259,7 @@ class BaseConfig: redis_token_expiration: int = constants.Expiration.TOKEN - session_token_ttl: int = 7 * 24 * 60 * 60 + session_token_ttl: int = 7 * 24 * 60 * 60 # Seven days, in seconds. session_token_refresh_interval: int | None = None diff --git a/packages/reflex-base/src/reflex_base/session.py b/packages/reflex-base/src/reflex_base/session.py index b8d84c82a73..69f357ab22c 100644 --- a/packages/reflex-base/src/reflex_base/session.py +++ b/packages/reflex-base/src/reflex_base/session.py @@ -179,14 +179,14 @@ class SessionTokenManager: def __init__( self, app_name: str, - ttl: int = 7 * 24 * 60 * 60, + ttl: int = 7 * 24 * 60 * 60, # Seven days, in seconds. refresh_interval: int | None = None, secrets: tuple[bytes, ...] | None = None, ) -> None: """Configure the codec without reading secrets until first use. Args: - app_name: Stable app name used to isolate its cookie. + app_name: Stable app name used to isolate its cookie and signed audience. ttl: Session lifetime in seconds. refresh_interval: Age in seconds to refresh, defaulting to half the TTL. secrets: Optional explicit key ring, primarily for isolated tests. @@ -201,6 +201,7 @@ def __init__( "Session TTL must be positive and refresh interval between zero and TTL" ) raise ValueError(msg) + self._audience = app_name self.ttl = ttl self.refresh_interval = ( ttl / 2 if refresh_interval is None else refresh_interval @@ -251,6 +252,7 @@ def encode(self, session: SessionToken) -> str: json.dumps( { "v": 1, + "aud": self._audience, "sid": session._sid, "iat": session.issued_at, "exp": session.expires_at, @@ -302,6 +304,7 @@ def decode(self, token: str) -> SessionToken | None: if ( type(claims.get("v")) is not int or claims["v"] != 1 + or claims.get("aud") != self._audience or not isinstance(sid, str) or not _SID.fullmatch(sid) or type(issued_at) is not int diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index 3da0b5f34b6..827e168eb0c 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -474,7 +474,9 @@ def initialize_web_directory(): path_ops.rm(backend_file) else: path_ops.rm(item) - shutil.copytree(constants.Templates.Dirs.WEB_TEMPLATE, web_dir, dirs_exist_ok=True) + shutil.copytree( + constants.Templates.Dirs.WEB_TEMPLATE, web_dir, dirs_exist_ok=True + ) logger.debug("Restoring lockfiles.") sync_root_lockfiles_to_web() diff --git a/tests/units/reflex_base/test_session.py b/tests/units/reflex_base/test_session.py index 8d88f4f5217..110d4fe340c 100644 --- a/tests/units/reflex_base/test_session.py +++ b/tests/units/reflex_base/test_session.py @@ -9,6 +9,7 @@ from unittest.mock import patch import pytest +from reflex_base import constants from reflex_base.config import Config from reflex_base.environment import environment from reflex_base.session import ( @@ -119,7 +120,9 @@ def test_decode_rejects_invalid_claims(manager, field, value): field: The claim to replace. value: The invalid claim value. """ - claims = {"v": 1, "sid": "a" * 32, "iat": 1000, "exp": 1100} + claims = {"v": 1, "aud": "test", "sid": "a" * 32, "iat": 1000, "exp": 1100} + header = {"alg": "HS256", "kid": next(iter(manager._keys))} + assert manager.decode(_signed_token(header, claims)) is not None claims[field] = value header = {"alg": "HS256", "kid": next(iter(manager._keys))} assert manager.decode(_signed_token(header, claims)) is None @@ -143,7 +146,7 @@ def test_decode_rejects_wrong_algorithm_or_key(manager, header): manager: The session codec fixture. header: The invalid JWT header. """ - claims = {"v": 1, "sid": "a" * 32, "iat": 1000, "exp": 1100} + claims = {"v": 1, "aud": "test", "sid": "a" * 32, "iat": 1000, "exp": 1100} assert manager.decode(_signed_token(header, claims)) is None @@ -154,7 +157,7 @@ def test_decode_rejects_bad_signature_and_nonobject_claims(manager): manager: The session codec fixture. """ header = {"alg": "HS256", "kid": next(iter(manager._keys))} - claims = {"v": 1, "sid": "a" * 32, "iat": 1000, "exp": 1100} + claims = {"v": 1, "aud": "test", "sid": "a" * 32, "iat": 1000, "exp": 1100} assert manager.decode(_signed_token(header, claims, OTHER_SECRET)) is None assert manager.decode(_signed_token(header, [])) is None @@ -273,6 +276,26 @@ def test_secrets_load_lazily_and_cookies_are_app_specific(monkeypatch): assert first.cookie_name == SessionTokenManager("first").cookie_name +def test_sessions_cannot_cross_apps_sharing_signing_keys(manager): + """A valid signature must not authorize credentials issued for another app.""" + other = SessionTokenManager("other", secrets=(SECRET,)) + credential = manager.encode(manager.create()) + assert manager.decode(credential) is not None + assert other.decode(credential) is None + + +@pytest.mark.parametrize("audience", [None, "other", ["test"], 1]) +def test_decode_rejects_missing_or_invalid_audience(manager, audience): + """Require the issuing app's exact signed audience, including its type.""" + claims = {"v": 1, "aud": "test", "sid": "a" * 32, "iat": 1000, "exp": 1100} + if audience is None: + del claims["aud"] + else: + claims["aud"] = audience + header = {"alg": "HS256", "kid": next(iter(manager._keys))} + assert manager.decode(_signed_token(header, claims)) is None + + def test_unknown_signing_key_warns_once(manager, caplog): """Different instance secrets produce one diagnostic without credentials. @@ -331,7 +354,8 @@ def test_persisted_secret_is_atomic_private_and_reused(tmp_path, monkeypatch): assert len(results[0][0]) == 32 assert _load_secrets() == results[0] path = tmp_path / ".web" / "backend" / "session_secret" - assert path.stat().st_mode & 0o777 == 0o600 + if not constants.IS_WINDOWS: + assert path.stat().st_mode & 0o777 == 0o600 assert list(path.parent.iterdir()) == [path] diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index d76070b5956..b8e99367189 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -592,7 +592,8 @@ def test_initialize_web_directory_preserves_session_secret(tmp_path, monkeypatch assert secret.read_text() == "persistent-secret" assert secret.stat().st_ino == inode - assert secret.stat().st_mode & 0o777 == 0o600 + if not constants.IS_WINDOWS: + assert secret.stat().st_mode & 0o777 == 0o600 assert not (backend_dir / "stale-marker").exists() assert not (web_dir / "stale-file").exists() assert (web_dir / "new-file").read_text() == "new" From 44c4b4e8de06277fb7851600034151d03af83abb Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 14 Oct 2026 02:34:16 -0700 Subject: [PATCH 4/4] Keep frontend cleanup locking coverage with persistent session secrets --- tests/units/utils/test_path_ops.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/units/utils/test_path_ops.py b/tests/units/utils/test_path_ops.py index bc7e4aec01b..05531b6aa4d 100644 --- a/tests/units/utils/test_path_ops.py +++ b/tests/units/utils/test_path_ops.py @@ -342,6 +342,7 @@ def test_web_directory_cleanup_waits_for_staged_json_update( cleanup_reached = threading.Event() allow_cleanup = threading.Event() original_dump = path_ops.json.dump + original_rm = path_ops.rm def pausing_dump(value, file, *, ensure_ascii): file.write("{") @@ -352,11 +353,10 @@ def pausing_dump(value, file, *, ensure_ascii): file.truncate() original_dump(value, file, ensure_ascii=ensure_ascii) - def destructive_copy(_source, _destination): + def destructive_remove(path): cleanup_reached.set() assert allow_cleanup.wait(timeout=5) - shutil.rmtree(web_dir) - web_dir.mkdir() + original_rm(path) monkeypatch.setattr(path_ops.json, "dump", pausing_dump) monkeypatch.setattr(frontend_skeleton, "get_web_dir", lambda: web_dir) @@ -383,7 +383,7 @@ def destructive_copy(_source, _destination): staged_files = list(web_dir.glob(".reflex.json.*.tmp")) assert len(staged_files) == 1 _report_json_lock_attempt(monkeypatch, cleanup_lock_attempted) - monkeypatch.setattr(path_ops, "copy_tree", destructive_copy) + monkeypatch.setattr(path_ops, "rm", destructive_remove) cleanup = executor.submit(frontend_skeleton.initialize_web_directory) try: assert cleanup_lock_attempted.wait(timeout=5)