Harden origin and media URL security

This commit is contained in:
Jage9
2026-03-08 20:51:50 -04:00
parent 3d69bbcea2
commit 78bc931cce
12 changed files with 378 additions and 14 deletions

View File

@@ -31,6 +31,12 @@ What this sets up:
- `server/run_server.sh` (loads `.env` and starts server) - `server/run_server.sh` (loads `.env` and starts server)
- first-run admin bootstrap prompt (if no admin exists) - first-run admin bootstrap prompt (if no admin exists)
Before starting the service, set `CHGRID_HOST_ORIGIN` in `server/.env` to the exact browser origin that will host Chat Grid, for example:
```bash
CHGRID_HOST_ORIGIN=https://example.com
```
## 3) Publish Client ## 3) Publish Client
```bash ```bash
@@ -77,6 +83,8 @@ ProxyPass /auth/session/ http://127.0.0.1:8765/auth/session/
ProxyPassReverse /auth/session/ http://127.0.0.1:8765/auth/session/ ProxyPassReverse /auth/session/ http://127.0.0.1:8765/auth/session/
``` ```
The websocket server enforces browser origin matching against `CHGRID_HOST_ORIGIN`, so the public site origin must match that env var exactly.
What each route does: What each route does:
- `/ws`: websocket signaling (presence, movement, item actions, chat, voice signaling). - `/ws`: websocket signaling (presence, movement, item actions, chat, voice signaling).
- `/auth/session/set`: called by client after successful login to set `HttpOnly` session cookie. - `/auth/session/set`: called by client after successful login to set `HttpOnly` session cookie.
@@ -100,6 +108,12 @@ 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`.
The proxy also requires the same `CHGRID_HOST_ORIGIN` value in the PHP/Apache environment so only your own site origin can read from it. For Apache, one simple option is:
```apache
SetEnv CHGRID_HOST_ORIGIN https://example.com
```
Use: Use:
```text ```text

View File

@@ -2,6 +2,9 @@
# Keep your existing main DocumentRoot unchanged when hosting Chat Grid under /chgrid. # Keep your existing main DocumentRoot unchanged when hosting Chat Grid under /chgrid.
# Required modules: proxy, proxy_http, proxy_wstunnel # Required modules: proxy, proxy_http, proxy_wstunnel
# Optional but recommended modules for client update freshness: headers, setenvif # Optional but recommended modules for client update freshness: headers, setenvif
# Set the public browser origin for websocket and media-proxy origin checks.
# Example:
# SetEnv CHGRID_HOST_ORIGIN https://example.com
# Proxy websocket signaling endpoint to local Python service. # Proxy websocket signaling endpoint to local Python service.
# `/ws` is used by the browser signaling client for realtime packets. # `/ws` is used by the browser signaling client for realtime packets.

View File

@@ -115,6 +115,38 @@ function send_text($code, $message)
exit; exit;
} }
function normalize_origin($value)
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
$parts = parse_url($value);
if ($parts === false || !isset($parts['scheme']) || !isset($parts['host'])) {
return '';
}
$scheme = strtolower((string) $parts['scheme']);
if ($scheme !== 'http' && $scheme !== 'https') {
return '';
}
if (isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])) {
return '';
}
$path = isset($parts['path']) ? (string) $parts['path'] : '';
if ($path !== '' && $path !== '/') {
return '';
}
$host = strtolower((string) $parts['host']);
if ($host === '') {
return '';
}
if (strpos($host, ':') !== false && substr($host, 0, 1) !== '[') {
$host = '[' . $host . ']';
}
$port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
return $scheme . '://' . $host . $port;
}
function host_matches_suffix($host, $suffix) function host_matches_suffix($host, $suffix)
{ {
if ($suffix === '') { if ($suffix === '') {
@@ -377,7 +409,20 @@ function resolve_safe_redirect_chain($initialUrl, $allowlistSuffixes, $requestHe
return ''; return '';
} }
header('Access-Control-Allow-Origin: *'); // Optional allowlist env var: CHGRID_MEDIA_PROXY_ALLOWLIST=dropbox.com,example.com
$allowlistEnv = getenv('CHGRID_MEDIA_PROXY_ALLOWLIST');
$allowlistSuffixes = parse_allowlist_suffixes($allowlistEnv);
$allowedOrigin = normalize_origin(getenv('CHGRID_HOST_ORIGIN'));
if ($allowedOrigin === '') {
send_text(500, 'CHGRID_HOST_ORIGIN is required');
}
$requestOrigin = normalize_origin(isset($_SERVER['HTTP_ORIGIN']) ? (string) $_SERVER['HTTP_ORIGIN'] : '');
if ($requestOrigin !== '' && $requestOrigin !== $allowedOrigin) {
send_text(403, 'origin not allowed');
}
header('Access-Control-Allow-Origin: ' . $allowedOrigin);
header('Vary: Origin');
header('Access-Control-Allow-Methods: GET, HEAD, OPTIONS'); header('Access-Control-Allow-Methods: GET, HEAD, OPTIONS');
header('Access-Control-Allow-Headers: Range'); header('Access-Control-Allow-Headers: Range');
@@ -396,10 +441,6 @@ if ($rawUrl === '') {
} }
$rawUrl = normalize_dropbox_url($rawUrl); $rawUrl = normalize_dropbox_url($rawUrl);
// Optional allowlist env var: CHGRID_MEDIA_PROXY_ALLOWLIST=dropbox.com,example.com
$allowlistEnv = getenv('CHGRID_MEDIA_PROXY_ALLOWLIST');
$allowlistSuffixes = parse_allowlist_suffixes($allowlistEnv);
if (!function_exists('curl_init')) { if (!function_exists('curl_init')) {
send_text(500, 'curl extension is required'); send_text(500, 'curl extension is required');
} }

View File

@@ -58,6 +58,7 @@ print(secrets.token_urlsafe(64))
PY PY
)" )"
printf "CHGRID_AUTH_SECRET=%s\n" "$AUTH_SECRET" > .env printf "CHGRID_AUTH_SECRET=%s\n" "$AUTH_SECRET" > .env
printf "# Required browser origin, for example CHGRID_HOST_ORIGIN=https://example.com\n" >> .env
chmod 600 .env chmod 600 .env
echo "created $SERVER_DIR/.env with CHGRID_AUTH_SECRET" echo "created $SERVER_DIR/.env with CHGRID_AUTH_SECRET"
fi fi
@@ -123,4 +124,4 @@ fi
chmod +x "$SERVER_DIR/run_server.sh" chmod +x "$SERVER_DIR/run_server.sh"
echo "server install complete" echo "server install complete"
echo "next: edit $SERVER_DIR/config.toml (TLS, bind_ip, port)" echo "next: edit $SERVER_DIR/config.toml (TLS, bind_ip, port) and set CHGRID_HOST_ORIGIN in $SERVER_DIR/.env"

View File

@@ -4,6 +4,8 @@
```bash ```bash
cd /home/jjm/code/chgrid/server cd /home/jjm/code/chgrid/server
export CHGRID_AUTH_SECRET=dev-secret
export CHGRID_HOST_ORIGIN=http://localhost:5173
.venv/bin/python main.py --allow-insecure-ws .venv/bin/python main.py --allow-insecure-ws
``` ```
@@ -22,6 +24,7 @@ Defaults:
- Server defaults to TLS-required unless you set `network.allow_insecure_ws=true` or pass `--allow-insecure-ws` for local/dev. - Server defaults to TLS-required unless you set `network.allow_insecure_ws=true` or pass `--allow-insecure-ws` for local/dev.
- Client dev default is `localhost:5173`. - Client dev default is `localhost:5173`.
- Auth requires `CHGRID_AUTH_SECRET` in environment. - Auth requires `CHGRID_AUTH_SECRET` in environment.
- Browser-origin enforcement requires `CHGRID_HOST_ORIGIN` 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`). - 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`).
## Quick Restarts ## Quick Restarts
@@ -30,7 +33,7 @@ Server:
```bash ```bash
lsof -tiTCP:8765 -sTCP:LISTEN | xargs -r kill lsof -tiTCP:8765 -sTCP:LISTEN | xargs -r kill
cd /home/jjm/code/chgrid/server cd /home/jjm/code/chgrid/server
nohup .venv/bin/python main.py --allow-insecure-ws > /tmp/chgrid-server.log 2>&1 & CHGRID_AUTH_SECRET=dev-secret CHGRID_HOST_ORIGIN=http://localhost:5173 nohup .venv/bin/python main.py --allow-insecure-ws > /tmp/chgrid-server.log 2>&1 &
``` ```
Client: Client:

View File

@@ -125,6 +125,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
- Server is authoritative for movement acceptance (bounds + rate/delta checks). - Server is authoritative for movement acceptance (bounds + rate/delta checks).
- Server persists account state (last nickname + last position) and restores spawn from that state on auth login/resume. - 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: - Server also supports websocket handshake cookie resume:
- accepts browser sockets only when websocket `Origin` matches `CHGRID_HOST_ORIGIN`
- reads `chgrid_session_token` from websocket `Cookie` header - reads `chgrid_session_token` from websocket `Cookie` header
- attempts resume before sending `auth_required` - 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` required)
@@ -139,6 +140,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `none/off` normalize to empty values - `none/off` normalize to empty values
- bare filenames normalize to `sounds/<name>` for sound-reference fields - bare filenames normalize to `sounds/<name>` for sound-reference fields
- media URL-like fields are trimmed/validated consistently - media URL-like fields are trimmed/validated consistently
- radio stream metadata fetches only follow validated public `http`/`https` URLs and revalidate redirect hops
- Client-side item edit validation is convenience only; server remains source of truth. - Client-side item edit validation is convenience only; server remains source of truth.
## Heartbeat/Stale Recovery ## Heartbeat/Stale Recovery

