mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
Mission-control style deploys reverse-proxy the dashboard at a path
prefix (e.g. mission-control.tilos.com/hermes/* -> :9119) and inject
X-Forwarded-Prefix: /hermes on every request. The SPA mount already
honoured this for asset URLs and the bootstrap __HERMES_BASE_PATH__,
but the OAuth gate didn't:
1. The gate's Location: header to /login and the 401 envelope's
login_url were built bare ("/login?next=..."). Under a /hermes
prefix the browser follows that to mission-control.tilos.com/login
which the proxy doesn't route to the dashboard.
2. _redirect_uri (the OAuth callback URL handed to the IDP) used
request.url_for() which doesn't honour X-Forwarded-Prefix
(Starlette/uvicorn only proxy_headers Host + Proto + For). The
IDP redirects back to /auth/callback instead of /hermes/auth/
callback → 404 in the user's browser.
3. Cookies were set with Path=/ which leaks them to other apps on
the same origin and won't be sent back on requests under the
prefix in the first place.
Fix threads the normalised prefix through every boundary:
* New hermes_cli/dashboard_auth/prefix.py — single source of truth
for X-Forwarded-Prefix parsing. web_server._normalise_prefix
becomes a re-export so the SPA mount, the gate, and the cookies
helper all agree.
* middleware._unauth_response builds login_url = f"{prefix}/login".
* routes._redirect_uri splices the prefix into the path component
of the IDP-bound URL (with full validation of the header).
* cookies.{set,clear}_{session,pkce}_cookie now take prefix="".
Path attribute switches to /hermes when set; cookie name switches
name variant (see below). Every caller passes the request's
normalised prefix.
Cookie hardening (Teknium's lesser-note #1 in the PR review): adopt
the __Host- / __Secure- cookie name prefixes per draft-west-cookie-
prefixes. The variant is selected from (use_https, prefix):
* Loopback HTTP → bare "hermes_session_at" (both prefixes require
Secure, incompatible with HTTP).
* HTTPS, direct deploy (Path=/) → "__Host-hermes_session_at".
Strongest spec: bound to exact origin, no Domain attribute, Secure
required.
* HTTPS, behind a proxy prefix (Path=/hermes) →
"__Secure-hermes_session_at". __Host- forbids Path != "/"; the
explicit Path=/hermes covers same-origin app isolation.
Setter and reader BOTH consult the prefix because the cookie *name*
changes — a reader that looked up the bare name when the setter wrote
__Secure- would never find the value. The reader falls back across
all three variants so a request whose shape changed mid-session (e.g.
post-deploy from no-prefix to /hermes) still picks up the existing
cookie until it expires.
Test coverage:
- tests/hermes_cli/test_dashboard_auth_prefix.py — new file. 11 tests
pinning:
• Location: /hermes/login on the gate's HTML redirect
• 401 envelope login_url carries the prefix
• Malformed X-Forwarded-Prefix is ignored (header-injection
defence; the script-tag value is normalised to empty string)
• _redirect_uri splices /hermes into the path (the property
that prevents the IDP-returns-to-404 failure)
• PKCE cookie uses Path=/hermes + __Secure- when proxied
• Session cookies use __Host- when direct, __Secure- when
proxied, bare on loopback HTTP
• End-to-end round trip with hand-managed PKCE cookie carriage
(TestClient can't simulate a Path=/hermes cookie automatically)
- tests/hermes_cli/test_dashboard_auth_cookies.py — rewritten to pin
each (use_https, prefix) shape produces its expected cookie name,
plus reader-side coverage that __Host- and __Secure- variants are
both recognised.
- Existing tests across middleware / 401-reauth / etc. updated to
match the new cookie names (substring contains instead of
startswith).
Mutation-tested: reverting _unauth_response to build the bare
"/login" URL trips exactly the two tests that pin the prefix
carriage, confirming the suite discriminates the regression.
234 lines
7.6 KiB
Python
234 lines
7.6 KiB
Python
"""Tests for the dashboard-auth cookie helpers."""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.responses import Response
|
|
from fastapi.testclient import TestClient
|
|
from starlette.requests import Request
|
|
|
|
from hermes_cli.dashboard_auth.cookies import (
|
|
PKCE_COOKIE,
|
|
SESSION_AT_COOKIE,
|
|
SESSION_RT_COOKIE,
|
|
clear_pkce_cookie,
|
|
clear_session_cookies,
|
|
read_pkce_cookie,
|
|
read_session_cookies,
|
|
set_pkce_cookie,
|
|
set_session_cookies,
|
|
)
|
|
|
|
|
|
def _build_app(use_https: bool = True, prefix: str = ""):
|
|
app = FastAPI()
|
|
|
|
@app.get("/set")
|
|
def set_endpoint():
|
|
r = Response("ok")
|
|
set_session_cookies(
|
|
r, access_token="AT", refresh_token="RT",
|
|
access_token_expires_in=3600, use_https=use_https,
|
|
prefix=prefix,
|
|
)
|
|
return r
|
|
|
|
@app.get("/set-pkce")
|
|
def set_pkce():
|
|
r = Response("ok")
|
|
set_pkce_cookie(r, payload="provider=stub;state=s;verifier=v",
|
|
use_https=use_https, prefix=prefix)
|
|
return r
|
|
|
|
@app.get("/clear")
|
|
def clear():
|
|
r = Response("ok")
|
|
clear_session_cookies(r, prefix=prefix)
|
|
clear_pkce_cookie(r, prefix=prefix)
|
|
return r
|
|
|
|
return app
|
|
|
|
|
|
# Cookie name resolution helpers used throughout — the bare name resolves
|
|
# to a request-shape-dependent variant (__Host- / __Secure- / bare).
|
|
# Tests pin a specific shape so a regression in the name-resolution
|
|
# logic fails loudly rather than silently breaking sessions.
|
|
|
|
|
|
def test_session_cookies_use_host_prefix_on_https_direct():
|
|
"""HTTPS + no proxy prefix → __Host- prefix (strongest spec
|
|
hardening: bound to exact origin, requires Path=/, requires Secure)."""
|
|
client = TestClient(_build_app(use_https=True, prefix=""))
|
|
r = client.get("/set")
|
|
cookies = r.headers.get_list("set-cookie")
|
|
at = next(c for c in cookies if c.startswith(f"__Host-{SESSION_AT_COOKIE}="))
|
|
rt = next(c for c in cookies if c.startswith(f"__Host-{SESSION_RT_COOKIE}="))
|
|
for c in (at, rt):
|
|
assert "HttpOnly" in c
|
|
assert "samesite=lax" in c.lower()
|
|
assert "Secure" in c
|
|
assert "Path=/" in c
|
|
|
|
|
|
def test_session_cookies_use_secure_prefix_when_proxied():
|
|
"""HTTPS + /hermes prefix → __Secure- prefix (__Host- forbids
|
|
Path != "/"; __Secure- keeps the Secure-required hardening)."""
|
|
client = TestClient(_build_app(use_https=True, prefix="/hermes"))
|
|
r = client.get("/set")
|
|
cookies = r.headers.get_list("set-cookie")
|
|
at = next(c for c in cookies if c.startswith(f"__Secure-{SESSION_AT_COOKIE}="))
|
|
assert "Path=/hermes" in at
|
|
assert "Secure" in at
|
|
# __Host- variant must NOT be emitted on the prefix path.
|
|
assert not any(
|
|
c.startswith(f"__Host-{SESSION_AT_COOKIE}=") for c in cookies
|
|
)
|
|
|
|
|
|
def test_session_cookies_use_bare_name_on_http():
|
|
"""Loopback HTTP dev: __Host- / __Secure- both require Secure, which
|
|
we can't set on HTTP. Use bare cookie names."""
|
|
client = TestClient(_build_app(use_https=False))
|
|
r = client.get("/set")
|
|
cookies = r.headers.get_list("set-cookie")
|
|
# Bare name present; no __Host- / __Secure- variant emitted.
|
|
assert any(c.startswith(f"{SESSION_AT_COOKIE}=") for c in cookies)
|
|
assert not any(
|
|
c.startswith(f"__Host-{SESSION_AT_COOKIE}=")
|
|
or c.startswith(f"__Secure-{SESSION_AT_COOKIE}=")
|
|
for c in cookies
|
|
)
|
|
# No Secure flag (HTTP).
|
|
at = next(c for c in cookies if c.startswith(f"{SESSION_AT_COOKIE}="))
|
|
assert "Secure" not in at
|
|
|
|
|
|
def test_session_cookies_have_30day_rt_and_token_ttl_at():
|
|
client = TestClient(_build_app(use_https=True))
|
|
r = client.get("/set")
|
|
cookies = r.headers.get_list("set-cookie")
|
|
at = next(c for c in cookies if c.startswith(f"__Host-{SESSION_AT_COOKIE}="))
|
|
rt = next(c for c in cookies if c.startswith(f"__Host-{SESSION_RT_COOKIE}="))
|
|
assert "Max-Age=3600" in at
|
|
assert "Max-Age=2592000" in rt # 30 days = 30 * 86400
|
|
|
|
|
|
def test_clear_session_cookies_emits_expired_at_and_rt():
|
|
"""``clear_session_cookies`` emits Max-Age=0 deletions for every
|
|
plausible cookie-name variant under the active prefix so we flush
|
|
stale cookies that an older deploy may have set under a different
|
|
prefix."""
|
|
client = TestClient(_build_app())
|
|
r = client.get("/clear")
|
|
cookies = r.headers.get_list("set-cookie")
|
|
# At least one variant of each session cookie should be deleted.
|
|
assert any(
|
|
SESSION_AT_COOKIE in c and "Max-Age=0" in c for c in cookies
|
|
)
|
|
assert any(
|
|
SESSION_RT_COOKIE in c and "Max-Age=0" in c for c in cookies
|
|
)
|
|
|
|
|
|
def test_pkce_cookie_short_ttl_and_path_root():
|
|
client = TestClient(_build_app(use_https=True))
|
|
r = client.get("/set-pkce")
|
|
pkce = next(
|
|
c for c in r.headers.get_list("set-cookie")
|
|
if PKCE_COOKIE in c
|
|
)
|
|
assert "HttpOnly" in pkce
|
|
assert "Max-Age=600" in pkce # 10 minutes
|
|
assert "Path=/" in pkce
|
|
assert "Secure" in pkce
|
|
|
|
|
|
def test_read_session_cookies_from_request_bare_name():
|
|
"""Reader accepts the bare name (loopback) by default."""
|
|
scope = {
|
|
"type": "http",
|
|
"method": "GET",
|
|
"path": "/",
|
|
"headers": [(
|
|
b"cookie",
|
|
f"{SESSION_AT_COOKIE}=at_value; {SESSION_RT_COOKIE}=rt_value".encode(),
|
|
)],
|
|
}
|
|
req = Request(scope)
|
|
at, rt = read_session_cookies(req)
|
|
assert at == "at_value"
|
|
assert rt == "rt_value"
|
|
|
|
|
|
def test_read_session_cookies_from_request_host_prefix():
|
|
"""Reader also finds cookies set with the __Host- variant
|
|
(HTTPS direct deploy)."""
|
|
scope = {
|
|
"type": "http",
|
|
"method": "GET",
|
|
"path": "/",
|
|
"headers": [(
|
|
b"cookie",
|
|
f"__Host-{SESSION_AT_COOKIE}=at_value; "
|
|
f"__Host-{SESSION_RT_COOKIE}=rt_value".encode(),
|
|
)],
|
|
}
|
|
req = Request(scope)
|
|
at, rt = read_session_cookies(req)
|
|
assert at == "at_value"
|
|
assert rt == "rt_value"
|
|
|
|
|
|
def test_read_session_cookies_from_request_secure_prefix():
|
|
"""Reader also finds cookies set with the __Secure- variant
|
|
(HTTPS behind a proxy prefix)."""
|
|
scope = {
|
|
"type": "http",
|
|
"method": "GET",
|
|
"path": "/",
|
|
"headers": [(
|
|
b"cookie",
|
|
f"__Secure-{SESSION_AT_COOKIE}=at_value; "
|
|
f"__Secure-{SESSION_RT_COOKIE}=rt_value".encode(),
|
|
)],
|
|
}
|
|
req = Request(scope)
|
|
at, rt = read_session_cookies(req)
|
|
assert at == "at_value"
|
|
assert rt == "rt_value"
|
|
|
|
|
|
def test_read_session_cookies_missing_returns_none():
|
|
req = Request({"type": "http", "method": "GET", "path": "/", "headers": []})
|
|
assert read_session_cookies(req) == (None, None)
|
|
|
|
|
|
def test_read_pkce_cookie_round_trip():
|
|
scope = {
|
|
"type": "http",
|
|
"method": "GET",
|
|
"path": "/",
|
|
"headers": [(b"cookie", f"{PKCE_COOKIE}=state=s;verifier=v".encode())],
|
|
}
|
|
req = Request(scope)
|
|
assert read_pkce_cookie(req) == "state=s" # NB: cookie value stops at ';'
|
|
|
|
|
|
def test_detect_https_via_scheme():
|
|
"""``detect_https`` reads from request.url.scheme.
|
|
|
|
Under uvicorn proxy_headers=True the scheme is rewritten from
|
|
``X-Forwarded-Proto``; that's an integration concern, not unit.
|
|
"""
|
|
from hermes_cli.dashboard_auth.cookies import detect_https
|
|
http_req = Request({
|
|
"type": "http", "method": "GET", "path": "/", "scheme": "http",
|
|
"headers": [], "server": ("x", 80),
|
|
})
|
|
https_req = Request({
|
|
"type": "http", "method": "GET", "path": "/", "scheme": "https",
|
|
"headers": [], "server": ("x", 443),
|
|
})
|
|
assert detect_https(http_req) is False
|
|
assert detect_https(https_req) is True
|