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:
committed by
kshitij
parent
c4f1e9ed57
commit
4b1ff38e51
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
*,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user