proxy: add opt-in inbound bearer middleware (HERMES_API_KEY) - #28077
proxy: add opt-in inbound bearer middleware (HERMES_API_KEY)#28077Slimydog21 wants to merge 1 commit into
Conversation
When ``HERMES_API_KEY`` resolves to a non-empty value (via ``hermes_cli.config.get_env_value`` so ``~/.hermes/.env`` is honored), the proxy enforces a constant-time bearer match on every inbound ``/v1/*`` request. Mismatch returns 401 with an OpenAI-style error body; the upstream is never contacted. When the env var is unset, the legacy localhost-only behavior is preserved unchanged — existing nous-on-127.0.0.1 deployments are not regressed. ``/health`` is exempt so operators can probe status without sending the credential. The middleware replaces the inbound bearer with the adapter's upstream credential after authentication, preserving the existing contract that the client's Authorization header never leaks to upstream. Why opt-in rather than mandatory: deployments that bind to 127.0.0.1 already have an effective security boundary. The middleware exists for deployments that expose the proxy beyond localhost (e.g. a Cloudflare Tunnel terminating at the proxy's 127.0.0.1:8645 to make Hermes-managed OAuth-backed inference reachable from another machine). Tests (7 new, 35 total in the proxy file): - env unset → no enforcement (legacy contract) - env set, missing Authorization → 401 inbound_auth_missing - env set, wrong token → 401 inbound_auth_mismatch - env set, malformed scheme (e.g. Basic) → 401 - env set, matching token → request forwarded; inbound bearer not leaked to upstream (replaced by adapter's bearer) - /health always open regardless of env - _resolve_inbound_bearer strips whitespace + treats empty as unset
magnus919
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Comment — clean, well-structured implementation. One minor style nit, nothing blocking.
What it does
Adds an opt-in inbound bearer auth middleware to hermes proxy. When HERMES_API_KEY is set (via env or ~/.hermes/.env), the proxy requires Authorization: Bearer <token> on every inbound request except /health. When unset, legacy localhost-only behavior is preserved.
✅ Looks Good
- Opt-in by design: unset env var = no behavior change. Existing localhost deployments are not regressed.
- Constant-time compare: uses
secrets.compare_digest— correct for bearer token comparison. /healthexempted: operators can probe without credentials.- Inbound bearer stripped before forwarding: preserves the existing contract that client auth doesn't leak upstream.
- Env resolution via
get_env_value: honors~/.hermes/.env, matching the pattern used elsewhere in the codebase (tools/xai_http.py). - Error codes are distinct:
inbound_auth_missingvsinbound_auth_mismatch— useful for debugging. - Test coverage: 7 new tests covering unset, missing, wrong, malformed, matching, health exemption, and whitespace stripping. 35 total passing.
💡 Minor suggestion (non-blocking)
The import secrets as _secrets is inside the middleware handler function, meaning it runs on every request. For a string comparison on a hot path, this is trivially fast, but conventionally these imports go at module level. Consider moving it to the top of the function or module.
Summary
Solid, well-tested auth middleware for the proxy. The opt-in design correctly avoids regressing localhost-only deployments while securing exposed endpoints. The implementation is straightforward and the test coverage is thorough.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the security-focused proxy hardening. I verified the premise against current main: create_app() still builds a plain web.Application() in hermes_cli/proxy/server.py:92, while docs still warn that exposed proxy instances accept any bearer at website/docs/user-guide/features/subscription-proxy.md:171.
Problems
- The behavior change needs user-facing docs/help updates. Current docs say the proxy ignores client auth at
website/docs/user-guide/features/subscription-proxy.md:65and has no auth of its own atwebsite/docs/user-guide/features/subscription-proxy.md:171; CLI/help copy says “any bearer” athermes_cli/proxy/cli.py:62andhermes_cli/subcommands/gateway.py:251. HERMES_API_KEYis a broad name that current main already uses as a generic API key fallback intui_gateway/server.py:9851. A proxy-specific secret name would reduce surprising opt-in behavior.
Suggested changes
- Add a short “optional inbound bearer” section to the subscription proxy docs and env reference.
- Consider renaming the secret to a proxy-specific key such as
HERMES_PROXY_API_KEY/HERMES_PROXY_INBOUND_BEARER.
This is an automated hermes-sweeper review.
| # nous adapter, for example) rely on the bind-to-127.0.0.1 boundary | ||
| # alone. Forcing inbound bearer on those would be a regression. | ||
| # Opt-in lets a deployment that exposes the proxy beyond localhost | ||
| # (e.g. via a Cloudflare Tunnel pointing at hermes-bridge.<domain>) |
There was a problem hiding this comment.
Consider a proxy-specific secret name here. Current main already references HERMES_API_KEY as a generic API key in tui_gateway/server.py:9851, so this could silently enable proxy inbound auth for users who set that generic key for another purpose.
|
Thanks for the security-focused proxy hardening. The premise still holds on current main: Problems
Suggested changes
This is an automated hermes-sweeper review. |
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Summary
Three PRs in this complex modify proxy behavior, but they address distinct causes: #28077 adds optional inbound authentication, #29279 closes upstream resources when response preparation fails, and #63579 corrects timeout classification while adding broader failure-path tests.
Related pull requests
- #28077
related— (+274/-1) — keep open for revision: the diff adds constant-time opt-in bearer enforcement and preserves upstream credential replacement, directly addressing unauthenticated exposure. Consistent with the keep_open reviews on #28077, it should use a proxy-specific environment variable, update documentation/help that currently promises “any bearer,” and test the documented .env resolution path before merge. - #29279
related— (+102/-14) — merge independently: the diff moves StreamResponse preparation inside the existing cleanup try/finally, so prepare failures release the upstream response and close its ClientSession; the regression test exercises that exact leak path. This matches the keep_open review on #29279 and does not overlap #28077’s authentication change. - #63579 [closed]
related— (+129/-9) — separate closed fix, still relevant: the diff orders asyncio.TimeoutError before aiohttp.ClientError so ServerTimeoutError produces the intended 504 rather than 502, with route-level regression coverage; its additional BlueBubbles tests are unrelated to the proxy cause. The positive keep_open review on #63579 confirms the timeout bug remained on main, but this is not an implementation of #28077 or #29279.
Suggested consolidation
Merge #29279 after CI as the focused resource-cleanup fix; keep #28077 open until its contributor-requested naming, documentation/help, and .env-test gaps are resolved. Do not close any of these PRs as duplicates: #63579’s closed timeout-classification fix should be evaluated separately rather than consolidated into either open PR.
Cross-PR triage: Reviewed 3 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 27 kB of PR diffs, 5 kB of issue/PR text, 6 kB of discussion (5 comments), 0 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
|
@GottZ the botslop is getting kind of excessive. Especially when the primary contributor has already weighed in and your bot is just adding noise to all of our inboxes. |
@magnus919 That is fair, and the specific version of it is fair too: teknium1 had already posted a On the linking, it may help to see what the filter removed rather than what it kept. The visible links are the residue, not the output. For this PR specifically
That negative result is the point. The expensive failure mode in a backlog this size is not a missing link — it is closing a PR as a duplicate when it actually fixes something else, or merging one of six identical PRs while the other five sit open for months. Why this exists at all The repository currently has 17,773 open PRs and 8,387 open issues, and roughly 30% of the open PRs are likely duplicates of another open PR. At that scale nobody can answer "has someone already fixed this, and can I close five of these six?" by reading threads. The graph answers that from the diffs rather than the titles, so review capacity goes to distinct work instead of the same fix six times over. One current example, deliberately without issue numbers so this comment does not ping seventeen more threads: ten open PRs are all fixing the same CPython 3.14 On inbox noise, honestly Across all targets, 90.5% received exactly one comment (3,229 of 3,569), averaging 1.17 per target. One cross-PR note per thread is the intent, not a conversation. But the tail is real: a handful of threads received up to nine, and the maintainer-already-reviewed case above should never have been posted at all. Both are worth fixing, and I would rather hear it than not. If you would prefer no automated triage comments on threads you are involved in, say so and I will exclude you — no argument needed. |
Summary
Adds an opt-in inbound bearer middleware to
hermes proxy. WhenHERMES_API_KEYresolves to a non-empty value (viahermes_cli.config.get_env_valueso~/.hermes/.envis honored), the proxy enforces a constant-time bearer match on every inbound/v1/*request. When unset, the legacy localhost-only behavior is preserved unchanged — existingnous-on-127.0.0.1 deployments are not regressed.Why
hermes proxytoday assumes the bind-to-127.0.0.1 boundary is the security perimeter. That's fine for thenousupstream when callers are the operator's own local apps. It's not sufficient the moment the proxy is exposed beyond localhost — e.g. a Cloudflare Tunnel terminating at the proxy's127.0.0.1:8645to make Hermes-managed OAuth-backed inference reachable from another machine (the case the newxai-oauthupstream is being used for).Without this gate, anyone who learns the tunnel URL can spend the operator's OAuth-attributed quota anonymously.
Design choices
/healthexempt: operators need to probe status without sending the credential.secrets.compare_digest— defense-in-depth.Authorizationheader never leaks to upstream.hermes_cli.config.get_env_valueso~/.hermes/.env(the standard Hermes location) is honored, matching the pattern already used bytools/xai_http.py:resolve_xai_http_credentials.Tests
7 new tests, all green. The full proxy test file now has 35 passing:
inbound_auth_missinginbound_auth_mismatchBasic) → 401/healthalways open regardless of env_resolve_inbound_bearerstrips whitespace + treats empty as unsetTest plan
pytest tests/hermes_cli/test_proxy.py— 35 passedhermes proxy start --provider xai-oauthwithHERMES_API_KEYset, confirmed unauthenticated requests get 401 and matching-bearer requests forward to xAI cleanly