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)
This commit is contained in:
John Paul Soliva
2026-09-28 16:36:18 +09:00
committed by kshitij
parent c4f1e9ed57
commit 4b1ff38e51
3 changed files with 84 additions and 6 deletions

View File

@@ -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)

View File

@@ -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],
*,

View File

@@ -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.