From 4b1ff38e51a4dedbdf41db781064ffc821987bc4 Mon Sep 17 00:00:00 2001 From: John Paul Soliva Date: Mon, 28 Sep 2026 16:36:18 +0900 Subject: [PATCH] fix(proxy): accept only local Host names and no foreign browser Origin hermes proxy attaches the operator's subscription credential to every forwarded request but accepted any Host header and any Origin. A loopback or specific-IP bind now serves only its own Host names, and a request whose Origin is not the proxy's own is refused with 403. Non-browser clients send no Origin and are unaffected; a wildcard bind keeps accepting any Host. (cherry picked from commit 3cf3870cfc2a795e439b1a8c0c53424f79c24505) --- hermes_cli/proxy/server.py | 47 +++++++++++++++++-- tests/hermes_cli/test_proxy.py | 32 +++++++++++++ .../user-guide/features/subscription-proxy.md | 11 +++-- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/hermes_cli/proxy/server.py b/hermes_cli/proxy/server.py index 3baea21d17..c9746aa38e 100644 --- a/hermes_cli/proxy/server.py +++ b/hermes_cli/proxy/server.py @@ -11,6 +11,7 @@ import asyncio import logging import signal from typing import Optional +from urllib.parse import urlsplit try: import aiohttp @@ -53,6 +54,38 @@ def _json_error(status: int, message: str, code: str = "proxy_error") -> "web.Re return web.json_response(body, status=status) +_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) +_WILDCARD_HOSTS = frozenset({"", "0.0.0.0", "::"}) + + +def _authority_hostname(authority: str) -> str: + """Lowercased hostname of a Host-header authority; ``""`` when malformed (fails closed).""" + if not authority or any(c in authority for c in "@/\\?# \t"): + return "" + try: + parts = urlsplit("//" + authority) + parts.port # noqa: B018 — raises ValueError on a malformed port + except ValueError: + return "" + return (parts.hostname or "").lower() + + +def _local_request_error(host_header: str, origin: Optional[str], bound_host: str) -> Optional[str]: + """Why a request must be refused, or None. The proxy attaches the operator's subscription + credential to whatever reaches it, so a web page in the operator's browser must not be able + to drive it: a loopback/specific-IP bind accepts only its own Host names (a DNS-rebound + hostname fails), and any Origin other than the proxy's own is a cross-site browser request. + Non-browser clients send no Origin and are unaffected. A wildcard bind is an explicit LAN + opt-in, so only the Origin rule applies there.""" + hostname = _authority_hostname(host_header) + bound = bound_host.strip("[]").lower() + if not hostname or (bound not in _WILDCARD_HOSTS and hostname not in _LOOPBACK_HOSTS | {bound}): + return "host_not_allowed" + if origin is not None and origin.strip().lower() != f"http://{host_header.strip().lower()}": + return "origin_not_allowed" + return None + + def _filter_headers(headers, drop: frozenset = _HOP_BY_HOP_HEADERS) -> dict: """Strip hop-by-hop (+ auth) headers; ``drop`` widens the set for upstream responses.""" return {key: value for key, value in headers.items() if key.lower() not in drop} @@ -123,7 +156,7 @@ async def _stream_back(request: "web.Request", session, upstream_resp) -> "web.S return resp -def create_app(adapter: UpstreamAdapter) -> "web.Application": +def create_app(adapter: UpstreamAdapter, bound_host: str = DEFAULT_HOST) -> "web.Application": """Build the aiohttp application bound to a specific upstream adapter. Every adapter method is synchronous and blocking (the Nous adapter takes the 15s cross-process @@ -132,7 +165,15 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application": the single loop and every other in-flight streaming completion. """ _require_aiohttp() - app = web.Application(client_max_size=MAX_REQUEST_BYTES) + + @web.middleware + async def local_only(request: "web.Request", handler): + refusal = _local_request_error(request.headers.get("Host", ""), request.headers.get("Origin"), bound_host) + if refusal: + return _json_error(403, "Request refused: only local, non-browser clients may use this proxy.", code=refusal) + return await handler(request) + + app = web.Application(client_max_size=MAX_REQUEST_BYTES, middlewares=[local_only]) # AppKey: forward-compat with aiohttp versions that strip bare-string keys. app[web.AppKey("adapter", UpstreamAdapter)] = adapter @@ -189,7 +230,7 @@ async def run_server( ) -> None: """Run the proxy in the current event loop until shutdown_event is set.""" _require_aiohttp() - app = create_app(adapter) + app = create_app(adapter, bound_host=host) runner = web.AppRunner(app, access_log=None) await runner.setup() site = web.TCPSite(runner, host=host, port=port) diff --git a/tests/hermes_cli/test_proxy.py b/tests/hermes_cli/test_proxy.py index a9c33b4716..f0a558abfa 100644 --- a/tests/hermes_cli/test_proxy.py +++ b/tests/hermes_cli/test_proxy.py @@ -365,6 +365,38 @@ def test_server_strips_client_auth_header(): asyncio.run(run()) +@pytest.mark.parametrize("headers,allowed", [ + ({}, True), # plain local client + ({"Origin": "http://{authority}"}, True), # the proxy's own origin + ({"Host": "localhost:{port}"}, True), # loopback alias + ({"Host": "rebound.example:{port}"}, False), # foreign Host + ({"Origin": "https://site.example"}, False), # cross-site browser request + ({"Origin": "null"}, False), # opaque browser origin +]) +def test_loopback_proxy_serves_only_local_non_browser_requests(headers, allowed): + """A loopback-bound proxy forwards with the operator's credential only for requests whose Host + names the loopback listener and that carry no foreign Origin; refused ones never go upstream.""" + async def run(): + captured: Dict[str, Any] = {"requests": []} + upstream_runner, upstream_base = await _start_runner(_build_fake_upstream(captured)) + proxy_runner, proxy_base = await _start_runner(create_app(FakeAdapter(f"{upstream_base}/v1"))) + authority = proxy_base.removeprefix("http://") + sent = {k: v.format(authority=authority, port=authority.rsplit(":", 1)[1]) for k, v in headers.items()} + try: + async with aiohttp.ClientSession() as session: + async with session.post(f"{proxy_base}/v1/chat/completions", json={}, headers=sent) as resp: + await resp.read() + status = resp.status + finally: + await proxy_runner.cleanup() + await upstream_runner.cleanup() + assert (status == 200, len(captured["requests"]) == 1) == (allowed, allowed), status + if not allowed: + assert 400 <= status < 500 + + asyncio.run(run()) + + def _build_sse_upstream( frames: list[bytes], *, diff --git a/website/docs/user-guide/features/subscription-proxy.md b/website/docs/user-guide/features/subscription-proxy.md index 5e7e852a3c..08d97357f1 100644 --- a/website/docs/user-guide/features/subscription-proxy.md +++ b/website/docs/user-guide/features/subscription-proxy.md @@ -161,15 +161,20 @@ OpenAI-compatible client. ## Exposing on LAN -By default the proxy binds `127.0.0.1` (localhost only). To let other -machines on your network use it: +By default the proxy binds `127.0.0.1` (localhost only). It refuses requests +whose `Host` header is not its own address (`localhost`, `127.0.0.1`, `[::1]` +or the bound IP) and any request carrying a browser `Origin` other than its +own, so a web page open in your browser cannot use it. Clients such as SDKs +and `curl` send no `Origin` and are unaffected. To let other machines on your +network use it: ```bash hermes proxy start --host 0.0.0.0 --port 8645 ``` ⚠ **Be aware:** anyone on your network can now use your Portal -subscription. The proxy has no auth of its own — it accepts any bearer. +subscription. A wildcard bind skips the `Host` check (any name may reach it); +the browser `Origin` check still applies. The proxy has no auth of its own — it accepts any bearer. Use a firewall, VPN, or reverse proxy with proper auth if you expose this beyond your trusted network.