diff --git a/AGENTS.md b/AGENTS.md index 332aa20..56ec311 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -612,6 +612,7 @@ Key variables: - `MQTT_TLS` - Enable TLS/SSL for MQTT (default: `false`) - `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys - `WEB_ADMIN_ENABLED` - Enable admin interface at /a/ (default: `false`, requires auth proxy) +- `WEB_TRUSTED_PROXY_HOSTS` - Comma-separated list of trusted proxy hosts for admin authentication headers. Default: `*` (all hosts). Recommended: set to your reverse proxy IP in production. A startup warning is emitted when using the default `*` with admin enabled. - `WEB_THEME` - Default theme for the web dashboard (default: `dark`, options: `dark`, `light`). Users can override via the theme toggle in the navbar, which persists their preference in browser localStorage. - `WEB_AUTO_REFRESH_SECONDS` - Auto-refresh interval in seconds for list pages (default: `30`, `0` to disable) - `TZ` - Timezone for web dashboard date/time display (default: `UTC`, e.g., `America/New_York`, `Europe/London`) diff --git a/PLAN.md b/PLAN.md index bb8d5ac..9b91633 100644 --- a/PLAN.md +++ b/PLAN.md @@ -516,6 +516,7 @@ LetsMesh compatibility parity note: | WEB_PORT | 8080 | Web bind port | | API_BASE_URL | http://localhost:8000 | API endpoint | | API_KEY | | API key for queries | +| WEB_TRUSTED_PROXY_HOSTS | * | Comma-separated list of trusted proxy hosts for admin authentication headers. Default: `*` (all hosts). Recommended: set to your reverse proxy IP in production. | | WEB_LOCALE | en | UI translation locale | | WEB_DATETIME_LOCALE | en-US | Date formatting locale for UI timestamps | | TZ | UTC | Timezone used for UI timestamp rendering | diff --git a/README.md b/README.md index 2a9b27e..33dd167 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,7 @@ The collector automatically cleans up old event data and inactive nodes: | `WEB_DATETIME_LOCALE` | `en-US` | Locale used for date formatting in the web dashboard (e.g., `en-US` for MM/DD/YYYY, `en-GB` for DD/MM/YYYY). | | `WEB_AUTO_REFRESH_SECONDS` | `30` | Auto-refresh interval in seconds for list pages (0 to disable) | | `WEB_ADMIN_ENABLED` | `false` | Enable admin interface at /a/ (requires auth proxy: `X-Forwarded-User`/`X-Auth-Request-User` or forwarded `Authorization: Basic ...`) | +| `WEB_TRUSTED_PROXY_HOSTS` | `*` | Comma-separated list of trusted proxy hosts for admin authentication headers. Default: `*` (all hosts). Recommended: set to your reverse proxy IP in production. A startup warning is emitted when using the default `*` with admin enabled. | | `TZ` | `UTC` | Timezone for displaying dates/times (e.g., `America/New_York`, `Europe/London`) | | `NETWORK_DOMAIN` | *(none)* | Network domain name (optional) | | `NETWORK_NAME` | `MeshCore Network` | Display name for the network | diff --git a/src/meshcore_hub/api/auth.py b/src/meshcore_hub/api/auth.py index 73b874c..a64ca61 100644 --- a/src/meshcore_hub/api/auth.py +++ b/src/meshcore_hub/api/auth.py @@ -1,5 +1,6 @@ """Authentication middleware for the API.""" +import hmac import logging from typing import Annotated @@ -79,7 +80,9 @@ async def require_read( ) # Check if token matches any key - if token == read_key or token == admin_key: + if (read_key and hmac.compare_digest(token, read_key)) or ( + admin_key and hmac.compare_digest(token, admin_key) + ): return token raise HTTPException( @@ -124,7 +127,7 @@ async def require_admin( ) # Check if token matches admin key - if token == admin_key: + if hmac.compare_digest(token, admin_key): return token raise HTTPException( diff --git a/src/meshcore_hub/api/metrics.py b/src/meshcore_hub/api/metrics.py index 0d19f92..53f13b8 100644 --- a/src/meshcore_hub/api/metrics.py +++ b/src/meshcore_hub/api/metrics.py @@ -1,6 +1,7 @@ """Prometheus metrics endpoint for MeshCore Hub API.""" import base64 +import hmac import logging import time from typing import Any @@ -54,7 +55,9 @@ def verify_basic_auth(request: Request) -> bool: try: decoded = base64.b64decode(auth_header[6:]).decode("utf-8") username, password = decoded.split(":", 1) - return username == "metrics" and password == read_key + return hmac.compare_digest(username, "metrics") and hmac.compare_digest( + password, read_key + ) except Exception: return False diff --git a/src/meshcore_hub/api/routes/dashboard.py b/src/meshcore_hub/api/routes/dashboard.py index 9393224..8875a70 100644 --- a/src/meshcore_hub/api/routes/dashboard.py +++ b/src/meshcore_hub/api/routes/dashboard.py @@ -2,8 +2,7 @@ from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Request -from fastapi.responses import HTMLResponse +from fastapi import APIRouter from sqlalchemy import func, select from meshcore_hub.api.auth import RequireRead @@ -362,175 +361,3 @@ async def get_node_count_history( data.append(DailyActivityPoint(date=date_str, count=count)) return NodeCountHistory(days=days, data=data) - - -@router.get("/", response_class=HTMLResponse) -async def dashboard( - request: Request, - session: DbSession, -) -> HTMLResponse: - """Simple HTML dashboard page.""" - now = datetime.now(timezone.utc) - today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) - yesterday = now - timedelta(days=1) - - # Get stats - total_nodes = session.execute(select(func.count()).select_from(Node)).scalar() or 0 - - active_nodes = ( - session.execute( - select(func.count()).select_from(Node).where(Node.last_seen >= yesterday) - ).scalar() - or 0 - ) - - total_messages = ( - session.execute(select(func.count()).select_from(Message)).scalar() or 0 - ) - - messages_today = ( - session.execute( - select(func.count()) - .select_from(Message) - .where(Message.received_at >= today_start) - ).scalar() - or 0 - ) - - # Get recent nodes - recent_nodes = ( - session.execute(select(Node).order_by(Node.last_seen.desc()).limit(10)) - .scalars() - .all() - ) - - # Get recent messages - recent_messages = ( - session.execute(select(Message).order_by(Message.received_at.desc()).limit(10)) - .scalars() - .all() - ) - - # Build HTML - html = f""" - - - - MeshCore Hub Dashboard - - - - - - -
-

MeshCore Hub Dashboard

-

Last updated: {now.strftime('%Y-%m-%d %H:%M:%S UTC')}

- -
-
-

Total Nodes

-
{total_nodes}
-
-
-

Active Nodes (24h)

-
{active_nodes}
-
-
-

Total Messages

-
{total_messages}
-
-
-

Messages Today

-
{messages_today}
-
-
- -
-

Recent Nodes

- - - - - - - - - - - {"".join(f''' - - - - - - - ''' for n in recent_nodes)} - -
NamePublic KeyTypeLast Seen
{n.name or '-'}{n.public_key[:16]}...{n.adv_type or '-'}{n.last_seen.strftime('%Y-%m-%d %H:%M') if n.last_seen else '-'}
-
- -
-

Recent Messages

- - - - - - - - - - - {"".join(f''' - - - - - - - ''' for m in recent_messages)} - -
TypeFrom/ChannelTextReceived
{m.message_type}{m.pubkey_prefix or f'Ch {m.channel_idx}' or '-'}{m.text[:50]}{'...' if len(m.text) > 50 else ''}{m.received_at.strftime('%Y-%m-%d %H:%M') if m.received_at else '-'}
-
-
- - -""" - return HTMLResponse(content=html) diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index c8ad910..032523d 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -352,6 +352,12 @@ class WebSettings(CommonSettings): ge=0, ) + # Trusted proxy hosts for X-Forwarded-For header processing + web_trusted_proxy_hosts: str = Field( + default="*", + description="Comma-separated list of trusted proxy hosts or '*' for all", + ) + # Admin interface (disabled by default for security) web_admin_enabled: bool = Field( default=False, diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index 0a54ab6..683e633 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -180,7 +180,11 @@ def _build_config_json(app: FastAPI, request: Request) -> str: "logo_invert_light": app.state.logo_invert_light, } - return json.dumps(config) + # Escape "" sequences to prevent XSS breakout from the + # ", + network_city="Test City", + network_country="Test Country", + network_radio_config="Test Radio Config", + network_contact_email="test@example.com", + features=ALL_FEATURES_ENABLED, + ) + app.state.http_client = mock_http_client + return app + + +@pytest.fixture +def xss_client(xss_app: Any, mock_http_client: MockHttpClient) -> TestClient: + """Create a test client whose network_name contains a script injection payload.""" + xss_app.state.http_client = mock_http_client + return TestClient(xss_app, raise_server_exceptions=True) + + +class TestConfigJsonXssEscaping: + """Tests that _build_config_json escapes to prevent XSS breakout.""" + + def test_script_tag_escaped_in_rendered_html(self, xss_client: TestClient) -> None: + """Config value containing is escaped to <\\/script> in the HTML.""" + response = xss_client.get("/") + assert response.status_code == 200 + + html = response.text + + # The literal "" must NOT appear inside the config JSON block. + # Find the config JSON assignment to isolate the embedded block. + config_marker = "window.__APP_CONFIG__ = " + start = html.find(config_marker) + assert start != -1, "Config JSON block not found in rendered HTML" + start += len(config_marker) + end = html.find(";", start) + config_block = html[start:end] + + # The raw closing tag must be escaped + assert "" not in config_block + assert "<\\/script>" in config_block + + def test_normal_config_values_unaffected(self, client: TestClient) -> None: + """Config values without special characters render unchanged.""" + response = client.get("/") + assert response.status_code == 200 + + html = response.text + config_marker = "window.__APP_CONFIG__ = " + start = html.find(config_marker) + assert start != -1 + start += len(config_marker) + end = html.find(";", start) + config_block = html[start:end] + + config = json.loads(config_block) + assert config["network_name"] == "Test Network" + assert config["network_city"] == "Test City" + assert config["network_country"] == "Test Country" + + def test_escaped_json_is_parseable(self, xss_client: TestClient) -> None: + """The escaped JSON is still valid and parseable by json.loads.""" + response = xss_client.get("/") + assert response.status_code == 200 + + html = response.text + config_marker = "window.__APP_CONFIG__ = " + start = html.find(config_marker) + assert start != -1 + start += len(config_marker) + end = html.find(";", start) + config_block = html[start:end] + + # json.loads handles <\/ sequences correctly (they are valid JSON) + config = json.loads(config_block) + assert isinstance(config, dict) + # The parsed value should contain the original unescaped string + assert config["network_name"] == "" + + def test_build_config_json_direct_escaping(self, web_app: Any) -> None: + """Calling _build_config_json directly escapes " + + scope = { + "type": "http", + "method": "GET", + "path": "/", + "query_string": b"", + "headers": [], + } + request = Request(scope) + + result = _build_config_json(web_app, request) + + # Raw output must not contain literal "" + assert "" not in result + assert "<\\/script>" in result + + # Result must still be valid JSON + parsed = json.loads(result) + assert parsed["network_name"] == "" + + def test_build_config_json_no_escaping_needed(self, web_app: Any) -> None: + """_build_config_json leaves normal values intact when no None: + """A warning is logged when WEB_ADMIN_ENABLED=true and WEB_TRUSTED_PROXY_HOSTS is '*'.""" + with patch("meshcore_hub.common.config.get_web_settings") as mock_get_settings: + from meshcore_hub.common.config import WebSettings + + settings = WebSettings( + _env_file=None, + web_admin_enabled=True, + web_trusted_proxy_hosts="*", + ) + mock_get_settings.return_value = settings + + with caplog.at_level(logging.WARNING, logger="meshcore_hub.web.app"): + create_app( + api_url="http://localhost:8000", + admin_enabled=True, + features=ALL_FEATURES_ENABLED, + ) + + assert any( + "WEB_ADMIN_ENABLED is true but WEB_TRUSTED_PROXY_HOSTS is '*'" in msg + for msg in caplog.messages + ), f"Expected warning not found in log messages: {caplog.messages}" + + def test_no_warning_when_trusted_proxy_hosts_is_specific( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """No warning is logged when WEB_TRUSTED_PROXY_HOSTS is set to a specific value.""" + with patch("meshcore_hub.common.config.get_web_settings") as mock_get_settings: + from meshcore_hub.common.config import WebSettings + + settings = WebSettings( + _env_file=None, + web_admin_enabled=True, + web_trusted_proxy_hosts="10.0.0.1", + ) + mock_get_settings.return_value = settings + + with caplog.at_level(logging.WARNING, logger="meshcore_hub.web.app"): + create_app( + api_url="http://localhost:8000", + admin_enabled=True, + features=ALL_FEATURES_ENABLED, + ) + + assert not any( + "WEB_TRUSTED_PROXY_HOSTS" in msg for msg in caplog.messages + ), f"Unexpected warning found in log messages: {caplog.messages}" + + def test_no_warning_when_admin_disabled( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """No warning is logged when WEB_ADMIN_ENABLED is false even with wildcard hosts.""" + with patch("meshcore_hub.common.config.get_web_settings") as mock_get_settings: + from meshcore_hub.common.config import WebSettings + + settings = WebSettings( + _env_file=None, + web_admin_enabled=False, + web_trusted_proxy_hosts="*", + ) + mock_get_settings.return_value = settings + + with caplog.at_level(logging.WARNING, logger="meshcore_hub.web.app"): + create_app( + api_url="http://localhost:8000", + features=ALL_FEATURES_ENABLED, + ) + + assert not any( + "WEB_TRUSTED_PROXY_HOSTS" in msg for msg in caplog.messages + ), f"Unexpected warning found in log messages: {caplog.messages}" + + def test_proxy_hosts_comma_list_parsed_correctly(self) -> None: + """A comma-separated WEB_TRUSTED_PROXY_HOSTS is split into a list for middleware.""" + from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware + + with patch("meshcore_hub.common.config.get_web_settings") as mock_get_settings: + from meshcore_hub.common.config import WebSettings + + settings = WebSettings( + _env_file=None, + web_trusted_proxy_hosts="10.0.0.1, 10.0.0.2, 172.16.0.1", + ) + mock_get_settings.return_value = settings + + app = create_app( + api_url="http://localhost:8000", + features=ALL_FEATURES_ENABLED, + ) + + # Find the ProxyHeadersMiddleware entry in app.user_middleware + proxy_entries = [ + m for m in app.user_middleware if m.cls is ProxyHeadersMiddleware + ] + assert len(proxy_entries) == 1, "ProxyHeadersMiddleware not found in middleware" + assert proxy_entries[0].kwargs["trusted_hosts"] == [ + "10.0.0.1", + "10.0.0.2", + "172.16.0.1", + ] + + def test_wildcard_proxy_hosts_passed_as_string(self) -> None: + """Wildcard WEB_TRUSTED_PROXY_HOSTS='*' is passed as a string to middleware.""" + from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware + + with patch("meshcore_hub.common.config.get_web_settings") as mock_get_settings: + from meshcore_hub.common.config import WebSettings + + settings = WebSettings( + _env_file=None, + web_trusted_proxy_hosts="*", + ) + mock_get_settings.return_value = settings + + app = create_app( + api_url="http://localhost:8000", + features=ALL_FEATURES_ENABLED, + ) + + # Find the ProxyHeadersMiddleware entry in app.user_middleware + proxy_entries = [ + m for m in app.user_middleware if m.cls is ProxyHeadersMiddleware + ] + assert len(proxy_entries) == 1, "ProxyHeadersMiddleware not found in middleware" + assert proxy_entries[0].kwargs["trusted_hosts"] == "*"