fix: onboarding unblocked for reverse proxy / SSH tunnel deployments (fixes #390) (#391)

- Read X-Forwarded-For and X-Real-IP before falling back to raw socket IP
- Add HERMES_WEBUI_ONBOARDING_OPEN=1 env var escape hatch for remote servers
- Error message now includes the env var hint
- 18 new tests (TestOnboardingIPLogic + TestOnboardingSetupEndpoint)

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
nesquena-hermes
2026-04-13 17:52:07 -07:00
committed by GitHub
parent acc14f2f0b
commit 2acee7fc34
4 changed files with 205 additions and 4 deletions

View File

@@ -1,5 +1,12 @@
# Hermes Web UI -- Changelog # 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. > Living document. Updated at the end of every sprint.
> Repository: https://github.com/nesquena/hermes-webui > Repository: https://github.com/nesquena/hermes-webui

View File

@@ -914,16 +914,26 @@ def handle_post(handler, parsed) -> bool:
# Writing API keys to disk - restrict to local/private networks unless auth is active. # 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, # 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. # 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 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 import ipaddress
try: 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 is_local = addr.is_loopback or addr.is_private
except ValueError: except ValueError:
is_local = False is_local = False
if not is_local: 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: try:
return j(handler, apply_onboarding_setup(body)) return j(handler, apply_onboarding_setup(body))
except ValueError as e: except ValueError as e:

View File

@@ -528,7 +528,7 @@
<div class="settings-section-title">System</div> <div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div> <div class="settings-section-meta">Instance version and access controls.</div>
</div> </div>
<span class="settings-version-badge">v0.50.21</span> <span class="settings-version-badge">v0.50.22</span>
</div> </div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px"> <div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label> <label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>

View File

@@ -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}"