From ed3738cb94d8456783af7115cba874629adff09a Mon Sep 17 00:00:00 2001 From: Farhan Date: Thu, 22 Oct 2026 19:51:15 +0000 Subject: [PATCH 1/2] fix(app): restrict default CORS origins to deploy_url in prod mode The `["*"]` default for `cors_allowed_origins` let any site connect to the backend. In prod mode the default now narrows to the origin of `deploy_url` (or nothing when `deploy_url` is unset), while an explicit `cors_allowed_origins`, from rxconfig or the environment, is used as is. Dev mode keeps allowing any origin. Both the Starlette CORS middleware and the Socket.IO server resolve their origins through one helper. Closes #7640 Claude-Session: https://claude.ai/code/session_01C1xrGYmKsaacgEafTz9BTJ --- docs/advanced_onboarding/configuration.md | 2 +- news/+cors-prod-default.breaking.md | 1 + .../news/+cors-prod-default.docs.md | 1 + .../reflex-base/src/reflex_base/config.py | 2 +- reflex/app.py | 30 +++++-- tests/units/test_app.py | 83 +++++++++++++++++++ 6 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 news/+cors-prod-default.breaking.md create mode 100644 packages/reflex-base/news/+cors-prod-default.docs.md diff --git a/docs/advanced_onboarding/configuration.md b/docs/advanced_onboarding/configuration.md index 92d7e67567f..ef807b6e02e 100644 --- a/docs/advanced_onboarding/configuration.md +++ b/docs/advanced_onboarding/configuration.md @@ -87,7 +87,7 @@ The imported module must still define `app` at module level. ## CORS -The backend only accepts cross-origin requests from origins listed in `cors_allowed_origins`. The default, `["*"]`, allows any origin — convenient in development, but in production restrict it to the origins your frontend is actually served from (typically the origin of `deploy_url`): +The backend only accepts cross-origin requests from origins listed in `cors_allowed_origins`. By default any origin is allowed in dev mode, while in prod mode only the origin of `deploy_url` is allowed, so set `deploy_url` to where your frontend is served from. Set `cors_allowed_origins` explicitly when the frontend is reachable from several origins, or to `["*"]` to allow any origin in production: ```python config = rx.Config( diff --git a/news/+cors-prod-default.breaking.md b/news/+cors-prod-default.breaking.md new file mode 100644 index 00000000000..af55b83e69c --- /dev/null +++ b/news/+cors-prod-default.breaking.md @@ -0,0 +1 @@ +In prod mode, the backend now accepts cross-origin requests only from the origin of `deploy_url` unless `cors_allowed_origins` is set explicitly. Previously any origin was allowed by default. Set `deploy_url` to the frontend's URL, or set `cors_allowed_origins` (`["*"]` restores the old behavior). diff --git a/packages/reflex-base/news/+cors-prod-default.docs.md b/packages/reflex-base/news/+cors-prod-default.docs.md new file mode 100644 index 00000000000..4f8aef2a026 --- /dev/null +++ b/packages/reflex-base/news/+cors-prod-default.docs.md @@ -0,0 +1 @@ +Document that the default `cors_allowed_origins` is restricted to the origin of `deploy_url` in prod mode. diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index c3861dc4c3d..e91c251654a 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -173,7 +173,7 @@ class BaseConfig: bun_path: The bun path. frozen_lockfile: Run frontend package manager in lockfile-enforcing mode (only honored by bun). static_page_generation_timeout: Timeout to do a production build of a frontend page. - cors_allowed_origins: Comma separated list of origins that are allowed to connect to the backend API. + cors_allowed_origins: Comma separated list of origins that are allowed to connect to the backend API. The default allows any origin in dev mode; in prod mode it allows only the origin of deploy_url. vite_allowed_hosts: Allowed hosts for the Vite dev server. Set to True to allow all hosts, or provide a list of hostnames (e.g. ["myservice.local"]) to allow specific ones. Prevents 403 errors in Docker, Codespaces, reverse proxies, etc. react_strict_mode: Whether to use React strict mode. react_compiler: Enable experimental React Compiler memoization for generated components in development and production. Defaults to False. diff --git a/reflex/app.py b/reflex/app.py index 97d7d7472e5..a395126dd2e 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -387,6 +387,26 @@ def _is_location_specifier(specifier: str) -> bool: ) +def _cors_allowed_origins() -> list[str]: + """Resolve the origins the backend accepts cross-origin requests from. + + An explicitly configured ``cors_allowed_origins`` is used as is. The + ``["*"]`` default stays open in dev mode, but in prod mode it narrows to + the origin of ``deploy_url`` so only the configured frontend can reach + the backend; without a ``deploy_url`` no cross-origin request is accepted. + + Returns: + The allowed origins, where ``["*"]`` means any origin. + """ + config = get_config() + if "cors_allowed_origins" in config._non_default_attributes or not is_prod_mode(): + return list(config.cors_allowed_origins) + if not config.deploy_url: + return [] + parts = urllib.parse.urlsplit(config.deploy_url) + return [f"{parts.scheme}://{parts.netloc}"] + + @dataclasses.dataclass() class App(MiddlewareMixin, LifespanMixin): """The main Reflex app that encapsulates the backend and frontend. @@ -626,13 +646,7 @@ def _setup_state(self) -> None: self.sio = AsyncServer( async_mode="asgi", cors_allowed_origins=( - ( - "*" - if config.cors_allowed_origins == ("*",) - else list(config.cors_allowed_origins) - ) - if config.transport == "websocket" - else [] + _cors_allowed_origins() if config.transport == "websocket" else [] ), cors_credentials=config.transport == "websocket", max_http_buffer_size=environment.REFLEX_SOCKET_MAX_HTTP_BUFFER_SIZE.get(), @@ -932,7 +946,7 @@ def _add_cors(api: Starlette): allow_credentials=True, allow_methods=["*"], allow_headers=["*"], - allow_origins=get_config().cors_allowed_origins, + allow_origins=_cors_allowed_origins(), ) @property diff --git a/tests/units/test_app.py b/tests/units/test_app.py index ae575628094..6442f427456 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -66,6 +66,7 @@ ComponentCallable, EventNamespace, _ContextMiddleware, + _cors_allowed_origins, _decode_asgi_headers, _sio_dumps, _sio_loads, @@ -5795,3 +5796,85 @@ def reader(): for future in readers: future.result() assert json.loads(marker.read_text()) == routes + + +@pytest.mark.parametrize( + ("prod", "config_kwargs", "expected"), + [ + (False, {}, ["*"]), + (True, {}, ["http://localhost:3000"]), + (True, {"deploy_url": "https://example.com/app/"}, ["https://example.com"]), + (True, {"deploy_url": None}, []), + ( + True, + {"cors_allowed_origins": ["https://a.com", "https://b.com"]}, + ["https://a.com", "https://b.com"], + ), + (True, {"cors_allowed_origins": ["*"]}, ["*"]), + (False, {"cors_allowed_origins": ["https://a.com"]}, ["https://a.com"]), + ], +) +def test_cors_allowed_origins_default_restricts_to_deploy_url_in_prod( + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + prod: bool, + config_kwargs: dict[str, Any], + expected: list[str], +): + """The default CORS origins open up in dev mode and narrow to deploy_url in prod mode. + + Args: + mocker: The pytest mocker fixture. + monkeypatch: The pytest monkeypatch fixture. + prod: Whether to resolve in prod mode. + config_kwargs: Extra config values. + expected: The expected allowed origins. + """ + monkeypatch.delenv("REFLEX_CORS_ALLOWED_ORIGINS", raising=False) + monkeypatch.delenv("REFLEX_DEPLOY_URL", raising=False) + config = rx.Config(app_name="cors_app", **config_kwargs) + mocker.patch("reflex.app.get_config", return_value=config) + mocker.patch("reflex.app.is_prod_mode", return_value=prod) + + assert _cors_allowed_origins() == expected + + +def test_cors_allowed_origins_from_env_is_explicit( + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +): + """An env-provided cors_allowed_origins counts as explicit, even in prod mode. + + Args: + mocker: The pytest mocker fixture. + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.setenv("REFLEX_CORS_ALLOWED_ORIGINS", "*") + config = rx.Config(app_name="cors_app") + mocker.patch("reflex.app.get_config", return_value=config) + mocker.patch("reflex.app.is_prod_mode", return_value=True) + + assert _cors_allowed_origins() == ["*"] + + +def test_add_cors_and_socketio_use_resolved_origins( + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +): + """Both the HTTP middleware and the Socket.IO server use the resolved origins. + + Args: + mocker: The pytest mocker fixture. + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.delenv("REFLEX_CORS_ALLOWED_ORIGINS", raising=False) + config = rx.Config(app_name="cors_app", deploy_url="https://example.com") + mocker.patch("reflex.app.get_config", return_value=config) + mocker.patch("reflex.app.is_prod_mode", return_value=True) + + api = Starlette() + App._add_cors(api) + assert api.user_middleware[0].kwargs["allow_origins"] == ["https://example.com"] + + app = App(_state=rx.State) + app._setup_state() + assert app.sio is not None + assert app.sio.eio.cors_allowed_origins == ["https://example.com"] From 119337dc78dab3769f25eeb0caaf0f50371bd3b2 Mon Sep 17 00:00:00 2001 From: Farhan Date: Thu, 22 Oct 2026 19:58:17 +0000 Subject: [PATCH 2/2] fix(app): normalize deploy_url to a browser-style origin for CORS Browsers serialize the Origin header with a lowercase host and without the scheme's default port, so `https://Example.com:443/app` must allow `https://example.com`. Also cover CORS enforcement with request-level tests for the HTTP middleware and the Socket.IO origin check. Claude-Session: https://claude.ai/code/session_01C1xrGYmKsaacgEafTz9BTJ --- reflex/app.py | 12 +++++++++- tests/units/test_app.py | 52 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/reflex/app.py b/reflex/app.py index a395126dd2e..89a5230e902 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -387,6 +387,9 @@ def _is_location_specifier(specifier: str) -> bool: ) +_DEFAULT_PORTS = {"http": 80, "https": 443} + + def _cors_allowed_origins() -> list[str]: """Resolve the origins the backend accepts cross-origin requests from. @@ -403,8 +406,15 @@ def _cors_allowed_origins() -> list[str]: return list(config.cors_allowed_origins) if not config.deploy_url: return [] + # Match the browser's Origin serialization: lowercase host, no default port. parts = urllib.parse.urlsplit(config.deploy_url) - return [f"{parts.scheme}://{parts.netloc}"] + host = parts.hostname or "" + if ":" in host: + host = f"[{host}]" + port = parts.port + if port is None or port == _DEFAULT_PORTS.get(parts.scheme): + return [f"{parts.scheme}://{host}"] + return [f"{parts.scheme}://{host}:{port}"] @dataclasses.dataclass() diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 6442f427456..a17cfaeab99 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -54,7 +54,8 @@ from starlette.datastructures import FormData, Headers, UploadFile from starlette.exceptions import HTTPException from starlette.requests import ClientDisconnect -from starlette.responses import StreamingResponse +from starlette.responses import PlainTextResponse, StreamingResponse +from starlette.routing import Route from starlette.testclient import TestClient from starlette_admin.auth import AuthProvider @@ -5804,6 +5805,10 @@ def reader(): (False, {}, ["*"]), (True, {}, ["http://localhost:3000"]), (True, {"deploy_url": "https://example.com/app/"}, ["https://example.com"]), + (True, {"deploy_url": "https://Example.COM:443/app"}, ["https://example.com"]), + (True, {"deploy_url": "http://example.com:80"}, ["http://example.com"]), + (True, {"deploy_url": "http://example.com:8080"}, ["http://example.com:8080"]), + (True, {"deploy_url": "http://[::1]:3000"}, ["http://[::1]:3000"]), (True, {"deploy_url": None}, []), ( True, @@ -5878,3 +5883,48 @@ def test_add_cors_and_socketio_use_resolved_origins( app._setup_state() assert app.sio is not None assert app.sio.eio.cors_allowed_origins == ["https://example.com"] + + +@pytest.mark.parametrize( + ("deploy_url", "origin", "allowed"), + [ + ("https://example.com:443/app", "https://example.com", True), + ("https://example.com", "https://evil.example", False), + (None, "http://localhost:3000", False), + ], +) +def test_cors_middleware_enforces_resolved_origins( + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + deploy_url: str | None, + origin: str, + allowed: bool, +): + """A preflight from the frontend origin passes and any other origin is refused. + + Args: + mocker: The pytest mocker fixture. + monkeypatch: The pytest monkeypatch fixture. + deploy_url: The configured deploy_url. + origin: The Origin header of the request. + allowed: Whether the preflight should succeed. + """ + monkeypatch.delenv("REFLEX_CORS_ALLOWED_ORIGINS", raising=False) + config = rx.Config(app_name="cors_app", deploy_url=deploy_url) + mocker.patch("reflex.app.get_config", return_value=config) + mocker.patch("reflex.app.is_prod_mode", return_value=True) + + api = Starlette(routes=[Route("/ping", lambda request: PlainTextResponse("pong"))]) + App._add_cors(api) + response = TestClient(api).options( + "/ping", + headers={"Origin": origin, "Access-Control-Request-Method": "GET"}, + ) + assert (response.status_code == 200) is allowed + assert (response.headers.get("access-control-allow-origin") == origin) is allowed + + app = App(_state=rx.State) + app._setup_state() + assert app.sio is not None + environ = {"HTTP_ORIGIN": origin, "wsgi.url_scheme": "http", "HTTP_HOST": "api"} + assert (origin in app.sio.eio._cors_allowed_origins(environ)) is allowed