diff --git a/deploy/README.md b/deploy/README.md index 97e5f79..0d8f7c2 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -110,7 +110,7 @@ ProxyPassReverse /listen/8000/ http://127.0.0.1:8000/ `deploy/php/media_proxy.php` is copied into your publish directory by `deploy_client.sh`. -When `server/.env` contains `CHGRID_HOST_ORIGIN`, `deploy_client.sh` also generates `media_proxy.config.php` in the publish directory so the proxy can enforce the same origin without extra Apache-specific config. +When `server/.env` contains `CHGRID_HOST_ORIGIN`, `deploy_client.sh` also generates `media_proxy.config.php` in the publish directory so the proxy can enforce the same origin and validate authenticated sessions without extra Apache-specific config. The generated file derives the local auth-check URL from `server/config.toml`, so custom signaling ports continue to work. If you deploy the PHP proxy some other way, you can still provide `CHGRID_HOST_ORIGIN` directly through your PHP/web-server environment. diff --git a/deploy/php/media_proxy.php b/deploy/php/media_proxy.php index 4f008b1..8580c25 100644 --- a/deploy/php/media_proxy.php +++ b/deploy/php/media_proxy.php @@ -165,6 +165,73 @@ function load_proxy_host_origin() return normalize_origin($config['host_origin']); } +function load_proxy_session_check_url() +{ + $fromEnv = trim((string) getenv('CHGRID_MEDIA_PROXY_SESSION_CHECK_URL')); + if ($fromEnv !== '') { + return $fromEnv; + } + + $configPath = __DIR__ . '/media_proxy.config.php'; + if (!is_file($configPath)) { + return 'http://127.0.0.1:8765/auth/session/check'; + } + $config = require $configPath; + if (!is_array($config) || !isset($config['session_check_url'])) { + return 'http://127.0.0.1:8765/auth/session/check'; + } + $value = trim((string) $config['session_check_url']); + if ($value === '') { + return 'http://127.0.0.1:8765/auth/session/check'; + } + return $value; +} + +function require_valid_proxy_session($sessionCheckUrl) +{ + $cookieHeader = isset($_SERVER['HTTP_COOKIE']) ? trim((string) $_SERVER['HTTP_COOKIE']) : ''; + if ($cookieHeader === '') { + send_text(401, 'session required'); + } + if (!function_exists('curl_init')) { + send_text(500, 'curl extension is required'); + } + + $ch = curl_init(); + if (!$ch) { + send_text(500, 'proxy init failed'); + } + + curl_setopt($ch, CURLOPT_URL, $sessionCheckUrl); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); + curl_setopt($ch, CURLOPT_MAXREDIRS, 0); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_NOSIGNAL, true); + curl_setopt($ch, CURLOPT_USERAGENT, 'ChatGridMediaProxy/1.0'); + curl_setopt( + $ch, + CURLOPT_HTTPHEADER, + array( + 'Cookie: ' . $cookieHeader, + 'X-Chgrid-Auth-Client: 1', + ) + ); + + $response = curl_exec($ch); + if ($response === false) { + $error = curl_error($ch); + curl_close($ch); + send_text(502, 'session check failed: ' . $error); + } + $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + if ($status !== 204) { + send_text(401, 'session required'); + } +} + function host_matches_suffix($host, $suffix) { if ($suffix === '') { @@ -431,6 +498,7 @@ function resolve_safe_redirect_chain($initialUrl, $allowlistSuffixes, $requestHe $allowlistEnv = getenv('CHGRID_MEDIA_PROXY_ALLOWLIST'); $allowlistSuffixes = parse_allowlist_suffixes($allowlistEnv); $allowedOrigin = load_proxy_host_origin(); +$sessionCheckUrl = load_proxy_session_check_url(); if ($allowedOrigin === '') { send_text(500, 'CHGRID_HOST_ORIGIN is required'); } @@ -453,6 +521,8 @@ if ($method !== 'GET' && $method !== 'HEAD') { send_text(405, 'method not allowed'); } +require_valid_proxy_session($sessionCheckUrl); + $rawUrl = isset($_GET['url']) ? trim((string) $_GET['url']) : ''; if ($rawUrl === '') { send_text(400, 'missing url query param'); diff --git a/deploy/scripts/deploy_client.sh b/deploy/scripts/deploy_client.sh index 7e820db..3993d8c 100755 --- a/deploy/scripts/deploy_client.sh +++ b/deploy/scripts/deploy_client.sh @@ -39,12 +39,42 @@ if [[ -f "$SERVER_ENV_FILE" ]]; then fi if [[ -n "${CHGRID_HOST_ORIGIN:-}" ]]; then + session_check_url="$( + python3 - "$REPO_ROOT/server/config.toml" <<'PY' +from pathlib import Path +import sys +import tomllib + +config_path = Path(sys.argv[1]) +host = "127.0.0.1" +port = 8765 +if config_path.exists(): + with config_path.open("rb") as fp: + data = tomllib.load(fp) + server = data.get("server", {}) + bind_ip = str(server.get("bind_ip", host)).strip() or host + if bind_ip in {"0.0.0.0", ""}: + host = "127.0.0.1" + elif bind_ip == "::": + host = "[::1]" + else: + host = bind_ip + try: + port = int(server.get("port", port)) + except (TypeError, ValueError): + port = 8765 +print(f"http://{host}:{port}/auth/session/check") +PY + )" escaped_host_origin=${CHGRID_HOST_ORIGIN//\\/\\\\} escaped_host_origin=${escaped_host_origin//\'/\\\'} + escaped_session_check_url=${session_check_url//\\/\\\\} + escaped_session_check_url=${escaped_session_check_url//\'/\\\'} cat > "$PUBLISH_DIR/media_proxy.config.php" < '$escaped_host_origin', + 'session_check_url' => '$escaped_session_check_url', ); EOF else diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index f383271..ed26745 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -12,6 +12,7 @@ 7. Server sends `auth_result`. - includes role + permissions for authenticated session. 8. Client persists authenticated session into a server-managed `HttpOnly` cookie via `GET /auth/session/set` (`Authorization: Bearer `, `X-Chgrid-Auth-Client: 1`), and clears it via `GET /auth/session/clear` (`X-Chgrid-Auth-Client: 1`) on logout/session errors. + - the optional PHP media proxy validates that same cookie through `GET /auth/session/check` before relaying media 9. Server sends `welcome` with users/items snapshot. 10. Client: - applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering diff --git a/server/app/server.py b/server/app/server.py index a058069..b544fcb 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -135,6 +135,7 @@ AUTH_SESSION_COOKIE_NAME = "chgrid_session_token" AUTH_SESSION_COOKIE_MAX_AGE_SECONDS = 14 * 24 * 60 * 60 AUTH_SESSION_COOKIE_SET_PATH = "/auth/session/set" AUTH_SESSION_COOKIE_CLEAR_PATH = "/auth/session/clear" +AUTH_SESSION_COOKIE_CHECK_PATH = "/auth/session/check" 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." @@ -301,7 +302,7 @@ class SignalingServer: """Handle lightweight same-origin auth cookie set/clear HTTP endpoints.""" path = request.path.split("?", 1)[0] - if path not in {AUTH_SESSION_COOKIE_SET_PATH, AUTH_SESSION_COOKIE_CLEAR_PATH}: + if path not in {AUTH_SESSION_COOKIE_SET_PATH, AUTH_SESSION_COOKIE_CLEAR_PATH, AUTH_SESSION_COOKIE_CHECK_PATH}: return None headers = Headers() @@ -311,6 +312,17 @@ class SignalingServer: if client_header != "1": return HttpResponse(400, "Bad Request", headers, b"missing client header") + if path == AUTH_SESSION_COOKIE_CHECK_PATH: + cookie_header = str(request.headers.get("Cookie", "")).strip() + token = self._cookie_value(cookie_header, AUTH_SESSION_COOKIE_NAME) + if not token: + return HttpResponse(401, "Unauthorized", headers, b"missing session") + try: + self.auth_service.resume(token) + except AuthError: + return HttpResponse(401, "Unauthorized", headers, b"invalid session") + return HttpResponse(204, "No Content", headers, b"") + if path == AUTH_SESSION_COOKIE_CLEAR_PATH: headers["Set-Cookie"] = self._clear_session_cookie_header(request=request) return HttpResponse(200, "OK", headers, b"cleared") diff --git a/server/tests/test_http_session_cookie.py b/server/tests/test_http_session_cookie.py index dd51bb5..a3be3b9 100644 --- a/server/tests/test_http_session_cookie.py +++ b/server/tests/test_http_session_cookie.py @@ -9,6 +9,7 @@ from websockets.http11 import Request from app.server import ( AUTH_SESSION_COOKIE_CLIENT_HEADER, + AUTH_SESSION_COOKIE_CHECK_PATH, AUTH_SESSION_COOKIE_CLEAR_PATH, AUTH_SESSION_COOKIE_NAME, AUTH_SESSION_COOKIE_SET_PATH, @@ -61,6 +62,36 @@ async def test_session_cookie_clear_endpoint_expires_cookie() -> None: assert "HttpOnly" in set_cookie +@pytest.mark.asyncio +async def test_session_cookie_check_endpoint_accepts_valid_cookie() -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + username = f"user_{uuid.uuid4().hex[:8]}" + session = server.auth_service.register(username, "password99") + request = _request( + AUTH_SESSION_COOKIE_CHECK_PATH, + headers={ + AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", + "Cookie": f"{AUTH_SESSION_COOKIE_NAME}={session.token}", + }, + ) + + response = await server._process_http_request(SimpleNamespace(), request) + + assert response is not None + assert response.status_code == 204 + + +@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"}) + + response = await server._process_http_request(SimpleNamespace(), request) + + assert response is not None + assert response.status_code == 401 + + def test_session_token_from_websocket_cookie_reads_named_cookie() -> None: server = SignalingServer("127.0.0.1", 8765, None, None) websocket = SimpleNamespace(