diff --git a/docs/advanced_onboarding/configuration.md b/docs/advanced_onboarding/configuration.md index 92d7e67567f..0b9f5cfe65e 100644 --- a/docs/advanced_onboarding/configuration.md +++ b/docs/advanced_onboarding/configuration.md @@ -83,11 +83,11 @@ The imported module must still define `app` at module level. - `frontend_port` (default `3000`) and `backend_port` (default `8000`) control which ports the frontend and backend listen on. In dev mode, if a port is taken, the next available port is used. - `backend_host` (default `0.0.0.0`) is the address the backend server binds to. - `api_url` (default `http://localhost:8000`) is the URL the user's **browser** uses to reach the backend. You typically don't need to set it: when `api_url` points at localhost, the frontend substitutes the domain the app was served from, so a backend reachable at the same address as the frontend (e.g. behind a reverse proxy or load balancer) is found automatically. Set `api_url` only when the backend is listening on a different address than the frontend, e.g. `https://api.example.com`. -- `deploy_url` (default `http://localhost:3000`) is the public URL where the **frontend** is hosted. Reflex uses it wherever an absolute frontend URL is needed — most notably for the links in the generated `sitemap.xml`. It is also the origin browsers will present when connecting to the backend, which matters for CORS (below). +- `deploy_url` (unset by default) is the public URL where the **frontend** is hosted when it is served from a different origin than the backend. Reflex uses it wherever an absolute frontend URL is needed — most notably for the links in the generated `sitemap.xml`, which fall back to `http://localhost:` when it is unset (`config.frontend_url` exposes that resolved value). It is also the origin browsers will present when connecting to the backend, which matters for CORS (below). ## 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. In prod mode the backend serves the frontend from its own origin, so by default no cross-origin requests are allowed at all and same-origin requests pass; when `deploy_url` is set, only that origin is allowed. 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/docs/app/README.md b/docs/app/README.md index e80a61a8235..132f0a2a807 100644 --- a/docs/app/README.md +++ b/docs/app/README.md @@ -64,7 +64,7 @@ If the build uses a custom `REFLEX_WEB_WORKDIR`, pass that environment variable The `reflex-docs` integration CI jobs run the frontend tests after building the production site, using the installed React and bundler dependencies. -Public URLs use `deploy_url` and `frontend_path`. Keep the default localhost origin expected by the Helm sitemap rewrite; environments may override `REFLEX_DEPLOY_URL`. The `/docs` mount is configured separately. +Public URLs use `deploy_url` and `frontend_path`. `deploy_url` is unset by default, so the sitemap uses `config.frontend_url`, the default localhost origin expected by the Helm sitemap rewrite; environments may override `REFLEX_DEPLOY_URL`. The `/docs` mount is configured separately. The docs app serves permanent HTTP 301 redirects for its legacy URLs when the Reflex backend serves the frontend. In development or when HTML is hosted separately, requests reach the frontend instead: the redirect pages retain client navigation and prerendered HTML includes an immediate refresh, canonical link, noindex directive, and a usable destination link. That fallback navigates readers but returns HTTP 200. For HTTP 301 semantics on a separate frontend/CDN, configure redirects at that host's edge using the `redirects` list in `reflex_docs/reflex_docs.py`; backend middleware alone cannot redirect requests it never receives. diff --git a/docs/app/tests/test_config.py b/docs/app/tests/test_config.py index a1040a3c81f..f70e9b08be5 100644 --- a/docs/app/tests/test_config.py +++ b/docs/app/tests/test_config.py @@ -24,7 +24,7 @@ def test_docs_deployment_origin_comes_from_environment( if deploy_url is not None: monkeypatch.setenv("REFLEX_DEPLOY_URL", deploy_url) config = runpy.run_path(str(Path(__file__).parents[1] / "rxconfig.py"))["config"] - assert config.deploy_url == expected + assert config.frontend_url == expected def test_docs_head_identifies_cross_origin_video_embeds(monkeypatch): diff --git a/news/+cors-prod-default.breaking.md b/news/+cors-prod-default.breaking.md new file mode 100644 index 00000000000..ce41a390b15 --- /dev/null +++ b/news/+cors-prod-default.breaking.md @@ -0,0 +1 @@ +In prod mode, when `deploy_url` is unset (now the default), the backend no longer sends CORS headers, so browsers cannot read cross-origin responses, and Socket.IO accepts same-origin connections only. With `deploy_url` set, only that origin is allowed. Previously any origin was allowed. Set `cors_allowed_origins` explicitly for other setups (`["*"]` restores the old behavior). diff --git a/packages/reflex-base/news/+deploy-url-default.breaking.md b/packages/reflex-base/news/+deploy-url-default.breaking.md new file mode 100644 index 00000000000..a7db0c551f9 --- /dev/null +++ b/packages/reflex-base/news/+deploy-url-default.breaking.md @@ -0,0 +1 @@ +`deploy_url` now defaults to `None` instead of `http://localhost:3000` and is no longer derived from `frontend_port`. The new `Config.frontend_url` property returns `deploy_url` when set and `http://localhost:` otherwise; the sitemap plugin builds absolute links from it. diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index c3861dc4c3d..e5c542754fb 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -163,7 +163,7 @@ class BaseConfig: backend_port: The port to run the backend on. NOTE: When running in dev mode, the next available port will be used if this is taken. backend_path: The path prefix for backend routes. For example, "/api" mounts the event websocket, /ping, /_upload, /_health, and /_all_routes under /api, and is automatically included in URLs baked into the frontend. Changing this requires a full `reflex run` restart — routes are registered at startup. api_url: The backend url the frontend will connect to. Only needs to be set when the backend is listening on a different address than the frontend. - deploy_url: The url the frontend will be hosted on. Used to build absolute frontend URLs, e.g. links in the generated sitemap.xml. + deploy_url: The public url the frontend is hosted on, when it differs from the backend origin. Unset by default: in prod mode the backend then serves the frontend from its own origin, and frontend_url falls back to http://localhost:. backend_host: The url the backend will be hosted on. trusted_proxy_hops: Number of trusted reverse proxies used to resolve client IPs from X-Forwarded-For. Defaults to 0, which ignores X-Forwarded-For. db_url: The database url used by rx.Model. @@ -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, or only same-origin requests when deploy_url is unset. 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. @@ -212,7 +212,7 @@ class BaseConfig: api_url: str = f"http://localhost:{constants.DefaultPorts.BACKEND_PORT}" - deploy_url: str | None = f"http://localhost:{constants.DefaultPorts.FRONTEND_PORT}" + deploy_url: str | None = None backend_host: str = "0.0.0.0" @@ -733,6 +733,18 @@ def prepend_backend_path(self, path: str) -> str: """ return self._prepend_path(path, self.backend_path) + @property + def frontend_url(self) -> str: + """The public url the frontend is reachable at. + + Returns: + ``deploy_url`` when set, otherwise ``http://localhost:``. + """ + return ( + self.deploy_url + or f"http://localhost:{self.frontend_port or constants.DefaultPorts.FRONTEND_PORT}" + ) + @property def app_module(self) -> ModuleType | None: """The app module if `app_module_import` is set. @@ -818,12 +830,6 @@ def _replace_defaults(self, **kwargs): if "api_url" not in self._non_default_attributes and "backend_port" in kwargs: self.api_url = f"http://localhost:{kwargs['backend_port']}" - if ( - "deploy_url" not in self._non_default_attributes - and "frontend_port" in kwargs - ): - self.deploy_url = f"http://localhost:{kwargs['frontend_port']}" - if "api_url" not in self._non_default_attributes: # If running in Github Codespaces, override API_URL codespace_name = os.getenv("CODESPACE_NAME") diff --git a/packages/reflex-base/src/reflex_base/plugins/sitemap.py b/packages/reflex-base/src/reflex_base/plugins/sitemap.py index 190c9a26fe5..c09f7adde52 100644 --- a/packages/reflex-base/src/reflex_base/plugins/sitemap.py +++ b/packages/reflex-base/src/reflex_base/plugins/sitemap.py @@ -149,7 +149,7 @@ def generate_links_for_sitemap( from reflex_base.config import get_config app_config = get_config() - deploy_url = app_config.deploy_url + deploy_url = app_config.frontend_url links: list[SitemapLink] = [] diff --git a/reflex/app.py b/reflex/app.py index 635405df1e7..f08c933bfad 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -478,6 +478,38 @@ def _is_location_specifier(specifier: str) -> bool: ) +_DEFAULT_PORTS = {"http": 80, "https": 443} + + +def _cors_allowed_origins() -> list[str] | None: + """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`` the backend serves the frontend + itself, so CORS is left unconfigured and only same-origin requests pass. + + Returns: + The allowed origins, where ``["*"]`` means any origin, or None to + leave CORS unconfigured. + """ + 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 None + # Match the browser's Origin serialization: lowercase host, no default port. + parts = urllib.parse.urlsplit(config.deploy_url) + 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() class App(MiddlewareMixin, LifespanMixin): """The main Reflex app that encapsulates the backend and frontend. @@ -717,13 +749,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(), @@ -966,10 +992,15 @@ def __call__(self) -> ASGIApp: if asgi_app is not api: lifespan_apps.append(asgi_app) + cors_options = App._cors_options() top_asgi_app = _RootMiddleware( # Covers the routes a callable transformer adds and the 500 responses # that the outermost Starlette app's error handler sends. - cors.CORSMiddleware(asgi_app, **App._cors_options()), + ( + cors.CORSMiddleware(asgi_app, **cors_options) + if cors_options is not None + else asgi_app + ), reflex_app=self, lifespan=functools.partial(self._run_lifespan, api, lifespan_apps), ) @@ -1032,17 +1063,21 @@ def _add_optional_endpoints(self): self.add_all_routes_endpoint() @staticmethod - def _cors_options() -> dict[str, Any]: + def _cors_options() -> dict[str, Any] | None: """Get the options of the CORS middleware. Returns: - The keyword arguments for ``CORSMiddleware``. + The keyword arguments for ``CORSMiddleware``, or None when CORS is + left unconfigured so only same-origin requests are served. """ + allow_origins = _cors_allowed_origins() + if allow_origins is None: + return None return { "allow_credentials": True, "allow_methods": ["*"], "allow_headers": ["*"], - "allow_origins": get_config().cors_allowed_origins, + "allow_origins": allow_origins, } @staticmethod @@ -1052,7 +1087,10 @@ def _add_cors(api: Starlette): Args: api: The Starlette app to add CORS middleware to. """ - api.add_middleware(cors.CORSMiddleware, **App._cors_options()) + options = App._cors_options() + if options is None: + return + api.add_middleware(cors.CORSMiddleware, **options) @property def state_manager(self) -> StateManager: diff --git a/tests/units/plugins/test_sitemap.py b/tests/units/plugins/test_sitemap.py index f5c13f55419..2fc4f7c342b 100644 --- a/tests/units/plugins/test_sitemap.py +++ b/tests/units/plugins/test_sitemap.py @@ -7,6 +7,7 @@ import pytest from reflex_base.plugins.sitemap import ( SitemapLink, + configuration_with_loc, generate_links_for_sitemap, generate_xml, ) @@ -100,7 +101,7 @@ def test_generate_links_for_sitemap_static_routes( caplog: Pytest log capture fixture. """ mock_get_config.return_value.frontend_path = "" - mock_get_config.return_value.deploy_url = "https://example.com" + mock_get_config.return_value.frontend_url = "https://example.com" def mock_component(): return rx.text("Test") @@ -160,7 +161,7 @@ def test_generate_links_for_sitemap_dynamic_routes( caplog: Pytest log capture fixture. """ mock_get_config.return_value.frontend_path = "" - mock_get_config.return_value.deploy_url = "https://sub.example.org" + mock_get_config.return_value.frontend_url = "https://sub.example.org" now = datetime.datetime(2023, 6, 13, 12, 0, 0) def mock_component(): @@ -231,7 +232,9 @@ def test_generate_links_for_sitemap_404_route( caplog: Pytest log capture fixture. """ mock_get_config.return_value.frontend_path = "" - mock_get_config.return_value.deploy_url = None # No deploy URL + mock_get_config.return_value.frontend_url = ( + "http://localhost:3000" # deploy_url unset + ) def mock_component(): return rx.text("404") @@ -260,7 +263,7 @@ def mock_component(): ] links = generate_links_for_sitemap(pages, trailing_slash="preserve") assert len(links) == 1 - assert {"loc": "/custom-404", "priority": 0.1} in links + assert {"loc": "http://localhost:3000/custom-404", "priority": 0.1} in links warnings = _sitemap_warnings(caplog) assert len(warnings) == 1 assert ( @@ -277,7 +280,9 @@ def test_generate_links_for_sitemap_opt_out(mock_get_config: MagicMock): mock_get_config: Mock for the get_config function. """ mock_get_config.return_value.frontend_path = "" - mock_get_config.return_value.deploy_url = None # No deploy URL + mock_get_config.return_value.frontend_url = ( + "http://localhost:3000" # deploy_url unset + ) def mock_component(): return rx.text("Unlisted") @@ -306,7 +311,7 @@ def mock_component(): ] links = generate_links_for_sitemap(pages, trailing_slash="preserve") assert len(links) == 1 - assert {"loc": "/listed"} in links + assert {"loc": "http://localhost:3000/listed"} in links @patch("reflex_base.config.get_config") @@ -317,7 +322,7 @@ def test_generate_links_for_sitemap_loc_override(mock_get_config: MagicMock): mock_get_config: Mock for the get_config function. """ mock_get_config.return_value.frontend_path = "" - mock_get_config.return_value.deploy_url = "http://localhost:3000" + mock_get_config.return_value.frontend_url = "http://localhost:3000" def mock_component(): return rx.text("Test") @@ -358,7 +363,7 @@ def test_generate_links_for_sitemap_priority_clamping(mock_get_config: MagicMock mock_get_config: Mock for the get_config function. """ mock_get_config.return_value.frontend_path = "" - mock_get_config.return_value.deploy_url = "https://example.com" + mock_get_config.return_value.frontend_url = "https://example.com" def mock_component(): return rx.text("Test") @@ -409,11 +414,14 @@ def mock_component(): def test_generate_links_for_sitemap_no_deploy_url(mock_get_config: MagicMock): """Test generate_links_for_sitemap when deploy_url is not set. + ``Config.frontend_url`` then falls back to the local frontend origin, so the + links are still absolute. + Args: mock_get_config: Mock for the get_config function. """ mock_get_config.return_value.frontend_path = "" - mock_get_config.return_value.deploy_url = None + mock_get_config.return_value.frontend_url = "http://localhost:3000" def mock_component(): return rx.text("Test") @@ -452,7 +460,11 @@ def mock_component(): ] links = generate_links_for_sitemap(pages, trailing_slash="preserve") assert len(links) == 3 - expected_links = [{"loc": "/home"}, {"loc": "/about"}, {"loc": "/"}] + expected_links = [ + {"loc": "http://localhost:3000/home"}, + {"loc": "http://localhost:3000/about"}, + {"loc": "http://localhost:3000/"}, + ] for expected_link in expected_links: assert expected_link in links @@ -467,7 +479,7 @@ def test_generate_links_for_sitemap_deploy_url_trailing_slash( mock_get_config: Mock for the get_config function. """ mock_get_config.return_value.frontend_path = "" - mock_get_config.return_value.deploy_url = "https://example.com/" + mock_get_config.return_value.frontend_url = "https://example.com/" def mock_component(): return rx.text("Test") @@ -497,7 +509,7 @@ def test_generate_links_for_sitemap_loc_leading_slash(mock_get_config: MagicMock mock_get_config: Mock for the get_config function. """ mock_get_config.return_value.frontend_path = "" - mock_get_config.return_value.deploy_url = "https://example.com" + mock_get_config.return_value.frontend_url = "https://example.com" def mock_component(): return rx.text("Test") @@ -527,7 +539,7 @@ def test_generate_links_for_sitemap_loc_full_url(mock_get_config: MagicMock): mock_get_config: Mock for the get_config function. """ mock_get_config.return_value.frontend_path = "" - mock_get_config.return_value.deploy_url = "https://example.com" + mock_get_config.return_value.frontend_url = "https://example.com" def mock_component(): return rx.text("Test") @@ -557,7 +569,7 @@ def test_generate_links_trailing_slash_always(mock_get_config: MagicMock): mock_get_config: Mock for the get_config function. """ mock_get_config.return_value.frontend_path = "" - mock_get_config.return_value.deploy_url = "https://example.com" + mock_get_config.return_value.frontend_url = "https://example.com" def mock_component(): return rx.text("Test") @@ -609,7 +621,7 @@ def test_generate_links_trailing_slash_never(mock_get_config: MagicMock): mock_get_config: Mock for the get_config function. """ mock_get_config.return_value.frontend_path = "" - mock_get_config.return_value.deploy_url = "https://example.com" + mock_get_config.return_value.frontend_url = "https://example.com" def mock_component(): return rx.text("Test") @@ -658,13 +670,13 @@ def mock_component(): def test_generate_links_trailing_slash_never_no_deploy_url_index( mock_get_config: MagicMock, ): - """Test that trailing_slash='never' with no deploy_url preserves '/' for index. + """Test that trailing_slash='never' with no deploy_url strips the index slash. Args: mock_get_config: Mock for the get_config function. """ mock_get_config.return_value.frontend_path = "" - mock_get_config.return_value.deploy_url = None + mock_get_config.return_value.frontend_url = "http://localhost:3000" def mock_component(): return rx.text("Test") @@ -693,9 +705,16 @@ def mock_component(): ] links = generate_links_for_sitemap(pages, trailing_slash="never") assert len(links) == 2 - # "/" should not become "" — it should be preserved as "/" - assert {"loc": "/"} in links - assert {"loc": "/about"} in links + assert {"loc": "http://localhost:3000"} in links + assert {"loc": "http://localhost:3000/about"} in links + + +def test_configuration_with_loc_relative_index_keeps_slash(): + """Without a base URL, trailing_slash='never' must not turn '/' into ''.""" + link = configuration_with_loc( + config={}, deploy_url=None, loc="/", trailing_slash="never" + ) + assert link == {"loc": "/"} @patch("reflex_base.config.get_config") @@ -706,7 +725,7 @@ def test_generate_links_trailing_slash_preserve(mock_get_config: MagicMock): mock_get_config: Mock for the get_config function. """ mock_get_config.return_value.frontend_path = "" - mock_get_config.return_value.deploy_url = "https://example.com" + mock_get_config.return_value.frontend_url = "https://example.com" def mock_component(): return rx.text("Test") @@ -747,7 +766,7 @@ def test_default_sitemap_locations_include_frontend_path( mock_get_config, frontend_path ): """Mounted apps must advertise their public home and article URLs.""" - mock_get_config.return_value.deploy_url = "https://example.com" + mock_get_config.return_value.frontend_url = "https://example.com" mock_get_config.return_value.frontend_path = frontend_path pages = [MagicMock(route=route, context={}) for route in ("index", "intro")] links = generate_links_for_sitemap(pages, trailing_slash="always") diff --git a/tests/units/test_app.py b/tests/units/test_app.py index e00eb30c639..49f3d905927 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -57,7 +57,8 @@ from starlette.exceptions import HTTPException from starlette.middleware.cors import CORSMiddleware from starlette.requests import ClientDisconnect, Request -from starlette.responses import JSONResponse, StreamingResponse +from starlette.responses import JSONResponse, PlainTextResponse, StreamingResponse +from starlette.routing import Route from starlette.testclient import TestClient from starlette_admin.auth import AuthProvider @@ -68,6 +69,7 @@ App, ComponentCallable, EventNamespace, + _cors_allowed_origins, _decode_asgi_headers, _DrainOversizedBodyMiddleware, _RootMiddleware, @@ -6016,6 +6018,173 @@ def reader(): assert json.loads(marker.read_text()) == routes +@pytest.mark.parametrize( + ("prod", "config_kwargs", "expected"), + [ + (False, {}, ["*"]), + (True, {}, None), + (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, + {"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] | None, +): + """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"] + + +@pytest.mark.parametrize( + ("deploy_url", "origin", "allowed"), + [ + ("https://example.com:443/app", "https://example.com", True), + ("https://example.com", "https://evil.example", False), + ], +) +def test_cors_middleware_enforces_resolved_origins( + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + deploy_url: str, + 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) + monkeypatch.delenv("REFLEX_DEPLOY_URL", 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 + + +def test_cors_left_unconfigured_in_prod_without_deploy_url( + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +): + """Without deploy_url in prod mode, only same-origin requests reach the backend. + + No CORS middleware is installed, so browsers refuse cross-origin reads, and + Socket.IO falls back to its same-origin default. + + Args: + mocker: The pytest mocker fixture. + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.delenv("REFLEX_CORS_ALLOWED_ORIGINS", raising=False) + monkeypatch.delenv("REFLEX_DEPLOY_URL", raising=False) + 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) + + api = Starlette() + App._add_cors(api) + assert api.user_middleware == [] + + app = App(_state=rx.State) + app._setup_state() + assert app.sio is not None + assert app.sio.eio.cors_allowed_origins is None + environ = {"wsgi.url_scheme": "https", "HTTP_HOST": "app.example.com"} + allowed = app.sio.eio._cors_allowed_origins(environ) + assert "https://app.example.com" in allowed + assert "https://evil.example" not in allowed + + mocker.patch.object(app, "_compile") + asgi_app = app() + assert isinstance(asgi_app, _RootMiddleware) + assert not isinstance(asgi_app.app, CORSMiddleware) + + def _polling_engine_app(max_size: int): """Build the socket ASGI app the way ``App._setup_state`` mounts it. diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 4ec4bc3d68c..db6ed4cab89 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -431,7 +431,7 @@ def test_get_event_namespace_matches_mount_path(mocker: MockerFixture): { "api_url": "http://localhost:8001", "backend_port": 8001, - "deploy_url": "http://localhost:3001", + "deploy_url": None, "frontend_port": 3001, }, ), @@ -443,7 +443,7 @@ def test_get_event_namespace_matches_mount_path(mocker: MockerFixture): { "api_url": "http://localhost:8002", "backend_port": 8002, - "deploy_url": "http://localhost:3001", + "deploy_url": None, "frontend_port": 3001, }, ), @@ -455,7 +455,7 @@ def test_get_event_namespace_matches_mount_path(mocker: MockerFixture): { "api_url": "http://localhost:8002", "backend_port": 8002, - "deploy_url": "http://localhost:3005", + "deploy_url": None, "frontend_port": 3005, }, ), @@ -1701,3 +1701,25 @@ def test_get_config_reload_deprecated(mocker: MockerFixture): # The freshly loaded config stays cached on the context afterwards. assert reflex_base.config.get_config() is second deprecate.assert_called_once() + + +def test_frontend_url_falls_back_to_localhost_frontend_port( + monkeypatch: pytest.MonkeyPatch, +): + """frontend_url is deploy_url when set, otherwise localhost on the frontend port. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.delenv("REFLEX_DEPLOY_URL", raising=False) + monkeypatch.delenv("REFLEX_FRONTEND_PORT", raising=False) + config = rx.Config(app_name="a") + assert config.deploy_url is None + assert config.frontend_url == "http://localhost:3000" + + config = rx.Config(app_name="a", frontend_port=3005) + assert config.deploy_url is None + assert config.frontend_url == "http://localhost:3005" + + config = rx.Config(app_name="a", deploy_url="https://example.com") + assert config.frontend_url == "https://example.com"