hermes-agent/tests/hermes_cli/test_web_server_host_header.py
alt-glitch a1e667b9f2 fix(restructure): fix test regressions from import rewrite
Fix variable name breakage (run_agent, hermes_constants, etc.) where
import rewriter changed 'import X' to 'import hermes_agent.Y' but
test code still referenced 'X' as a variable name.

Fix package-vs-module confusion (cli.auth, cli.models, cli.ui) where
single files became directories.

Fix hardcoded file paths in tests pointing to old locations.
Fix tool registry to discover tools in subpackage directories.
Fix stale import in hermes_agent/tools/__init__.py.

Part of #14182, #14183
2026-04-23 12:05:10 +05:30

143 lines
5.8 KiB
Python

"""Tests for GHSA-ppp5-vxwm-4cf7 — Host-header validation.
DNS rebinding defence: a victim browser that has the dashboard open
could be tricked into fetching from an attacker-controlled hostname
that TTL-flips to 127.0.0.1. Same-origin / CORS checks won't help —
the browser now treats the attacker origin as same-origin. Validating
the Host header at the application layer rejects the attack.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
class TestHostHeaderValidator:
"""Unit test the _is_accepted_host helper directly — cheaper and
more thorough than spinning up the full FastAPI app."""
def test_loopback_bind_accepts_loopback_names(self):
from hermes_agent.cli.web_server import _is_accepted_host
for bound in ("127.0.0.1", "localhost", "::1"):
for host_header in (
"127.0.0.1", "127.0.0.1:9119",
"localhost", "localhost:9119",
"[::1]", "[::1]:9119",
):
assert _is_accepted_host(host_header, bound), (
f"bound={bound} must accept host={host_header}"
)
def test_loopback_bind_rejects_attacker_hostnames(self):
"""The core rebinding defence: attacker-controlled hosts that
TTL-flip to 127.0.0.1 must be rejected."""
from hermes_agent.cli.web_server import _is_accepted_host
for bound in ("127.0.0.1", "localhost"):
for attacker in (
"evil.example",
"evil.example:9119",
"rebind.attacker.test:80",
"localhost.attacker.test", # subdomain trick
"127.0.0.1.evil.test", # lookalike IP prefix
"", # missing Host
):
assert not _is_accepted_host(attacker, bound), (
f"bound={bound} must reject attacker host={attacker!r}"
)
def test_zero_zero_bind_accepts_anything(self):
"""0.0.0.0 means operator explicitly opted into all-interfaces
(requires --insecure). No Host-layer defence is possible — rely
on operator network controls."""
from hermes_agent.cli.web_server import _is_accepted_host
for host in ("10.0.0.5", "evil.example", "my-server.corp.net"):
assert _is_accepted_host(host, "0.0.0.0")
assert _is_accepted_host(host + ":9119", "0.0.0.0")
def test_explicit_non_loopback_bind_requires_exact_match(self):
"""If the operator bound to a specific non-loopback hostname,
the Host header must match exactly."""
from hermes_agent.cli.web_server import _is_accepted_host
assert _is_accepted_host("my-server.corp.net", "my-server.corp.net")
assert _is_accepted_host("my-server.corp.net:9119", "my-server.corp.net")
# Different host — reject
assert not _is_accepted_host("evil.example", "my-server.corp.net")
# Loopback — reject (we bound to a specific non-loopback name)
assert not _is_accepted_host("localhost", "my-server.corp.net")
def test_case_insensitive_comparison(self):
"""Host headers are case-insensitive per RFC — accept variations."""
from hermes_agent.cli.web_server import _is_accepted_host
assert _is_accepted_host("LOCALHOST", "127.0.0.1")
assert _is_accepted_host("LocalHost:9119", "127.0.0.1")
class TestHostHeaderMiddleware:
"""End-to-end test via the FastAPI app — verify the middleware
rejects bad Host headers with 400."""
def test_rebinding_request_rejected(self):
from fastapi.testclient import TestClient
from hermes_agent.cli.web_server import app
# Simulate start_server having set the bound_host
app.state.bound_host = "127.0.0.1"
try:
client = TestClient(app)
# The TestClient sends Host: testserver by default — which is
# NOT a loopback alias, so the middleware must reject it.
resp = client.get(
"/api/status",
headers={"Host": "evil.example"},
)
assert resp.status_code == 400
assert "Invalid Host header" in resp.json()["detail"]
finally:
# Clean up so other tests don't inherit the bound_host
if hasattr(app.state, "bound_host"):
del app.state.bound_host
def test_legit_loopback_request_accepted(self):
from fastapi.testclient import TestClient
from hermes_agent.cli.web_server import app
app.state.bound_host = "127.0.0.1"
try:
client = TestClient(app)
# /api/status is in _PUBLIC_API_PATHS — passes auth — so the
# only thing that can reject is the host header middleware
resp = client.get(
"/api/status",
headers={"Host": "localhost:9119"},
)
# Either 200 (endpoint served) or some other non-400 —
# just not the host-rejection 400
assert resp.status_code != 400 or (
"Invalid Host header" not in resp.json().get("detail", "")
)
finally:
if hasattr(app.state, "bound_host"):
del app.state.bound_host
def test_no_bound_host_skips_validation(self):
"""If app.state.bound_host isn't set (e.g. running under test
infra without calling start_server), middleware must pass through
rather than crash."""
from fastapi.testclient import TestClient
from hermes_agent.cli.web_server import app
# Make sure bound_host isn't set
if hasattr(app.state, "bound_host"):
del app.state.bound_host
client = TestClient(app)
resp = client.get("/api/status")
# Should get through to the status endpoint, not a 400
assert resp.status_code != 400