diff --git a/remotestate-py/CHANGES.md b/remotestate-py/CHANGES.md index d93d693..0ce2ff1 100644 --- a/remotestate-py/CHANGES.md +++ b/remotestate-py/CHANGES.md @@ -1,3 +1,9 @@ +## Version 0.3.4 (in development) + +- Added opt-in CORS configuration through the `cors_origins` + argument to `serve()`. (#49) + + ## Version 0.3.3 - Improved usability of the Python `Store` class: diff --git a/remotestate-py/README.md b/remotestate-py/README.md index 90a9621..d923393 100644 --- a/remotestate-py/README.md +++ b/remotestate-py/README.md @@ -163,13 +163,15 @@ creates one. ## Serving -`serve(service, *, ui_dist, mounts, app, display, width, height, host, port, **uvicorn_settings)` +`serve(service, *, ui_dist, mounts, app, cors_origins, display, width, height, host, port, **uvicorn_settings)` starts the RemoteState server and connects it to a frontend bundle. - `service` is a `Service` instance - `ui_dist` can be a local React build directory or an HTTP(S) URL - `mounts` adds additional static paths - `app` lets you supply your own FastAPI app +- `cors_origins` optionally allows browser HTTP requests from specified origins; + cross-origin HTTP access is disabled by default - `display` controls how the UI is shown: `"auto"`, `"browser"`, `"notebook"`, `"none"`, or a callback - `host` and `port` configure the backend server @@ -186,6 +188,21 @@ print("WebSocket URL: ", result.ws_url) print("UI Base URL: ", result.ui_base_url) ``` +### Cross-origin access + +When the frontend is hosted on a different origin, explicitly allow that origin: + +```python +rs.serve( + CounterService(), + ui_dist="https://ui.example.com", + cors_origins=["https://ui.example.com"], +) +``` + +Only list origins you trust. `cors_origins` enables all HTTP methods and request +headers for the listed origins, while leaving CORS disabled for every other origin. + ## Paths `remotestate.path` exposes the parsed path types used by `Store.default_factory` and other diff --git a/remotestate-py/src/remotestate/serve.py b/remotestate-py/src/remotestate/serve.py index d431d84..7c9b8a2 100644 --- a/remotestate-py/src/remotestate/serve.py +++ b/remotestate-py/src/remotestate/serve.py @@ -4,7 +4,7 @@ import threading import time import webbrowser -from collections.abc import Callable +from collections.abc import Callable, Sequence from dataclasses import dataclass, field from html import escape from typing import Any, Literal, TypeGuard @@ -101,6 +101,7 @@ def serve( ui_dist: PathLike | StaticFiles | None = None, mounts: dict[str, PathLike | StaticFiles] | None = None, app: FastAPI | None = None, + cors_origins: Sequence[str] = (), display: Display = "auto", width: int | str = DEFAULT_NOTEBOOK_WIDTH, height: int | str = DEFAULT_NOTEBOOK_HEIGHT, @@ -122,6 +123,12 @@ def serve( app: A FastAPI instance to use. If not provided, a new instance is created and passed to `Service._init_app(app)` so that it can be initialized by the user. + cors_origins: Origins allowed to make cross-origin HTTP requests. CORS is + disabled by default. Configured origins may use all HTTP methods and + request headers. + May also include wildcard "*", which permits HTTP requests from any origin. + This is suitable only for intentionally public, unauthenticated endpoints; + it does not allow browser-managed credentials. display: Controls how the UI is shown after the server starts. Use "auto" to render inline in notebooks and open a browser otherwise, "browser", "notebook", "none", or a callback that accepts @@ -155,7 +162,12 @@ def serve( server_url=server_url, ) - rs_server = Server(service=service, mounts=mounts_, app=app) + rs_server = Server( + service=service, + mounts=mounts_, + app=app, + cors_origins=cors_origins, + ) uvicorn_settings.update(host=host, port=port) if "log_config" not in uvicorn_settings: diff --git a/remotestate-py/src/remotestate/server.py b/remotestate-py/src/remotestate/server.py index b74e21c..174cad4 100644 --- a/remotestate-py/src/remotestate/server.py +++ b/remotestate-py/src/remotestate/server.py @@ -1,12 +1,13 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Awaitable, Callable, Coroutine, Sequence from typing import Any from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.staticfiles import StaticFiles from pydantic import TypeAdapter +from starlette.middleware.cors import CORSMiddleware from starlette.staticfiles import PathLike from .context import _suppress_store_broadcast @@ -42,12 +43,22 @@ def __init__( *, mounts: dict[str, PathLike | StaticFiles] | None = None, app: FastAPI | None = None, + cors_origins: Sequence[str] = (), ) -> None: self._store = service.store self._service = service self._transport = WebSocketTransport() self._unsubscribe_store = self._store.subscribe(self._broadcast_store_update) self._app = app if app is not None else FastAPI() + if cors_origins: + self._app.add_middleware( + CORSMiddleware, + allow_origins=list(cors_origins), + allow_methods=["*"], + allow_headers=["*"], + # do not allow browser-managed credentials + allow_credentials=False, + ) self._init_app(mounts) if app is None: # noinspection PyProtectedMember diff --git a/remotestate-py/tests/test_server.py b/remotestate-py/tests/test_server.py index f9caf13..af72e92 100644 --- a/remotestate-py/tests/test_server.py +++ b/remotestate-py/tests/test_server.py @@ -2,6 +2,7 @@ import pytest from fastapi import FastAPI +from fastapi.testclient import TestClient from remotestate.protocol import ( ActionMessage, @@ -65,6 +66,52 @@ def test_configure_app_called(server): assert isinstance(service.the_app, FastAPI) +def test_cors_allows_configured_origin(service): + server = Server(service, cors_origins=["https://ui.example.com"]) + client = TestClient(server.app) + + response = client.get("/missing", headers={"Origin": "https://ui.example.com"}) + + assert response.headers["access-control-allow-origin"] == "https://ui.example.com" + assert response.headers["vary"] == "Origin" + + +def test_cors_handles_preflight_for_configured_origin(service): + server = Server(service, cors_origins=["https://ui.example.com"]) + client = TestClient(server.app) + + response = client.options( + "/missing", + headers={ + "Origin": "https://ui.example.com", + "Access-Control-Request-Method": "PATCH", + "Access-Control-Request-Headers": "X-Client-Version", + }, + ) + + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == "https://ui.example.com" + assert "PATCH" in response.headers["access-control-allow-methods"] + assert response.headers["access-control-allow-headers"] == "X-Client-Version" + + +def test_cors_does_not_allow_unconfigured_origins(service): + server = Server(service, cors_origins=["https://ui.example.com"]) + client = TestClient(server.app) + + response = client.get("/missing", headers={"Origin": "https://other.example.com"}) + + assert "access-control-allow-origin" not in response.headers + + +def test_cors_is_disabled_by_default(server): + client = TestClient(server.app) + + response = client.get("/missing", headers={"Origin": "https://ui.example.com"}) + + assert "access-control-allow-origin" not in response.headers + + def test_external_store_set_broadcasts_update(server): server._transport.send_nowait = MagicMock()