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..89a5230e902 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -387,6 +387,36 @@ 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. + + 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 [] + # 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. @@ -626,13 +656,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 +956,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..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 @@ -66,6 +67,7 @@ ComponentCallable, EventNamespace, _ContextMiddleware, + _cors_allowed_origins, _decode_asgi_headers, _sio_dumps, _sio_loads, @@ -5795,3 +5797,134 @@ 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": "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, + {"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"] + + +@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