Harden origin and media URL security
This commit is contained in:
@@ -31,6 +31,12 @@ What this sets up:
|
||||
- `server/run_server.sh` (loads `.env` and starts server)
|
||||
- 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
|
||||
|
||||
```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/
|
||||
```
|
||||
|
||||
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:
|
||||
- `/ws`: websocket signaling (presence, movement, item actions, chat, voice signaling).
|
||||
- `/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`.
|
||||
|
||||
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:
|
||||
|
||||
```text
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
# Keep your existing main DocumentRoot unchanged when hosting Chat Grid under /chgrid.
|
||||
# Required modules: proxy, proxy_http, proxy_wstunnel
|
||||
# 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.
|
||||
# `/ws` is used by the browser signaling client for realtime packets.
|
||||
|
||||
@@ -115,6 +115,38 @@ function send_text($code, $message)
|
||||
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)
|
||||
{
|
||||
if ($suffix === '') {
|
||||
@@ -377,7 +409,20 @@ function resolve_safe_redirect_chain($initialUrl, $allowlistSuffixes, $requestHe
|
||||
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-Headers: Range');
|
||||
|
||||
@@ -396,10 +441,6 @@ if ($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')) {
|
||||
send_text(500, 'curl extension is required');
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ print(secrets.token_urlsafe(64))
|
||||
PY
|
||||
)"
|
||||
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
|
||||
echo "created $SERVER_DIR/.env with CHGRID_AUTH_SECRET"
|
||||
fi
|
||||
@@ -123,4 +124,4 @@ fi
|
||||
chmod +x "$SERVER_DIR/run_server.sh"
|
||||
|
||||
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"
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
- Client dev default is `localhost:5173`.
|
||||
- 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`).
|
||||
|
||||
## Quick Restarts
|
||||
@@ -30,7 +33,7 @@ Server:
|
||||
```bash
|
||||
lsof -tiTCP:8765 -sTCP:LISTEN | xargs -r kill
|
||||
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:
|
||||
|
||||
@@ -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 persists account state (last nickname + last position) and restores spawn from that state on auth login/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
|
||||
- attempts resume before sending `auth_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
|
||||
- bare filenames normalize to `sounds/<name>` for sound-reference fields
|
||||
- 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.
|
||||
|
||||
## Heartbeat/Stale Recovery
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
1. User clicks connect.
|
||||
2. Client validates auth form and sets up local media.
|
||||
3. Client connects signaling websocket.
|
||||
4. Server attempts cookie-based session resume from websocket handshake cookie (`chgrid_session_token`).
|
||||
3. Client connects signaling websocket from the configured app origin.
|
||||
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`.
|
||||
- includes `authPolicy` limits for username/password.
|
||||
6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller).
|
||||
|
||||
77
security_best_practices_report.md
Normal file
77
security_best_practices_report.md
Normal 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.
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ....network_security import validate_media_reference
|
||||
from ....models import WorldItem
|
||||
from ...sound_policy import enforce_max_length, normalize_media_reference
|
||||
from ...helpers import keep_only_known_params
|
||||
@@ -16,6 +17,7 @@ def validate_update(item: WorldItem, next_params: dict) -> dict:
|
||||
max_length=2048,
|
||||
field_name="streamUrl",
|
||||
)
|
||||
next_params["streamUrl"] = validate_media_reference(next_params["streamUrl"], field_name="streamUrl")
|
||||
|
||||
enabled_value = next_params.get("enabled", True)
|
||||
if isinstance(enabled_value, bool):
|
||||
|
||||
165
server/app/network_security.py
Normal file
165
server/app/network_security.py
Normal 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.")
|
||||
@@ -22,7 +22,6 @@ import uuid
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request, urlopen
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from pydantic import ValidationError, TypeAdapter
|
||||
@@ -107,6 +106,7 @@ from .models import (
|
||||
WelcomePacket,
|
||||
WorldItem,
|
||||
)
|
||||
from .network_security import normalize_origin, open_validated_public_url
|
||||
from .ui_metadata import (
|
||||
ADMIN_MENU_ACTION_DEFINITIONS,
|
||||
ITEM_MANAGEMENT_ACTION_DEFINITIONS,
|
||||
@@ -160,6 +160,7 @@ class SignalingServer:
|
||||
grid_size: int = 41,
|
||||
state_save_debounce_ms: int = 200,
|
||||
state_save_max_delay_ms: int = 1000,
|
||||
host_origin: str | None = None,
|
||||
):
|
||||
"""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.instance_id = str(uuid.uuid4())
|
||||
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_max_delay_ms = max(self.state_save_debounce_ms, int(state_save_max_delay_ms))
|
||||
self._pending_state_save_handle: asyncio.TimerHandle | None = None
|
||||
@@ -676,11 +678,11 @@ class SignalingServer:
|
||||
if not stream_url:
|
||||
return "", ""
|
||||
try:
|
||||
request = Request(
|
||||
with open_validated_public_url(
|
||||
stream_url,
|
||||
headers={"Icy-MetaData": "1", "User-Agent": "ChatGrid"},
|
||||
)
|
||||
with urlopen(request, timeout=RADIO_METADATA_TIMEOUT_S) as response:
|
||||
timeout=RADIO_METADATA_TIMEOUT_S,
|
||||
) as response:
|
||||
station = str(response.headers.get("icy-name") or response.headers.get("ice-name") or "").strip()
|
||||
title = ""
|
||||
metaint_raw = response.headers.get("icy-metaint")
|
||||
@@ -1355,6 +1357,7 @@ class SignalingServer:
|
||||
self.port,
|
||||
ssl=self._ssl_context,
|
||||
max_size=self.max_message_size,
|
||||
origins=[self.host_origin] if self.host_origin else None,
|
||||
process_request=self._process_http_request,
|
||||
):
|
||||
await asyncio.Future()
|
||||
@@ -3078,6 +3081,13 @@ def run() -> None:
|
||||
auth_secret = os.getenv("CHGRID_AUTH_SECRET", "").strip()
|
||||
if not auth_secret:
|
||||
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()
|
||||
if not auth_db_value:
|
||||
raise SystemExit("auth.db_file must not be empty.")
|
||||
@@ -3206,6 +3216,6 @@ 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,
|
||||
host_origin=host_origin,
|
||||
)
|
||||
asyncio.run(server.start())
|
||||
ItemClockAnnouncePacket,
|
||||
|
||||
46
server/tests/test_network_security.py
Normal file
46
server/tests/test_network_security.py
Normal 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"
|
||||
Reference in New Issue
Block a user