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
-
-
-
- | Name |
- Public Key |
- Type |
- Last Seen |
-
-
-
- {"".join(f'''
-
- | {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 '-'} |
-
- ''' for n in recent_nodes)}
-
-
-
-
-
-
Recent Messages
-
-
-
- | Type |
- From/Channel |
- Text |
- Received |
-
-
-
- {"".join(f'''
-
- | {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 '-'} |
-
- ''' for m in recent_messages)}
-
-
-
-
-
-
-"""
- 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 sequences."""
+ from starlette.requests import Request
+
+ # Inject a malicious value into the app state
+ web_app.state.network_name = ""
+
+ 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 present."""
+ from starlette.requests import Request
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/",
+ "query_string": b"",
+ "headers": [],
+ }
+ request = Request(scope)
+
+ result = _build_config_json(web_app, request)
+
+ # No escaping artifacts for normal values
+ assert "<\\/" not in result
+
+ parsed = json.loads(result)
+ assert parsed["network_name"] == "Test Network"
+ assert parsed["network_city"] == "Test City"
+
+
+class TestTrustedProxyHostsWarning:
+ """Tests for trusted proxy hosts startup warning in create_app."""
+
+ def test_warning_logged_when_admin_enabled_and_wildcard_hosts(
+ self, caplog: pytest.LogCaptureFixture
+ ) -> 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"] == "*"