From cf30229b37d376029e32f7340ade50f87dc575cf Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sat, 28 Feb 2026 04:47:07 -0500 Subject: [PATCH] Enforce websocket origin allowlist with secure-mode config --- README.md | 1 + docs/local.md | 1 + docs/protocol-notes.md | 1 + server/README.md | 3 +++ server/app/config.py | 1 + server/app/server.py | 35 +++++++++++++++++++++++++++++- server/config.example.toml | 4 ++++ server/tests/test_config.py | 14 ++++++++++++ server/tests/test_origin_policy.py | 28 ++++++++++++++++++++++++ 9 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 server/tests/test_origin_policy.py diff --git a/README.md b/README.md index 8bf5690..c765016 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ npm run dev Notes: - Server defaults to `config.toml` when present. - Server bind/port defaults are `127.0.0.1:8765` unless changed in config or CLI flags. +- Server websocket origin checks use `network.allowed_origins`; in secure mode (`allow_insecure_ws=false`), configure your real `https://` site origins. - Client dev defaults to Vite local host/port (`localhost:5173`) unless flags override. - Auth requires `CHGRID_AUTH_SECRET` in server environment; `deploy/scripts/install_server.sh` creates `server/.env` with this value automatically if missing. - Saved login/session persistence uses a server-set `HttpOnly` cookie (`chgrid_session_token`). diff --git a/docs/local.md b/docs/local.md index 4a1742a..c525315 100644 --- a/docs/local.md +++ b/docs/local.md @@ -20,6 +20,7 @@ Defaults: - Server reads `config.toml` automatically when present. - Server default bind/port is `127.0.0.1:8765`. - Server defaults to TLS-required unless you set `network.allow_insecure_ws=true` or pass `--allow-insecure-ws` for local/dev. +- In local/dev insecure mode (`allow_insecure_ws=true`), websocket Origin allowlist defaults to `http://localhost:5173` and `http://127.0.0.1:5173` when `network.allowed_origins` is empty. - Client dev default is `localhost:5173`. - Auth requires `CHGRID_AUTH_SECRET` in environment. - Saved login uses server-managed `HttpOnly` cookie (`chgrid_session_token`) via `GET /auth/session/set` and `GET /auth/session/clear` (both require `X-Chgrid-Auth-Client: 1`). diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index d39451b..3a08ea2 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -111,6 +111,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - Server is authoritative for all action validation and normalization. - Server is authoritative for movement acceptance (bounds + rate/delta checks). +- Server enforces websocket Origin allowlist at handshake (`network.allowed_origins`). - Server persists account state (last nickname + last position) and restores spawn from that state on auth login/resume. - Server also supports websocket handshake cookie resume: - reads `chgrid_session_token` from websocket `Cookie` header diff --git a/server/README.md b/server/README.md index c6c9a3c..059c973 100644 --- a/server/README.md +++ b/server/README.md @@ -13,10 +13,13 @@ Key options: - `server.bind_ip`, `server.port` - `network.max_message_bytes` - `network.allow_insecure_ws` +- `network.allowed_origins` - `tls.cert_file`, `tls.key_file` If `network.allow_insecure_ws = false`, TLS cert/key are required and server runs as `wss://`. For local/dev without TLS, either set `network.allow_insecure_ws = true` or pass `--allow-insecure-ws`. +When insecure ws is disabled, `network.allowed_origins` must list your deployed `https://` origins. +When insecure ws is enabled and `network.allowed_origins` is empty, localhost dev origins are allowed automatically. ## Run diff --git a/server/app/config.py b/server/app/config.py index 1e9ceb9..b90118b 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -20,6 +20,7 @@ class NetworkConfigSection(BaseModel): max_message_bytes: int = Field(default=2_000_000, gt=0) allow_insecure_ws: bool = False + allowed_origins: list[str] = Field(default_factory=list) class TlsConfigSection(BaseModel): diff --git a/server/app/server.py b/server/app/server.py index 50ffebe..e59234f 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -127,6 +127,7 @@ AUTH_SESSION_COOKIE_CLEAR_PATH = "/auth/session/clear" AUTH_SESSION_COOKIE_CLIENT_HEADER = "X-Chgrid-Auth-Client" AUTH_LOGIN_FAILURE_MESSAGE = "We couldn't log you in. Check your details and try again." AUTH_RESUME_FAILURE_MESSAGE = "We couldn't restore your session. Please log in again." +LOCAL_DEV_ALLOWED_ORIGINS: tuple[str, ...] = ("http://localhost:5173", "http://127.0.0.1:5173") ADMIN_MENU_ACTION_DEFINITIONS: tuple[dict[str, str], ...] = ( {"id": "manage_roles", "label": "Role management", "permission": "role.manage"}, {"id": "change_user_role", "label": "Change user role", "permission": "user.change_role"}, @@ -144,6 +145,7 @@ class SignalingServer: port: int, ssl_cert: str | None, ssl_key: str | None, + allowed_origins: tuple[str, ...] | list[str] | None = None, auth_db_path: Path | None = None, auth_token_hash_secret: str = "dev-secret", password_min_length: int = 8, @@ -161,6 +163,7 @@ class SignalingServer: self.host = host self.port = port self.max_message_size = max_message_size + self.allowed_origins = tuple(allowed_origins or ()) self._ssl_context = self._build_ssl_context(ssl_cert, ssl_key) self.clients: dict[ServerConnection, ClientConnection] = {} resolved_auth_db_path = auth_db_path or Path.cwd() / "runtime" / "chatgrid.db" @@ -1302,6 +1305,7 @@ class SignalingServer: self._handle_client, self.host, self.port, + origins=self.allowed_origins if self.allowed_origins else None, ssl=self._ssl_context, max_size=self.max_message_size, process_request=self._process_http_request, @@ -2860,6 +2864,10 @@ def run() -> None: raise SystemExit( "TLS is required when insecure ws is disabled. Set tls.cert_file/tls.key_file in config.toml." ) + try: + allowed_origins = _resolve_allowed_origins(config.network.allowed_origins, allow_insecure_ws=allow_insecure_ws) + except ValueError as exc: + raise SystemExit(str(exc)) from exc auth_secret = os.getenv("CHGRID_AUTH_SECRET", "").strip() if not auth_secret: @@ -2992,6 +3000,31 @@ def run() -> None: grid_size=config.world.grid_size, state_save_debounce_ms=config.storage.state_save_debounce_ms, state_save_max_delay_ms=config.storage.state_save_max_delay_ms, + allowed_origins=allowed_origins, ) asyncio.run(server.start()) - ItemClockAnnouncePacket, + + +def _resolve_allowed_origins(raw_origins: list[str], *, allow_insecure_ws: bool) -> tuple[str, ...]: + """Resolve websocket Origin allowlist from config and transport mode.""" + + normalized: list[str] = [] + for origin in raw_origins: + candidate = str(origin or "").strip() + if not candidate or candidate in normalized: + continue + normalized.append(candidate) + + if allow_insecure_ws: + if normalized: + return tuple(normalized) + return LOCAL_DEV_ALLOWED_ORIGINS + + if not normalized: + raise ValueError( + "network.allowed_origins must list your https web origin(s) when insecure ws is disabled." + ) + non_https = [origin for origin in normalized if not origin.lower().startswith("https://")] + if non_https: + raise ValueError("network.allowed_origins must use https origins when insecure ws is disabled.") + return tuple(normalized) diff --git a/server/config.example.toml b/server/config.example.toml index 64b02ec..20b2862 100644 --- a/server/config.example.toml +++ b/server/config.example.toml @@ -9,6 +9,10 @@ port = 8765 max_message_bytes = 2000000 # Secure-by-default: TLS is required unless you explicitly set this to true for local/dev. allow_insecure_ws = false +# Allowed websocket request Origin values. +# Production: list your deployed https web origins explicitly. +# Local/dev: when allow_insecure_ws=true and this list is empty, localhost defaults are used. +allowed_origins = ["https://bestmidi.com", "https://www.bestmidi.com"] [tls] # Required when allow_insecure_ws = false. diff --git a/server/tests/test_config.py b/server/tests/test_config.py index a864a14..884c7de 100644 --- a/server/tests/test_config.py +++ b/server/tests/test_config.py @@ -9,6 +9,7 @@ def test_load_config_defaults_when_path_none() -> None: cfg = load_config(None) assert cfg.server.bind_ip == "127.0.0.1" assert cfg.network.allow_insecure_ws is False + assert cfg.network.allowed_origins == [] assert cfg.storage.state_file == "runtime/items.json" assert cfg.storage.state_save_debounce_ms == 200 assert cfg.storage.state_save_max_delay_ms == 1000 @@ -43,3 +44,16 @@ state_save_max_delay_ms = 900 cfg = load_config(config_path) assert cfg.storage.state_save_debounce_ms == 150 assert cfg.storage.state_save_max_delay_ms == 900 + + +def test_load_config_reads_allowed_origins(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text( + """ +[network] +allow_insecure_ws = true +allowed_origins = ["https://bestmidi.com", "https://www.bestmidi.com"] +""".strip() + ) + cfg = load_config(config_path) + assert cfg.network.allowed_origins == ["https://bestmidi.com", "https://www.bestmidi.com"] diff --git a/server/tests/test_origin_policy.py b/server/tests/test_origin_policy.py new file mode 100644 index 0000000..67377cc --- /dev/null +++ b/server/tests/test_origin_policy.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import pytest + +from app.server import LOCAL_DEV_ALLOWED_ORIGINS, _resolve_allowed_origins + + +def test_resolve_allowed_origins_defaults_localhost_for_insecure_mode() -> None: + origins = _resolve_allowed_origins([], allow_insecure_ws=True) + assert origins == LOCAL_DEV_ALLOWED_ORIGINS + + +def test_resolve_allowed_origins_requires_values_for_secure_mode() -> None: + with pytest.raises(ValueError): + _resolve_allowed_origins([], allow_insecure_ws=False) + + +def test_resolve_allowed_origins_requires_https_in_secure_mode() -> None: + with pytest.raises(ValueError): + _resolve_allowed_origins(["http://localhost:5173"], allow_insecure_ws=False) + + +def test_resolve_allowed_origins_normalizes_and_deduplicates() -> None: + origins = _resolve_allowed_origins( + [" https://bestmidi.com ", "https://bestmidi.com", "https://www.bestmidi.com"], + allow_insecure_ws=False, + ) + assert origins == ("https://bestmidi.com", "https://www.bestmidi.com")