Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/advanced_onboarding/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions news/+cors-prod-default.breaking.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions packages/reflex-base/news/+cors-prod-default.docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Document that the default `cors_allowed_origins` is restricted to the origin of `deploy_url` in prod mode.
Comment thread
FarhanAliRaza marked this conversation as resolved.
2 changes: 1 addition & 1 deletion packages/reflex-base/src/reflex_base/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 32 additions & 8 deletions reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down
135 changes: 134 additions & 1 deletion tests/units/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -66,6 +67,7 @@
ComponentCallable,
EventNamespace,
_ContextMiddleware,
_cors_allowed_origins,
_decode_asgi_headers,
_sio_dumps,
_sio_loads,
Expand Down Expand Up @@ -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"]
Comment thread
FarhanAliRaza marked this conversation as resolved.


@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)
Comment thread
FarhanAliRaza marked this conversation as resolved.
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
Loading