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)
|
- `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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
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 __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):
|
||||||
|
|||||||
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 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,
|
|
||||||
|
|||||||
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