From ba34ce4e9ba97aca2013839dd74950a9ab61fef8 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sun, 8 Mar 2026 21:58:19 -0400 Subject: [PATCH] Tighten auth helper origin checks --- .gitignore | 1 + README.md | 5 +++- docs/protocol-notes.md | 2 +- server/app/server.py | 16 +++++++++++ server/tests/test_http_session_cookie.py | 34 +++++++++++++++++++----- 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 3bf520c..02a6de2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Local config/state +server/.env server/config.toml server/runtime/ diff --git a/README.md b/README.md index c28e8da..012fbd5 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Chat Grid is designed to be run on a secure server with users connecting via a w ```bash cd server cp config.example.toml config.toml +cp .env.sample .env +# for local dev set CHGRID_HOST_ORIGIN=http://localhost:5173 in .env uv run python main.py --allow-insecure-ws ``` @@ -27,7 +29,8 @@ 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. - 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. +- Server runtime env lives in `server/.env`; `server/.env.sample` shows the required variables. +- `deploy/scripts/install_server.sh` creates `server/.env` with `CHGRID_AUTH_SECRET` automatically if missing. Common server overrides: - `uv run python main.py --config /path/to/config.toml` diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 5cb12d5..8185fd5 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -128,7 +128,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - accepts browser sockets only when websocket `Origin` matches `CHGRID_HOST_ORIGIN` - reads `chgrid_session_token` from websocket `Cookie` header - attempts resume before sending `auth_required` - - exposes `GET /auth/session/clear` to expire the `HttpOnly` cookie (`X-Chgrid-Auth-Client: 1` required) + - exposes `GET /auth/session/clear` to expire the `HttpOnly` cookie (`X-Chgrid-Auth-Client: 1` and matching `Origin` required) - Server applies auth hardening before accepting login/register/resume: - login/register PBKDF2 work runs off the event loop in bounded worker concurrency - repeated auth failures are rate-limited by IP and IP+identity windows diff --git a/server/app/server.py b/server/app/server.py index b544fcb..c749f7b 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -288,6 +288,20 @@ class SignalingServer: secure = "; Secure" if self._session_cookie_secure(request) else "" return f"{AUTH_SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}" + def _origin_allowed(self, request: HttpRequest) -> bool: + """Return whether one auth helper HTTP request comes from the configured app origin.""" + + if not self.host_origin: + return False + raw_origin = str(request.headers.get("Origin", "")).strip() + if not raw_origin: + return False + try: + origin = normalize_origin(raw_origin) + except ValueError: + return False + return origin == self.host_origin + @staticmethod def _cookie_value(cookie_header: str, name: str) -> str: """Extract one cookie value by name from a Cookie header.""" @@ -311,6 +325,8 @@ class SignalingServer: client_header = str(request.headers.get(AUTH_SESSION_COOKIE_CLIENT_HEADER, "")).strip() if client_header != "1": return HttpResponse(400, "Bad Request", headers, b"missing client header") + if not self._origin_allowed(request): + return HttpResponse(403, "Forbidden", headers, b"origin not allowed") if path == AUTH_SESSION_COOKIE_CHECK_PATH: cookie_header = str(request.headers.get("Cookie", "")).strip() diff --git a/server/tests/test_http_session_cookie.py b/server/tests/test_http_session_cookie.py index a3be3b9..6e92d59 100644 --- a/server/tests/test_http_session_cookie.py +++ b/server/tests/test_http_session_cookie.py @@ -26,7 +26,7 @@ def _request(path: str, headers: dict[str, str] | None = None) -> Request: @pytest.mark.asyncio async def test_session_cookie_set_endpoint_sets_httponly_cookie() -> None: - server = SignalingServer("127.0.0.1", 8765, None, None) + server = SignalingServer("127.0.0.1", 8765, None, None, host_origin="https://example.com") username = f"user_{uuid.uuid4().hex[:8]}" session = server.auth_service.register(username, "password99") request = _request( @@ -34,6 +34,7 @@ async def test_session_cookie_set_endpoint_sets_httponly_cookie() -> None: headers={ AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", "Authorization": f"Bearer {session.token}", + "Origin": "https://example.com", }, ) @@ -49,8 +50,11 @@ async def test_session_cookie_set_endpoint_sets_httponly_cookie() -> None: @pytest.mark.asyncio async def test_session_cookie_clear_endpoint_expires_cookie() -> None: - server = SignalingServer("127.0.0.1", 8765, None, None) - request = _request(AUTH_SESSION_COOKIE_CLEAR_PATH, headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1"}) + server = SignalingServer("127.0.0.1", 8765, None, None, host_origin="https://example.com") + request = _request( + AUTH_SESSION_COOKIE_CLEAR_PATH, + headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", "Origin": "https://example.com"}, + ) response = await server._process_http_request(SimpleNamespace(), request) @@ -64,7 +68,7 @@ async def test_session_cookie_clear_endpoint_expires_cookie() -> None: @pytest.mark.asyncio async def test_session_cookie_check_endpoint_accepts_valid_cookie() -> None: - server = SignalingServer("127.0.0.1", 8765, None, None) + server = SignalingServer("127.0.0.1", 8765, None, None, host_origin="https://example.com") username = f"user_{uuid.uuid4().hex[:8]}" session = server.auth_service.register(username, "password99") request = _request( @@ -72,6 +76,7 @@ async def test_session_cookie_check_endpoint_accepts_valid_cookie() -> None: headers={ AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", "Cookie": f"{AUTH_SESSION_COOKIE_NAME}={session.token}", + "Origin": "https://example.com", }, ) @@ -83,8 +88,11 @@ async def test_session_cookie_check_endpoint_accepts_valid_cookie() -> None: @pytest.mark.asyncio async def test_session_cookie_check_endpoint_rejects_missing_cookie() -> None: - server = SignalingServer("127.0.0.1", 8765, None, None) - request = _request(AUTH_SESSION_COOKIE_CHECK_PATH, headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1"}) + server = SignalingServer("127.0.0.1", 8765, None, None, host_origin="https://example.com") + request = _request( + AUTH_SESSION_COOKIE_CHECK_PATH, + headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", "Origin": "https://example.com"}, + ) response = await server._process_http_request(SimpleNamespace(), request) @@ -92,6 +100,20 @@ async def test_session_cookie_check_endpoint_rejects_missing_cookie() -> None: assert response.status_code == 401 +@pytest.mark.asyncio +async def test_session_cookie_helpers_reject_wrong_origin() -> None: + server = SignalingServer("127.0.0.1", 8765, None, None, host_origin="https://example.com") + request = _request( + AUTH_SESSION_COOKIE_CLEAR_PATH, + headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", "Origin": "https://evil.example.com"}, + ) + + response = await server._process_http_request(SimpleNamespace(), request) + + assert response is not None + assert response.status_code == 403 + + def test_session_token_from_websocket_cookie_reads_named_cookie() -> None: server = SignalingServer("127.0.0.1", 8765, None, None) websocket = SimpleNamespace(