diff --git a/deploy/README.md b/deploy/README.md index e3d2d12..281dd83 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 diff --git a/deploy/apache/chgrid-vhost-snippet.conf b/deploy/apache/chgrid-vhost-snippet.conf index f60d359..847e1fa 100644 --- a/deploy/apache/chgrid-vhost-snippet.conf +++ b/deploy/apache/chgrid-vhost-snippet.conf @@ -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. diff --git a/deploy/php/media_proxy.php b/deploy/php/media_proxy.php index c82d47e..08084cc 100644 --- a/deploy/php/media_proxy.php +++ b/deploy/php/media_proxy.php @@ -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'); } diff --git a/deploy/scripts/install_server.sh b/deploy/scripts/install_server.sh index 1e4297e..b516e81 100755 --- a/deploy/scripts/install_server.sh +++ b/deploy/scripts/install_server.sh @@ -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" diff --git a/docs/local.md b/docs/local.md index 4a1742a..c393c3e 100644 --- a/docs/local.md +++ b/docs/local.md @@ -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: diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 762278e..5cb12d5 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -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/` 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 diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 8c40879..f383271 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -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). diff --git a/security_best_practices_report.md b/security_best_practices_report.md new file mode 100644 index 0000000..5752141 --- /dev/null +++ b/security_best_practices_report.md @@ -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. diff --git a/server/app/items/types/radio_station/validator.py b/server/app/items/types/radio_station/validator.py index 0fd9983..995bc4a 100644 --- a/server/app/items/types/radio_station/validator.py +++ b/server/app/items/types/radio_station/validator.py @@ -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): diff --git a/server/app/network_security.py b/server/app/network_security.py new file mode 100644 index 0000000..87f466f --- /dev/null +++ b/server/app/network_security.py @@ -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.") diff --git a/server/app/server.py b/server/app/server.py index cc5c2c5..a058069 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -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, diff --git a/server/tests/test_network_security.py b/server/tests/test_network_security.py new file mode 100644 index 0000000..e800296 --- /dev/null +++ b/server/tests/test_network_security.py @@ -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"