View File

@@ -4,8 +4,8 @@
1. User clicks connect. 1. User clicks connect.
2. Client validates auth form and sets up local media. 2. Client validates auth form and sets up local media.
3. Client connects signaling websocket. 3. Client connects signaling websocket from the configured app origin.
4. Server attempts cookie-based session resume from websocket handshake cookie (`chgrid_session_token`). 4. Server accepts the socket only when the browser `Origin` matches `CHGRID_HOST_ORIGIN`, then attempts cookie-based session resume from websocket handshake cookie (`chgrid_session_token`).
5. If resume does not authenticate, server sends `auth_required`. 5. If resume does not authenticate, server sends `auth_required`.
- includes `authPolicy` limits for username/password. - includes `authPolicy` limits for username/password.
6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller). 6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller).

View File

@@ -0,0 +1,77 @@
# Security Best Practices Report
## Executive Summary
This report originally identified three issues: cross-site WebSocket session hijacking, radio metadata SSRF, and an open cross-origin media proxy. All three have now been addressed in code with exact-origin enforcement via `CHGRID_HOST_ORIGIN`, SSRF-safe radio URL validation, and same-origin-only access for the PHP media proxy.
## Critical Findings
### SEC-001: Cross-site WebSocket hijacking via cookie-based session resume without Origin validation
Impact: A malicious website can open a WebSocket to the Chat Grid server from a victim's browser, inherit the victim's session cookie, and perform authenticated actions as that user.
Evidence:
- The server reads the session token directly from the WebSocket handshake cookie in [server.py](/home/jjm/code/chgrid/server/app/server.py#L329) and [server.py](/home/jjm/code/chgrid/server/app/server.py#L339).
- The WebSocket client is auto-authenticated from that cookie before any application message is sent in [server.py](/home/jjm/code/chgrid/server/app/server.py#L1387) through [server.py](/home/jjm/code/chgrid/server/app/server.py#L1392).
- There is no `Origin` validation in the handshake path around [server.py](/home/jjm/code/chgrid/server/app/server.py#L1380).
Why this matters:
- Browser WebSockets are not protected by normal same-origin read restrictions once the server accepts the connection.
- Because the app uses cookie-based session resume, a third-party origin can potentially establish an authenticated socket unless the server rejects unexpected origins.
Implemented fix:
- The server now requires `CHGRID_HOST_ORIGIN` and passes that exact origin into the WebSocket handshake allowlist.
- Browser sockets with missing or mismatched `Origin` are rejected before the application session resume path runs.
## High Findings
### SEC-002: Radio metadata polling creates server-side request forgery from user-controlled `streamUrl`
Impact: An authenticated user who can create or edit a radio item can cause the server to fetch attacker-chosen URLs, including internal network targets.
Evidence:
- Radio item validation accepts `streamUrl` with only length normalization in [validator.py](/home/jjm/code/chgrid/server/app/items/types/radio_station/validator.py#L11) through [validator.py](/home/jjm/code/chgrid/server/app/items/types/radio_station/validator.py#L18).
- The server later fetches that URL with `urllib.request.urlopen` in [server.py](/home/jjm/code/chgrid/server/app/server.py#L673) through [server.py](/home/jjm/code/chgrid/server/app/server.py#L700).
- Any enabled radio with a listener in range is polled in [server.py](/home/jjm/code/chgrid/server/app/server.py#L703) through [server.py](/home/jjm/code/chgrid/server/app/server.py#L717).
Why this matters:
- This is a classic SSRF primitive.
- A user does not need browser access to the target; the server makes the request.
- Internal services, metadata endpoints, localhost services, or other private hosts are not blocked here.
Implemented fix:
- Radio `streamUrl` values are now validated server-side as public `http`/`https` URLs before they are saved.
- Metadata polling revalidates and manually follows redirect hops so a safe initial URL cannot redirect into a blocked target.
## Medium Findings
### SEC-003: Optional PHP media proxy is an open cross-origin proxy when no allowlist is configured
Impact: If deployed as-is without `CHGRID_MEDIA_PROXY_ALLOWLIST`, third parties can use the site as a public cross-origin proxy to arbitrary public hosts.
Evidence:
- The proxy explicitly enables wildcard CORS in [media_proxy.php](/home/jjm/code/chgrid/deploy/php/media_proxy.php#L380) through [media_proxy.php](/home/jjm/code/chgrid/deploy/php/media_proxy.php#L382).
- It accepts a user-supplied `url` parameter in [media_proxy.php](/home/jjm/code/chgrid/deploy/php/media_proxy.php#L393) through [media_proxy.php](/home/jjm/code/chgrid/deploy/php/media_proxy.php#L401).
- The hostname allowlist is optional, not required, in [media_proxy.php](/home/jjm/code/chgrid/deploy/php/media_proxy.php#L399) through [media_proxy.php](/home/jjm/code/chgrid/deploy/php/media_proxy.php#L401).
- Redirect resolution and streaming then proceed for the target URL in [media_proxy.php](/home/jjm/code/chgrid/deploy/php/media_proxy.php#L320) through [media_proxy.php](/home/jjm/code/chgrid/deploy/php/media_proxy.php#L377).
Why this matters:
- Even with private/reserved IP blocking, this can still be abused for bandwidth consumption, origin laundering, and relaying requests to arbitrary public endpoints.
- Because CORS is `*`, other websites can read proxy responses directly from browsers.
Implemented fix:
- The proxy now requires `CHGRID_HOST_ORIGIN`.
- `Access-Control-Allow-Origin` is restricted to that exact origin, and mismatched request origins are rejected.
- Upstream media hosts remain unrestricted, preserving the intended relay behavior for arbitrary public media sources.
## Lower-Risk Notes
- I did not find obvious client-side DOM XSS in the current UI paths I sampled. The app mostly builds DOM with `textContent` and node creation, and the `innerHTML` uses I saw were clearing containers rather than injecting untrusted markup.
- The client no longer stores session tokens in `localStorage`; session persistence is now moved to an `HttpOnly` cookie path, which is the right direction.
- TLS posture appears intentionally gated by config, and I did not treat local insecure WebSocket support as a finding.
## Residual Risks / Gaps
- I did not perform dependency CVE triage across `npm` and Python package locks in this pass.
- I did not run server-side tests or dynamic attack simulations; this was a code audit.
- The PHP proxy is deploy-time optional, but it is part of the repo and worth treating as production-facing if used.

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from ....network_security import validate_media_reference
from ....models import WorldItem from ....models import WorldItem
from ...sound_policy import enforce_max_length, normalize_media_reference from ...sound_policy import enforce_max_length, normalize_media_reference
from ...helpers import keep_only_known_params from ...helpers import keep_only_known_params
@@ -16,6 +17,7 @@ def validate_update(item: WorldItem, next_params: dict) -> dict:
max_length=2048, max_length=2048,
field_name="streamUrl", field_name="streamUrl",
) )
next_params["streamUrl"] = validate_media_reference(next_params["streamUrl"], field_name="streamUrl")
enabled_value = next_params.get("enabled", True) enabled_value = next_params.get("enabled", True)
if isinstance(enabled_value, bool): if isinstance(enabled_value, bool):

View File

@@ -0,0 +1,165 @@
"""Helpers for browser-origin policy and SSRF-safe outbound URL handling."""
from __future__ import annotations
import ipaddress
import socket
from typing import Iterable
from urllib.error import HTTPError
from urllib.parse import urljoin, urlsplit, urlunsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
IpAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
class _NoRedirectHandler(HTTPRedirectHandler):
"""Disable automatic redirects so each hop can be revalidated."""
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
"""Return None so urllib surfaces redirects as HTTPError objects."""
return None
_NO_REDIRECT_OPENER = build_opener(_NoRedirectHandler)
def _format_host(host: str) -> str:
"""Return one hostname/IP suitable for URL netloc reconstruction."""
if ":" in host and not host.startswith("["):
return f"[{host}]"
return host
def _normalize_netloc(parts) -> str:
"""Rebuild one normalized netloc from parsed URL parts."""
if not parts.hostname:
raise ValueError("host is required")
netloc = _format_host(parts.hostname.lower())
if parts.port is not None:
netloc = f"{netloc}:{parts.port}"
return netloc
def normalize_origin(value: str, *, field_name: str = "origin") -> str:
"""Validate and normalize one browser origin string."""
text = value.strip()
if not text:
raise ValueError(f"{field_name} must not be empty.")
try:
parts = urlsplit(text)
netloc = _normalize_netloc(parts)
except ValueError as exc:
raise ValueError(f"{field_name} must be a valid http/https origin.") from exc
scheme = parts.scheme.lower()
if scheme not in {"http", "https"}:
raise ValueError(f"{field_name} must use http or https.")
if parts.username is not None or parts.password is not None:
raise ValueError(f"{field_name} must not include credentials.")
if parts.path not in {"", "/"} or parts.query or parts.fragment:
raise ValueError(f"{field_name} must not include path, query, or fragment.")
return urlunsplit((scheme, netloc, "", "", ""))
def _resolve_host_ips(host: str) -> set[IpAddress]:
"""Resolve one hostname or IP literal to concrete IP addresses."""
try:
return {ipaddress.ip_address(host)}
except ValueError:
pass
resolved: set[IpAddress] = set()
try:
infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise ValueError("DNS resolution failed.") from exc
for family, _type, _proto, _canonname, sockaddr in infos:
if family == socket.AF_INET:
resolved.add(ipaddress.ip_address(sockaddr[0]))
elif family == socket.AF_INET6:
resolved.add(ipaddress.ip_address(sockaddr[0]))
if not resolved:
raise ValueError("DNS resolution failed.")
return resolved
def _ensure_public_ips(addresses: Iterable[IpAddress], *, field_name: str) -> None:
"""Reject non-public IP addresses for SSRF-sensitive outbound requests."""
for address in addresses:
if not address.is_global:
raise ValueError(f"{field_name} must resolve to a public IP address.")
def validate_public_media_url(value: str, *, field_name: str = "url") -> str:
"""Validate and normalize one public http/https media URL."""
text = value.strip()
if not text:
return ""
try:
parts = urlsplit(text)
netloc = _normalize_netloc(parts)
except ValueError as exc:
raise ValueError(f"{field_name} must be a valid http/https URL.") from exc
scheme = parts.scheme.lower()
if scheme not in {"http", "https"}:
raise ValueError(f"{field_name} must use http or https.")
if parts.username is not None or parts.password is not None:
raise ValueError(f"{field_name} must not include credentials.")
_ensure_public_ips(_resolve_host_ips(parts.hostname or ""), field_name=field_name)
return urlunsplit((scheme, netloc, parts.path, parts.query, parts.fragment))
def validate_media_reference(value: str, *, field_name: str = "url") -> str:
"""Validate one media reference as either a public URL or a site-relative path."""
text = value.strip()
if not text:
return ""
parts = urlsplit(text)
if parts.scheme:
return validate_public_media_url(text, field_name=field_name)
if parts.netloc:
raise ValueError(f"{field_name} must use http or https when specifying a host.")
if not text.startswith("/"):
raise ValueError(f"{field_name} must be an absolute http/https URL or site-relative path.")
return text
def open_validated_public_url(
url: str,
*,
headers: dict[str, str] | None = None,
timeout: float = 6.0,
max_redirects: int = 5,
):
"""Open one public media URL while revalidating each redirect target."""
current_url = validate_public_media_url(url)
request_headers = headers or {}
for redirect_count in range(max_redirects + 1):
request = Request(current_url, headers=request_headers)
try:
return _NO_REDIRECT_OPENER.open(request, timeout=timeout)
except HTTPError as exc:
try:
if 300 <= exc.code < 400:
if redirect_count >= max_redirects:
raise ValueError("Too many redirects.")
location = str(exc.headers.get("Location") or "").strip()
if not location:
raise ValueError("Redirect location missing or invalid.")
current_url = validate_public_media_url(urljoin(current_url, location))
continue
raise
finally:
exc.close()
raise ValueError("Too many redirects.")

View File

@@ -22,7 +22,6 @@ import uuid
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from urllib.error import URLError from urllib.error import URLError
from urllib.request import Request, urlopen
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from pydantic import ValidationError, TypeAdapter from pydantic import ValidationError, TypeAdapter
@@ -107,6 +106,7 @@ from .models import (
WelcomePacket, WelcomePacket,
WorldItem, WorldItem,
) )
from .network_security import normalize_origin, open_validated_public_url
from .ui_metadata import ( from .ui_metadata import (
ADMIN_MENU_ACTION_DEFINITIONS, ADMIN_MENU_ACTION_DEFINITIONS,
ITEM_MANAGEMENT_ACTION_DEFINITIONS, ITEM_MANAGEMENT_ACTION_DEFINITIONS,
@@ -160,6 +160,7 @@ class SignalingServer:
grid_size: int = 41, grid_size: int = 41,
state_save_debounce_ms: int = 200, state_save_debounce_ms: int = 200,
state_save_max_delay_ms: int = 1000, state_save_max_delay_ms: int = 1000,
host_origin: str | None = None,
): ):
"""Initialize runtime state, TLS context, and item service.""" """Initialize runtime state, TLS context, and item service."""
@@ -187,6 +188,7 @@ class SignalingServer:
self.movement_max_steps_per_tick = MOVEMENT_MAX_STEPS_PER_TICK self.movement_max_steps_per_tick = MOVEMENT_MAX_STEPS_PER_TICK
self.instance_id = str(uuid.uuid4()) self.instance_id = str(uuid.uuid4())
self.server_version = self._resolve_server_version() self.server_version = self._resolve_server_version()
self.host_origin = normalize_origin(host_origin, field_name="host origin") if host_origin else None
self.state_save_debounce_ms = max(1, int(state_save_debounce_ms)) self.state_save_debounce_ms = max(1, int(state_save_debounce_ms))
self.state_save_max_delay_ms = max(self.state_save_debounce_ms, int(state_save_max_delay_ms)) self.state_save_max_delay_ms = max(self.state_save_debounce_ms, int(state_save_max_delay_ms))
self._pending_state_save_handle: asyncio.TimerHandle | None = None self._pending_state_save_handle: asyncio.TimerHandle | None = None
@@ -676,11 +678,11 @@ class SignalingServer:
if not stream_url: if not stream_url:
return "", "" return "", ""
try: try:
request = Request( with open_validated_public_url(
stream_url, stream_url,
headers={"Icy-MetaData": "1", "User-Agent": "ChatGrid"}, headers={"Icy-MetaData": "1", "User-Agent": "ChatGrid"},
) timeout=RADIO_METADATA_TIMEOUT_S,
with urlopen(request, timeout=RADIO_METADATA_TIMEOUT_S) as response: ) as response:
station = str(response.headers.get("icy-name") or response.headers.get("ice-name") or "").strip() station = str(response.headers.get("icy-name") or response.headers.get("ice-name") or "").strip()
title = "" title = ""
metaint_raw = response.headers.get("icy-metaint") metaint_raw = response.headers.get("icy-metaint")
@@ -1355,6 +1357,7 @@ class SignalingServer:
self.port, self.port,
ssl=self._ssl_context, ssl=self._ssl_context,
max_size=self.max_message_size, max_size=self.max_message_size,
origins=[self.host_origin] if self.host_origin else None,
process_request=self._process_http_request, process_request=self._process_http_request,
): ):
await asyncio.Future() await asyncio.Future()
@@ -3078,6 +3081,13 @@ def run() -> None:
auth_secret = os.getenv("CHGRID_AUTH_SECRET", "").strip() auth_secret = os.getenv("CHGRID_AUTH_SECRET", "").strip()
if not auth_secret: if not auth_secret:
raise SystemExit("CHGRID_AUTH_SECRET is required.") raise SystemExit("CHGRID_AUTH_SECRET is required.")
host_origin = os.getenv("CHGRID_HOST_ORIGIN", "").strip()
if not host_origin:
raise SystemExit("CHGRID_HOST_ORIGIN is required.")
try:
host_origin = normalize_origin(host_origin, field_name="CHGRID_HOST_ORIGIN")
except ValueError as exc:
raise SystemExit(str(exc)) from exc
auth_db_value = config.auth.db_file.strip() auth_db_value = config.auth.db_file.strip()
if not auth_db_value: if not auth_db_value:
raise SystemExit("auth.db_file must not be empty.") raise SystemExit("auth.db_file must not be empty.")
@@ -3206,6 +3216,6 @@ def run() -> None:
grid_size=config.world.grid_size, grid_size=config.world.grid_size,
state_save_debounce_ms=config.storage.state_save_debounce_ms, state_save_debounce_ms=config.storage.state_save_debounce_ms,
state_save_max_delay_ms=config.storage.state_save_max_delay_ms, state_save_max_delay_ms=config.storage.state_save_max_delay_ms,
host_origin=host_origin,
) )
asyncio.run(server.start()) asyncio.run(server.start())
ItemClockAnnouncePacket,

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
import socket
import pytest
from app.network_security import normalize_origin, validate_media_reference, validate_public_media_url
def test_normalize_origin_rejects_paths() -> None:
with pytest.raises(ValueError):
normalize_origin("https://example.com/chgrid")
def test_normalize_origin_normalizes_case_and_trailing_slash() -> None:
assert normalize_origin("HTTPS://Example.COM:443/") == "https://example.com:443"
def test_validate_public_media_url_rejects_private_ip() -> None:
with pytest.raises(ValueError):
validate_public_media_url("http://127.0.0.1/audio")
def test_validate_public_media_url_resolves_hostname(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_getaddrinfo(host: str, port, type: int = 0):
assert host == "radio.example.com"
return [(socket.AF_INET, type, 6, "", ("93.184.216.34", 0))]
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
assert validate_public_media_url("https://Radio.Example.com/live") == "https://radio.example.com/live"
def test_validate_public_media_url_rejects_private_resolution(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_getaddrinfo(host: str, port, type: int = 0):
assert host == "radio.example.com"
return [(socket.AF_INET, type, 6, "", ("10.0.0.5", 0))]
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
with pytest.raises(ValueError):
validate_public_media_url("https://radio.example.com/live")
def test_validate_media_reference_allows_site_relative_path() -> None:
assert validate_media_reference("/chgrid/media_proxy.php?url=test") == "/chgrid/media_proxy.php?url=test"