Skip to content
Merged
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
6 changes: 6 additions & 0 deletions remotestate-py/CHANGES.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
19 changes: 18 additions & 1 deletion remotestate-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
16 changes: 14 additions & 2 deletions remotestate-py/src/remotestate/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 12 additions & 1 deletion remotestate-py/src/remotestate/server.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions remotestate-py/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

from remotestate.protocol import (
ActionMessage,
Expand Down Expand Up @@ -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()

Expand Down