diff --git a/CHANGELOG.md b/CHANGELOG.md
index 52f4439..0b15ee7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Hermes Web UI -- Changelog
+## [v0.50.22] Onboarding unblocked for reverse proxy / SSH tunnel deployments (fixes #390)
+
+- `api/routes.py`: Onboarding setup endpoint now reads `X-Forwarded-For` and `X-Real-IP` headers before falling back to raw socket IP — reverse proxy (nginx/Caddy/Traefik) and SSH tunnel users are no longer incorrectly blocked
+- Added `HERMES_WEBUI_ONBOARDING_OPEN=1` env var escape hatch for operators on remote servers who control network access themselves
+- Error message now includes the env var hint so users know how to unblock themselves
+- 18 new tests covering all IP resolution paths (`TestOnboardingIPLogic`, `TestOnboardingSetupEndpoint`)
+
> Living document. Updated at the end of every sprint.
> Repository: https://github.com/nesquena/hermes-webui
diff --git a/api/routes.py b/api/routes.py
index 82f78b3..4c9c5fe 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -914,16 +914,26 @@ def handle_post(handler, parsed) -> bool:
# Writing API keys to disk - restrict to local/private networks unless auth is active.
# In Docker, requests arrive from the bridge network (172.x.x.x), not 127.0.0.1,
# even when the user accesses via localhost:8787 on the host.
+ # Behind a reverse proxy (nginx/Caddy/Traefik) or SSH tunnel, X-Forwarded-For
+ # carries the real origin IP — read it first before falling back to the raw socket addr.
+ # HERMES_WEBUI_ONBOARDING_OPEN=1 lets operators on remote servers explicitly bypass
+ # the check when they control network access themselves (e.g. firewall + VPN).
from api.auth import is_auth_enabled
- if not is_auth_enabled():
+ import os as _os
+ if not is_auth_enabled() and not _os.getenv("HERMES_WEBUI_ONBOARDING_OPEN"):
import ipaddress
try:
- addr = ipaddress.ip_address(handler.client_address[0])
+ # Prefer forwarded headers set by reverse proxies
+ _xff = handler.headers.get("X-Forwarded-For", "").split(",")[0].strip()
+ _xri = handler.headers.get("X-Real-IP", "").strip()
+ _raw = handler.client_address[0]
+ _ip_str = _xff or _xri or _raw
+ addr = ipaddress.ip_address(_ip_str)
is_local = addr.is_loopback or addr.is_private
except ValueError:
is_local = False
if not is_local:
- return bad(handler, "Onboarding setup is only available from local networks when auth is not enabled.", 403)
+ return bad(handler, "Onboarding setup is only available from local networks when auth is not enabled. To bypass this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", 403)
try:
return j(handler, apply_onboarding_setup(body))
except ValueError as e:
diff --git a/static/index.html b/static/index.html
index 648d954..ed75913 100644
--- a/static/index.html
+++ b/static/index.html
@@ -528,7 +528,7 @@
System
Instance version and access controls.
- v0.50.21
+ v0.50.22
diff --git a/tests/test_onboarding_network.py b/tests/test_onboarding_network.py
new file mode 100644
index 0000000..13eab5e
--- /dev/null
+++ b/tests/test_onboarding_network.py
@@ -0,0 +1,184 @@
+"""
+Tests: onboarding /api/onboarding/setup network restriction logic (issue #390).
+
+Covers:
+ 1. Request from 127.0.0.1 (loopback) is allowed without auth
+ 2. Request from RFC-1918 private IP (172.x, 192.168.x, 10.x) is allowed without auth
+ 3. Request from public IP is blocked without auth → 403
+ 4. X-Forwarded-For loopback IP is trusted → allowed
+ 5. X-Forwarded-For private IP is trusted → allowed
+ 6. X-Forwarded-For public IP → still blocked
+ 7. X-Real-IP loopback → allowed
+ 8. HERMES_WEBUI_ONBOARDING_OPEN=1 bypasses the check entirely
+ 9. Auth enabled → check skipped, any IP allowed
+"""
+
+import json
+import os
+import pathlib
+import sys
+import unittest.mock
+import urllib.error
+import urllib.request
+
+import pytest
+
+REPO = pathlib.Path(__file__).parent.parent
+BASE = "http://127.0.0.1:8788"
+
+# ---------------------------------------------------------------------------
+# Unit tests — directly test the IP-resolution + guard logic in routes.py
+# without needing a live server. We replicate the logic to keep tests fast
+# and independent of server startup.
+# ---------------------------------------------------------------------------
+
+def _is_local_from_handler(
+ raw_ip: str,
+ xff: str = "",
+ xri: str = "",
+ auth_enabled: bool = False,
+ open_env: bool = False,
+) -> bool | str:
+ """
+ Mirror of the onboarding IP check in api/routes.py.
+ Returns True if the request would be allowed, False if blocked,
+ or the error message string if blocked.
+ """
+ import ipaddress
+
+ if auth_enabled or open_env:
+ return True
+
+ _xff = xff.split(",")[0].strip() if xff else ""
+ _xri = xri.strip()
+ _ip_str = _xff or _xri or raw_ip
+ try:
+ addr = ipaddress.ip_address(_ip_str)
+ is_local = addr.is_loopback or addr.is_private
+ except ValueError:
+ is_local = False
+
+ return is_local
+
+
+class TestOnboardingIPLogic:
+ """Unit tests for the IP-resolution logic (no live server needed)."""
+
+ def test_loopback_allowed(self):
+ assert _is_local_from_handler("127.0.0.1") is True
+
+ def test_ipv6_loopback_allowed(self):
+ assert _is_local_from_handler("::1") is True
+
+ def test_private_172_allowed(self):
+ """Docker bridge addresses (172.17.x.x) must be allowed."""
+ assert _is_local_from_handler("172.17.0.1") is True
+
+ def test_private_192168_allowed(self):
+ assert _is_local_from_handler("192.168.1.100") is True
+
+ def test_private_10_allowed(self):
+ assert _is_local_from_handler("10.0.0.5") is True
+
+ def test_public_ip_blocked(self):
+ assert _is_local_from_handler("8.8.8.8") is False
+
+ def test_xff_loopback_trusted(self):
+ """Reverse proxy sets X-Forwarded-For to 127.0.0.1 — should be allowed."""
+ assert _is_local_from_handler("172.20.0.1", xff="127.0.0.1") is True
+
+ def test_xff_private_trusted(self):
+ """Reverse proxy sets X-Forwarded-For to LAN IP — should be allowed."""
+ assert _is_local_from_handler("172.20.0.1", xff="192.168.1.50") is True
+
+ def test_xff_public_blocked(self):
+ """Public IP in X-Forwarded-For should still be blocked."""
+ assert _is_local_from_handler("172.20.0.1", xff="8.8.8.8") is False
+
+ def test_xff_first_entry_used(self):
+ """X-Forwarded-For may have multiple IPs; only the first (client) is used."""
+ # First entry is private → allowed
+ assert _is_local_from_handler("172.20.0.1", xff="10.0.0.1, 172.20.0.1") is True
+ # First entry is public → blocked
+ assert _is_local_from_handler("172.20.0.1", xff="8.8.8.8, 172.20.0.1") is False
+
+ def test_xreal_ip_loopback_trusted(self):
+ """X-Real-IP loopback → allowed."""
+ assert _is_local_from_handler("172.20.0.1", xri="127.0.0.1") is True
+
+ def test_xreal_ip_private_trusted(self):
+ assert _is_local_from_handler("172.20.0.1", xri="10.1.2.3") is True
+
+ def test_xff_takes_priority_over_xri(self):
+ """X-Forwarded-For wins over X-Real-IP when both present."""
+ # XFF says public, XRI says local → blocked (XFF takes priority)
+ assert _is_local_from_handler("172.20.0.1", xff="8.8.8.8", xri="127.0.0.1") is False
+
+ def test_open_env_bypasses_check(self):
+ """HERMES_WEBUI_ONBOARDING_OPEN=1 allows any IP."""
+ assert _is_local_from_handler("8.8.8.8", open_env=True) is True
+
+ def test_auth_enabled_bypasses_check(self):
+ """When auth is enabled, IP check is skipped entirely."""
+ assert _is_local_from_handler("8.8.8.8", auth_enabled=True) is True
+
+ def test_invalid_ip_blocked(self):
+ """Malformed IP in header → treated as non-local → blocked."""
+ assert _is_local_from_handler("not-an-ip") is False
+
+
+# ---------------------------------------------------------------------------
+# Integration tests — hit the live test server at port 8788
+# ---------------------------------------------------------------------------
+
+@pytest.mark.integration
+class TestOnboardingSetupEndpoint:
+ """
+ Integration tests for /api/onboarding/setup.
+ These require the test server running on port 8788.
+ """
+
+ def _post(self, path: str, data: dict, headers: dict | None = None) -> tuple[int, dict]:
+ payload = json.dumps(data).encode()
+ req = urllib.request.Request(
+ BASE + path,
+ data=payload,
+ method="POST",
+ headers={"Content-Type": "application/json", **(headers or {})},
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=10) as r:
+ return r.status, json.loads(r.read())
+ except urllib.error.HTTPError as e:
+ return e.code, json.loads(e.read())
+
+ def test_loopback_request_allowed(self):
+ """
+ Requests from 127.0.0.1 (which is what the test server sees) should
+ pass the IP check. We confirm no 403 is returned.
+ """
+ # The test server runs on 127.0.0.1:8788 so client_address[0] is 127.0.0.1.
+ # A valid setup payload with a mock provider should not be rejected for IP reasons.
+ # We patch apply_onboarding_setup to avoid actually writing any config.
+ import unittest.mock
+ with unittest.mock.patch("api.onboarding.apply_onboarding_setup", return_value={"ok": True}):
+ status, body = self._post(
+ "/api/onboarding/setup",
+ {"provider": "anthropic", "model": "claude-sonnet-4.6", "api_key": "test-key"},
+ )
+ # Should not be 403 (IP blocked). May be 200 or another error from apply logic.
+ assert status != 403, f"Got 403 — IP check incorrectly blocked loopback. Body: {body}"
+
+ def test_xff_loopback_header_respected(self):
+ """
+ Simulated reverse proxy: raw TCP is 127.0.0.1 but X-Forwarded-For is also
+ 127.0.0.1. Should be allowed.
+ """
+ import unittest.mock
+ with unittest.mock.patch("api.onboarding.apply_onboarding_setup", return_value={"ok": True}):
+ status, body = self._post(
+ "/api/onboarding/setup",
+ {"provider": "anthropic", "model": "claude-sonnet-4.6", "api_key": "test-key"},
+ headers={"X-Forwarded-For": "127.0.0.1"},
+ )
+ assert status != 403, f"Got 403 with XFF=127.0.0.1. Body: {body}"