Require auth session for media proxy

This commit is contained in:
Jage9
2026-03-08 21:44:27 -04:00
parent 47d4a61256
commit 6e8ecf44c1
6 changed files with 146 additions and 2 deletions

View File

@@ -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`. `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. If you deploy the PHP proxy some other way, you can still provide `CHGRID_HOST_ORIGIN` directly through your PHP/web-server environment.

View File

@@ -165,6 +165,73 @@ function load_proxy_host_origin()
return normalize_origin($config['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) function host_matches_suffix($host, $suffix)
{ {
if ($suffix === '') { if ($suffix === '') {
@@ -431,6 +498,7 @@ function resolve_safe_redirect_chain($initialUrl, $allowlistSuffixes, $requestHe
$allowlistEnv = getenv('CHGRID_MEDIA_PROXY_ALLOWLIST'); $allowlistEnv = getenv('CHGRID_MEDIA_PROXY_ALLOWLIST');
$allowlistSuffixes = parse_allowlist_suffixes($allowlistEnv); $allowlistSuffixes = parse_allowlist_suffixes($allowlistEnv);
$allowedOrigin = load_proxy_host_origin(); $allowedOrigin = load_proxy_host_origin();
$sessionCheckUrl = load_proxy_session_check_url();
if ($allowedOrigin === '') { if ($allowedOrigin === '') {
send_text(500, 'CHGRID_HOST_ORIGIN is required'); send_text(500, 'CHGRID_HOST_ORIGIN is required');
} }
@@ -453,6 +521,8 @@ if ($method !== 'GET' && $method !== 'HEAD') {
send_text(405, 'method not allowed'); send_text(405, 'method not allowed');
} }
require_valid_proxy_session($sessionCheckUrl);
$rawUrl = isset($_GET['url']) ? trim((string) $_GET['url']) : ''; $rawUrl = isset($_GET['url']) ? trim((string) $_GET['url']) : '';
if ($rawUrl === '') { if ($rawUrl === '') {
send_text(400, 'missing url query param'); send_text(400, 'missing url query param');

View File

@@ -39,12 +39,42 @@ if [[ -f "$SERVER_ENV_FILE" ]]; then
fi fi
if [[ -n "${CHGRID_HOST_ORIGIN:-}" ]]; then 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=${CHGRID_HOST_ORIGIN//\\/\\\\}
escaped_host_origin=${escaped_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" <<EOF cat > "$PUBLISH_DIR/media_proxy.config.php" <<EOF
<?php <?php
return array( return array(
'host_origin' => '$escaped_host_origin', 'host_origin' => '$escaped_host_origin',
'session_check_url' => '$escaped_session_check_url',
); );
EOF EOF
else else

View File

@@ -12,6 +12,7 @@
7. Server sends `auth_result`. 7. Server sends `auth_result`.
- includes role + permissions for authenticated session. - includes role + permissions for authenticated session.
8. Client persists authenticated session into a server-managed `HttpOnly` cookie via `GET /auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`), and clears it via `GET /auth/session/clear` (`X-Chgrid-Auth-Client: 1`) on logout/session errors. 8. Client persists authenticated session into a server-managed `HttpOnly` cookie via `GET /auth/session/set` (`Authorization: Bearer <sessionToken>`, `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. 9. Server sends `welcome` with users/items snapshot.
10. Client: 10. Client:
- applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering - applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering

View File

@@ -135,6 +135,7 @@ AUTH_SESSION_COOKIE_NAME = "chgrid_session_token"
AUTH_SESSION_COOKIE_MAX_AGE_SECONDS = 14 * 24 * 60 * 60 AUTH_SESSION_COOKIE_MAX_AGE_SECONDS = 14 * 24 * 60 * 60
AUTH_SESSION_COOKIE_SET_PATH = "/auth/session/set" AUTH_SESSION_COOKIE_SET_PATH = "/auth/session/set"
AUTH_SESSION_COOKIE_CLEAR_PATH = "/auth/session/clear" 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_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_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." 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.""" """Handle lightweight same-origin auth cookie set/clear HTTP endpoints."""
path = request.path.split("?", 1)[0] 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 return None
headers = Headers() headers = Headers()
@@ -311,6 +312,17 @@ class SignalingServer:
if client_header != "1": if client_header != "1":
return HttpResponse(400, "Bad Request", headers, b"missing client header") 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: if path == AUTH_SESSION_COOKIE_CLEAR_PATH:
headers["Set-Cookie"] = self._clear_session_cookie_header(request=request) headers["Set-Cookie"] = self._clear_session_cookie_header(request=request)
return HttpResponse(200, "OK", headers, b"cleared") return HttpResponse(200, "OK", headers, b"cleared")

View File

@@ -9,6 +9,7 @@ from websockets.http11 import Request
from app.server import ( from app.server import (
AUTH_SESSION_COOKIE_CLIENT_HEADER, AUTH_SESSION_COOKIE_CLIENT_HEADER,
AUTH_SESSION_COOKIE_CHECK_PATH,
AUTH_SESSION_COOKIE_CLEAR_PATH, AUTH_SESSION_COOKIE_CLEAR_PATH,
AUTH_SESSION_COOKIE_NAME, AUTH_SESSION_COOKIE_NAME,
AUTH_SESSION_COOKIE_SET_PATH, AUTH_SESSION_COOKIE_SET_PATH,
@@ -61,6 +62,36 @@ async def test_session_cookie_clear_endpoint_expires_cookie() -> None:
assert "HttpOnly" in set_cookie 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: def test_session_token_from_websocket_cookie_reads_named_cookie() -> None:
server = SignalingServer("127.0.0.1", 8765, None, None) server = SignalingServer("127.0.0.1", 8765, None, None)
websocket = SimpleNamespace( websocket = SimpleNamespace(