feat(dashboard-auth): honour X-Forwarded-Prefix + __Host-/__Secure- cookies

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.
This commit is contained in:
Ben 2026-05-26 09:40:00 +10:00 committed by Teknium
parent 034ad95fed
commit b26d81d536
9 changed files with 724 additions and 92 deletions

View file

@ -14,16 +14,34 @@ Three cookies in play:
All three are ``SameSite=Lax`` (browser will send on cross-site GET
top-level navigation, which we need for the IDP redirect back to
``/auth/callback``) and ``Path=/``. ``Secure`` is set ONLY when the
dashboard was reached over HTTPS detected via the request URL scheme,
which honours ``X-Forwarded-Proto`` upstream of Fly's TLS terminator
when uvicorn is configured with ``proxy_headers=True``. Loopback dev
traffic is always HTTP so ``Secure`` would lock the cookies out of
the browser.
``/auth/callback``) and live under the prefix's Path. ``Secure`` is set
ONLY when the dashboard was reached over HTTPS detected via the
request URL scheme, which honours ``X-Forwarded-Proto`` upstream of
Fly's TLS terminator when uvicorn is configured with
``proxy_headers=True``. Loopback dev traffic is always HTTP so
``Secure`` would lock the cookies out of the browser.
Cookie prefix selection (browser hardening per
https://datatracker.ietf.org/doc/html/draft-west-cookie-prefixes):
* Loopback HTTP bare name. ``__Host-`` / ``__Secure-`` require
``Secure``, which is incompatible with HTTP.
* Gated HTTPS, direct deploy (Path=/) ``__Host-`` prefix. Binds the
cookie to the exact origin (no Domain attribute) strongest spec
guarantee.
* Gated HTTPS, behind a reverse-proxy prefix (Path=/hermes)
``__Secure-`` prefix. ``__Host-`` is disallowed when Path != "/";
``__Secure-`` keeps the Secure-required hardening without the
Path constraint, and the explicit ``Path=/hermes`` covers
same-origin app isolation.
The setters and readers BOTH consult the active prefix because the
cookie *name* changes a reader that looked up the bare name when the
setter wrote ``__Secure-hermes_session_at`` would never find the value.
.. deprecated:: contract v1
``set_session_cookies`` accepts ``refresh_token=""`` (the contract-v1
default) and silently skips writing ``hermes_session_rt`` in that case.
default) and silently skips writing the RT cookie in that case.
``clear_session_cookies`` still emits a Max-Age=0 deletion for the RT
cookie so users carrying a stale cookie from an earlier deployment get
it cleared on logout / session expiry. The full refresh-flow machinery
@ -36,20 +54,58 @@ from typing import Optional, Tuple
from fastapi import Request
from fastapi.responses import Response
# Bare cookie names — the request-scoped ``_resolved_name`` helper
# decides whether to prepend ``__Host-`` / ``__Secure-`` based on the
# request's HTTPS + prefix combination.
SESSION_AT_COOKIE = "hermes_session_at"
SESSION_RT_COOKIE = "hermes_session_rt"
PKCE_COOKIE = "hermes_session_pkce"
# Possible name variants we may have to read back. Sorted so most-strict
# wins on iteration when both happen to be present (shouldn't happen in
# practice — a single request emits exactly one variant).
_NAME_VARIANTS = ("__Host-", "__Secure-", "")
# 30 days — matches Portal's REFRESH_TOKEN_TTL_SECONDS
_RT_MAX_AGE = 30 * 24 * 60 * 60
_PKCE_MAX_AGE = 10 * 60
def _common_attrs(use_https: bool) -> dict:
def _resolved_name(bare: str, *, use_https: bool, prefix: str) -> str:
"""Pick the cookie-prefix variant for the active request shape.
See module docstring for the prefix selection rules. Mismatch
between setter and reader would silently break sessions, so this
function is the single source of truth for naming.
"""
if not use_https:
return bare
if prefix:
# Path != "/" forbids __Host-; fall back to __Secure-.
return f"__Secure-{bare}"
return f"__Host-{bare}"
def _cookie_path(prefix: str) -> str:
"""Cookie ``Path`` attribute for the active deploy shape.
Under ``X-Forwarded-Prefix: /hermes`` we want ``Path=/hermes`` so:
a) the browser sends the cookie back on requests under the prefix
(browsers omit the cookie if request path doesn't start with
Path);
b) the cookie doesn't leak to other apps on the same origin
(``mission-control.tilos.com/billing/...``).
Direct-deploy (no proxy prefix) gets ``Path=/``.
"""
return prefix if prefix else "/"
def _common_attrs(*, use_https: bool, prefix: str) -> dict:
attrs: dict = {
"httponly": True,
"samesite": "lax",
"path": "/",
"path": _cookie_path(prefix),
}
if use_https:
attrs["secure"] = True
@ -63,6 +119,7 @@ def set_session_cookies(
refresh_token: str,
access_token_expires_in: int,
use_https: bool,
prefix: str = "",
) -> None:
"""Set the session cookies on the response.
@ -74,60 +131,96 @@ def set_session_cookies(
so a ``Session.refresh_token == ""`` from the provider means we don't
persist anything. If a future contract revision starts emitting refresh
tokens, this helper will write the RT cookie again with no other change.
``prefix`` is the normalised X-Forwarded-Prefix value (e.g. ``/hermes``)
or ``""`` for a direct deploy. It influences both the cookie name
(``__Host-`` vs ``__Secure-`` vs bare) and the ``Path`` attribute.
"""
response.set_cookie(
SESSION_AT_COOKIE, access_token,
_resolved_name(SESSION_AT_COOKIE, use_https=use_https, prefix=prefix),
access_token,
max_age=access_token_expires_in,
**_common_attrs(use_https),
**_common_attrs(use_https=use_https, prefix=prefix),
)
# Contract v1: empty refresh token means "don't persist RT cookie".
# Keeping a literal empty-value cookie around would be dead state at
# best, attack surface at worst.
if refresh_token:
response.set_cookie(
SESSION_RT_COOKIE, refresh_token,
_resolved_name(SESSION_RT_COOKIE, use_https=use_https, prefix=prefix),
refresh_token,
max_age=_RT_MAX_AGE,
**_common_attrs(use_https),
**_common_attrs(use_https=use_https, prefix=prefix),
)
def clear_session_cookies(response: Response) -> None:
"""Emit Max-Age=0 deletions for both session cookies."""
# Path must match the set-path for the delete to apply.
response.set_cookie(
SESSION_AT_COOKIE, "", max_age=0,
path="/", httponly=True, samesite="lax",
)
response.set_cookie(
SESSION_RT_COOKIE, "", max_age=0,
path="/", httponly=True, samesite="lax",
)
def clear_session_cookies(response: Response, *, prefix: str = "") -> None:
"""Emit Max-Age=0 deletions for both session cookies.
To delete a cookie reliably the deletion's ``Path`` must match the
set path AND the cookie name must match the variant the setter used.
We don't know which variant was originally set (cookie prefix
depends on the request that set it), so we emit deletions for every
plausible variant under the active path.
"""
path = _cookie_path(prefix)
for variant in _NAME_VARIANTS:
response.set_cookie(
f"{variant}{SESSION_AT_COOKIE}", "", max_age=0,
path=path, httponly=True, samesite="lax",
)
response.set_cookie(
f"{variant}{SESSION_RT_COOKIE}", "", max_age=0,
path=path, httponly=True, samesite="lax",
)
def set_pkce_cookie(response: Response, *, payload: str, use_https: bool) -> None:
def set_pkce_cookie(
response: Response, *, payload: str, use_https: bool, prefix: str = "",
) -> None:
response.set_cookie(
PKCE_COOKIE, payload,
_resolved_name(PKCE_COOKIE, use_https=use_https, prefix=prefix),
payload,
max_age=_PKCE_MAX_AGE,
**_common_attrs(use_https),
**_common_attrs(use_https=use_https, prefix=prefix),
)
def clear_pkce_cookie(response: Response) -> None:
response.set_cookie(
PKCE_COOKIE, "", max_age=0,
path="/", httponly=True, samesite="lax",
)
def clear_pkce_cookie(response: Response, *, prefix: str = "") -> None:
path = _cookie_path(prefix)
for variant in _NAME_VARIANTS:
response.set_cookie(
f"{variant}{PKCE_COOKIE}", "", max_age=0,
path=path, httponly=True, samesite="lax",
)
def _read_with_fallback(
request: Request, bare_name: str,
) -> Optional[str]:
"""Read a cookie by checking every prefix variant in order.
The setter chooses one variant based on the active request shape;
the reader doesn't know which one fired (the request that READS
the cookie may not be the same shape as the request that SET it
in pathological cases). Trying all three guarantees we find it.
"""
for variant in _NAME_VARIANTS:
value = request.cookies.get(f"{variant}{bare_name}")
if value is not None:
return value
return None
def read_session_cookies(request: Request) -> Tuple[Optional[str], Optional[str]]:
"""Returns (access_token, refresh_token), either may be None."""
at = request.cookies.get(SESSION_AT_COOKIE)
rt = request.cookies.get(SESSION_RT_COOKIE)
at = _read_with_fallback(request, SESSION_AT_COOKIE)
rt = _read_with_fallback(request, SESSION_RT_COOKIE)
return at, rt
def read_pkce_cookie(request: Request) -> Optional[str]:
return request.cookies.get(PKCE_COOKIE)
return _read_with_fallback(request, PKCE_COOKIE)
def detect_https(request: Request) -> bool:

View file

@ -73,10 +73,22 @@ def _unauth_response(request: Request, *, reason: str) -> Response:
HTML redirects also carry the ``next=`` query string so direct
navigation to ``/sessions`` (etc.) without a cookie comes back to
``/sessions`` after login.
Under a reverse proxy with ``X-Forwarded-Prefix: /hermes``, the
``login_url`` is prefixed (``/hermes/login?next=...``) so the
browser's window.location.assign / Location: follow lands on the
proxied login page rather than the bare ``/login`` (which the
proxy doesn't route to the dashboard).
"""
from hermes_cli.dashboard_auth.prefix import prefix_from_request
path = request.url.path
next_param = _safe_next_target(request)
login_url = f"/login?next={next_param}" if next_param else "/login"
prefix = prefix_from_request(request)
login_url = (
f"{prefix}/login?next={next_param}" if next_param
else f"{prefix}/login"
)
if path.startswith("/api/"):
# API routes never get redirects: the browser fetch() API would
@ -183,9 +195,12 @@ async def gated_auth_middleware(
# Clear the dead cookie so the browser doesn't keep sending it.
# Contract v1: no refresh token to retry with, so the only correct
# next step is full re-auth via /login. Importing locally avoids a
# cycle with cookies → middleware at module load.
# cycle with cookies → middleware at module load. Pass the active
# prefix so the deletion's Path matches the set-Path (otherwise
# the browser ignores it).
from hermes_cli.dashboard_auth.cookies import clear_session_cookies
clear_session_cookies(response)
from hermes_cli.dashboard_auth.prefix import prefix_from_request
clear_session_cookies(response, prefix=prefix_from_request(request))
return response
request.state.session = session

View file

@ -0,0 +1,50 @@
"""Helpers for X-Forwarded-Prefix support.
Mission-control style deploys reverse-proxy the dashboard at a path
prefix (e.g. ``mission-control.tilos.com/hermes/*`` -> dashboard on
:9119). The proxy injects ``X-Forwarded-Prefix: /hermes`` so the
backend can reconstruct prefixed URLs (Location: headers, OAuth
redirect_uri, cookie Path attributes, SPA asset URLs).
The single source of truth for the parsed prefix lives here so the
gate middleware, the OAuth routes, the cookie helpers, and the SPA
mount all agree on validation rules.
"""
from __future__ import annotations
from typing import Optional
def normalise_prefix(raw: Optional[str]) -> str:
"""Normalise an X-Forwarded-Prefix header value.
Returns a string like ``"/hermes"`` (no trailing slash) or ``""``
when no prefix is set / the header is malformed. We deliberately
reject anything containing ``..`` or non-printable bytes so a
hostile proxy can't inject HTML or path-traversal sequences via the
prefix.
"""
if not raw:
return ""
p = raw.strip()
if not p:
return ""
if not p.startswith("/"):
p = "/" + p
p = p.rstrip("/")
if (
"//" in p
or ".." in p
or any(c in p for c in ('"', "'", "<", ">", " ", "\n", "\r", "\t"))
):
return ""
if len(p) > 64:
return ""
return p
def prefix_from_request(request) -> str:
"""Convenience wrapper that reads the header off a Starlette/FastAPI
Request and normalises it. Returns ``""`` when no prefix.
"""
return normalise_prefix(request.headers.get("x-forwarded-prefix"))

View file

@ -53,8 +53,26 @@ def _redirect_uri(request: Request) -> str:
Reads from the request URL under uvicorn's ``proxy_headers=True``
this picks up the public https URL from ``X-Forwarded-Host`` plus
``X-Forwarded-Proto``.
Under ``X-Forwarded-Prefix: /hermes`` (Mission Control deploys), we
additionally prepend the prefix to the path so the IDP redirects
the user back to ``https://mission-control.tilos.com/hermes/auth/callback``
rather than the bare ``/auth/callback`` (which the proxy doesn't
route to the dashboard). FastAPI's ``url_for`` doesn't natively
honour X-Forwarded-Prefix that header isn't part of the
Starlette/uvicorn proxy_headers set so we splice the prefix in
manually.
"""
return str(request.url_for("auth_callback"))
from urllib.parse import urlparse, urlunparse
from hermes_cli.dashboard_auth.prefix import prefix_from_request
base = str(request.url_for("auth_callback"))
prefix = prefix_from_request(request)
if not prefix:
return base
parsed = urlparse(base)
return urlunparse(parsed._replace(path=f"{prefix}{parsed.path}"))
def _client_ip(request: Request) -> str:
@ -64,6 +82,18 @@ def _client_ip(request: Request) -> str:
return request.client.host if request.client else ""
def _prefix(request: Request) -> str:
"""Resolve the X-Forwarded-Prefix header for the active request.
Local indirection so the routes pass a consistent value to the
cookie helpers (cookie name + Path attribute) and the gate's
redirect builders (login_url construction). See
``hermes_cli.dashboard_auth.prefix`` for the normalisation rules.
"""
from hermes_cli.dashboard_auth.prefix import prefix_from_request
return prefix_from_request(request)
# ---------------------------------------------------------------------------
# Public: login page (server-rendered HTML, no SPA bundle)
# ---------------------------------------------------------------------------
@ -157,7 +187,10 @@ async def auth_login(request: Request, provider: str, next: str = ""):
if safe_next:
from urllib.parse import quote
pkce = f"{pkce};next={quote(safe_next, safe='')}"
set_pkce_cookie(resp, payload=pkce, use_https=detect_https(request))
set_pkce_cookie(
resp, payload=pkce, use_https=detect_https(request),
prefix=_prefix(request),
)
return resp
@ -280,8 +313,9 @@ async def auth_callback(
refresh_token=session.refresh_token,
access_token_expires_in=expires_in,
use_https=detect_https(request),
prefix=_prefix(request),
)
clear_pkce_cookie(resp)
clear_pkce_cookie(resp, prefix=_prefix(request))
return resp
@ -334,9 +368,10 @@ async def auth_logout(request: Request):
ip=_client_ip(request),
)
resp = RedirectResponse(url="/login", status_code=302)
clear_session_cookies(resp)
clear_pkce_cookie(resp)
prefix = _prefix(request)
resp = RedirectResponse(url=f"{prefix}/login", status_code=302)
clear_session_cookies(resp, prefix=prefix)
clear_pkce_cookie(resp, prefix=prefix)
return resp

View file

@ -3806,24 +3806,13 @@ async def events_ws(ws: WebSocket) -> None:
def _normalise_prefix(raw: Optional[str]) -> str:
"""Normalise an X-Forwarded-Prefix header value.
Returns a string like ``"/hermes"`` (no trailing slash) or ``""`` when
no prefix is set / the header is malformed. We deliberately reject
anything containing ``..`` or non-printable bytes so a hostile proxy
can't inject HTML via the prefix.
Thin re-export of :func:`hermes_cli.dashboard_auth.prefix.normalise_prefix`
the single source of truth lives in the dashboard_auth package so
the gate middleware, the OAuth routes, the cookie helpers, and the
SPA mount all agree on validation rules.
"""
if not raw:
return ""
p = raw.strip()
if not p:
return ""
if not p.startswith("/"):
p = "/" + p
p = p.rstrip("/")
if "//" in p or ".." in p or any(c in p for c in ('"', "'", "<", ">", " ", "\n", "\r", "\t")):
return ""
if len(p) > 64:
return ""
return p
from hermes_cli.dashboard_auth.prefix import normalise_prefix
return normalise_prefix(raw)
def mount_spa(application: FastAPI):

View file

@ -90,17 +90,17 @@ class TestRefreshTokenCookieDeprecation:
client = TestClient(self._build_app(refresh_token=""))
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
rt_cookies = [c for c in cookies if c.startswith(f"{SESSION_RT_COOKIE}=")]
rt_cookies = [c for c in cookies if SESSION_RT_COOKIE in c]
assert rt_cookies == []
# AT cookie still set.
at_cookies = [c for c in cookies if c.startswith(f"{SESSION_AT_COOKIE}=")]
# AT cookie still set (whichever variant the request resolves to).
at_cookies = [c for c in cookies if SESSION_AT_COOKIE in c]
assert len(at_cookies) == 1
def test_present_refresh_token_still_emits_rt_cookie(self):
client = TestClient(self._build_app(refresh_token="forward-compat"))
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
rt_cookies = [c for c in cookies if c.startswith(f"{SESSION_RT_COOKIE}=")]
rt_cookies = [c for c in cookies if SESSION_RT_COOKIE in c]
assert len(rt_cookies) == 1
assert "forward-compat" in rt_cookies[0]
@ -120,7 +120,7 @@ class TestRefreshTokenCookieDeprecation:
r = client.get("/clear")
cookies = r.headers.get_list("set-cookie")
assert any(
c.startswith(f"{SESSION_RT_COOKIE}=") and "Max-Age=0" in c
SESSION_RT_COOKIE in c and "Max-Age=0" in c
for c in cookies
)
@ -456,7 +456,7 @@ class TestAuthLoginPkceCookieNext:
)
assert r.status_code == 302
cookies = r.headers.get_list("set-cookie")
pkce = next(c for c in cookies if c.startswith("hermes_session_pkce="))
pkce = next(c for c in cookies if "hermes_session_pkce" in c)
assert "next=" not in pkce
def test_safe_next_query_encoded_into_cookie(self, gated_app):
@ -465,7 +465,7 @@ class TestAuthLoginPkceCookieNext:
follow_redirects=False,
)
cookies = r.headers.get_list("set-cookie")
pkce = next(c for c in cookies if c.startswith("hermes_session_pkce="))
pkce = next(c for c in cookies if "hermes_session_pkce" in c)
# ``next=`` segment present, URL-encoded.
assert "next=%2Fsessions" in pkce
@ -479,5 +479,5 @@ class TestAuthLoginPkceCookieNext:
follow_redirects=False,
)
cookies = r.headers.get_list("set-cookie")
pkce = next(c for c in cookies if c.startswith("hermes_session_pkce="))
pkce = next(c for c in cookies if "hermes_session_pkce" in c)
assert "next=" not in pkce

View file

@ -20,7 +20,7 @@ from hermes_cli.dashboard_auth.cookies import (
)
def _build_app(use_https: bool = True):
def _build_app(use_https: bool = True, prefix: str = ""):
app = FastAPI()
@app.get("/set")
@ -29,6 +29,7 @@ def _build_app(use_https: bool = True):
set_session_cookies(
r, access_token="AT", refresh_token="RT",
access_token_expires_in=3600, use_https=use_https,
prefix=prefix,
)
return r
@ -36,25 +37,33 @@ def _build_app(use_https: bool = True):
def set_pkce():
r = Response("ok")
set_pkce_cookie(r, payload="provider=stub;state=s;verifier=v",
use_https=use_https)
use_https=use_https, prefix=prefix)
return r
@app.get("/clear")
def clear():
r = Response("ok")
clear_session_cookies(r)
clear_pkce_cookie(r)
clear_session_cookies(r, prefix=prefix)
clear_pkce_cookie(r, prefix=prefix)
return r
return app
def test_session_cookies_are_httponly_samesite_lax_secure_in_https():
client = TestClient(_build_app(use_https=True))
# 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"{SESSION_AT_COOKIE}="))
rt = next(c for c in cookies if c.startswith(f"{SESSION_RT_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()
@ -62,35 +71,63 @@ def test_session_cookies_are_httponly_samesite_lax_secure_in_https():
assert "Path=/" in c
def test_session_cookies_omit_secure_when_http():
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")
for c in r.headers.get_list("set-cookie"):
if c.startswith(f"{SESSION_AT_COOKIE}=") or c.startswith(f"{SESSION_RT_COOKIE}="):
assert "Secure" not in c, f"Cookie unexpectedly Secure: {c}"
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"{SESSION_AT_COOKIE}="))
rt = next(c for c in cookies if c.startswith(f"{SESSION_RT_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(
c.startswith(f"{SESSION_AT_COOKIE}=") and "Max-Age=0" in c
for c in cookies
SESSION_AT_COOKIE in c and "Max-Age=0" in c for c in cookies
)
assert any(
c.startswith(f"{SESSION_RT_COOKIE}=") and "Max-Age=0" in c
for c in cookies
SESSION_RT_COOKIE in c and "Max-Age=0" in c for c in cookies
)
@ -99,7 +136,7 @@ def test_pkce_cookie_short_ttl_and_path_root():
r = client.get("/set-pkce")
pkce = next(
c for c in r.headers.get_list("set-cookie")
if c.startswith(f"{PKCE_COOKIE}=")
if PKCE_COOKIE in c
)
assert "HttpOnly" in pkce
assert "Max-Age=600" in pkce # 10 minutes
@ -107,7 +144,8 @@ def test_pkce_cookie_short_ttl_and_path_root():
assert "Secure" in pkce
def test_read_session_cookies_from_request():
def test_read_session_cookies_from_request_bare_name():
"""Reader accepts the bare name (loopback) by default."""
scope = {
"type": "http",
"method": "GET",
@ -123,6 +161,44 @@ def test_read_session_cookies_from_request():
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)

View file

@ -105,7 +105,7 @@ def test_full_login_round_trip_unlocks_api_status(gated_app):
assert r1.status_code == 302
pkce = next(
(c for c in r1.headers.get_list("set-cookie")
if c.startswith("hermes_session_pkce=")),
if "hermes_session_pkce" in c),
None,
)
assert pkce and "HttpOnly" in pkce
@ -125,8 +125,8 @@ def test_full_login_round_trip_unlocks_api_status(gated_app):
assert r2.status_code == 302
assert r2.headers["location"] == "/"
set_cookies = r2.headers.get_list("set-cookie")
assert any(c.startswith("hermes_session_at=") for c in set_cookies)
assert any(c.startswith("hermes_session_rt=") for c in set_cookies)
assert any("hermes_session_at" in c for c in set_cookies)
assert any("hermes_session_rt" in c for c in set_cookies)
# 3) /api/status now succeeds because we're authenticated.
r3 = gated_app.get("/api/status")

View file

@ -0,0 +1,374 @@
"""Path-prefix (X-Forwarded-Prefix) awareness for the dashboard-auth gate.
Mission-control style deployments reverse-proxy the dashboard at a path
prefix (e.g. ``mission-control.tilos.com/hermes/*`` -> local Caddy ->
:9119), injecting ``X-Forwarded-Prefix: /hermes`` on every request.
The dashboard already honours this for the SPA bundle (rewriting asset
URLs and the bootstrap ``__HERMES_BASE_PATH__``). The OAuth gate must
honour it too:
1. The gate's ``Location:`` redirect to /login (in
``_unauth_response``) needs to be ``/hermes/login`` so the browser
follows it through the proxy.
2. The 401 JSON envelope's ``login_url`` needs the same prefix so the
SPA's full-page navigation lands at the proxied login page.
3. ``_redirect_uri`` (the OAuth callback URL handed to the IDP) must
reconstruct the public URL including the prefix, otherwise the IDP
redirects back to ``/auth/callback`` instead of
``/hermes/auth/callback`` and the user gets 404.
4. Cookies must use ``Path=/hermes`` when behind a prefix so they
don't leak to other apps on the same origin AND so they get sent
back to the dashboard on subsequent requests under the prefix.
5. The ``__Host-`` cookie prefix requires ``Path=/`` when behind an
X-Forwarded-Prefix we use ``__Secure-`` instead (matches every
hardening property except scope, which the explicit ``Path``
covers).
These tests document the wire-level contract so a regression in any of
those rules surfaces before a Mission Control deploy.
"""
from __future__ import annotations
import pytest
# Same xdist group as the other dashboard-auth tests — they all mutate
# web_server.app.state.auth_required at module level.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
@pytest.fixture
def gated_app_proxied():
"""web_server.app configured for gated mode with proxy_headers + a
public Host that simulates the Mission Control reverse proxy.
The ``base_url`` sets ``host:scheme`` defaults so we don't have to
pass them on every request. ``X-Forwarded-Prefix`` is passed
per-request because the TestClient doesn't have a way to default
request headers.
"""
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "mission-control.tilos.com"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(
web_server.app,
base_url="https://mission-control.tilos.com",
)
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
@pytest.fixture
def gated_app_direct():
"""web_server.app configured for gated mode WITHOUT a proxy prefix,
for the Fly-direct deploy shape (no path mounting).
"""
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(
web_server.app,
base_url="https://fly-app.fly.dev",
)
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
# ---------------------------------------------------------------------------
# Gate middleware: Location: header and 401 envelope respect prefix
# ---------------------------------------------------------------------------
class TestGateRedirectsCarryPrefix:
def test_html_redirect_to_login_carries_prefix(self, gated_app_proxied):
r = gated_app_proxied.get(
"/sessions",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
assert r.status_code == 302
# /login redirect must include the prefix or the browser will
# follow it to mission-control.tilos.com/login (which the proxy
# doesn't route to the dashboard).
assert r.headers["location"].startswith("/hermes/login"), (
f"Location header lost prefix: {r.headers['location']!r}"
)
def test_api_401_envelope_login_url_carries_prefix(self, gated_app_proxied):
r = gated_app_proxied.get(
"/api/sessions",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
assert r.status_code == 401
body = r.json()
# SPA does window.location.assign(body.login_url); this MUST
# include the prefix.
assert body["login_url"].startswith("/hermes/login"), (
f"401 envelope login_url lost prefix: {body['login_url']!r}"
)
def test_no_prefix_header_keeps_unprefixed_paths(self, gated_app_direct):
"""When no X-Forwarded-Prefix is sent, the Location header must
NOT gain a phantom prefix the Fly-direct deploy shape has no
proxy at all."""
r = gated_app_direct.get("/sessions", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/login?next=%2Fsessions"
def test_malformed_prefix_header_is_ignored(self, gated_app_proxied):
"""A hostile proxy injects ``X-Forwarded-Prefix: <script>``;
the normaliser rejects it and the gate falls back to unprefixed
URLs. Defence against header-injection HTML inside Location."""
r = gated_app_proxied.get(
"/sessions",
headers={"x-forwarded-prefix": "<script>alert(1)</script>"},
follow_redirects=False,
)
assert r.status_code == 302
assert "<script>" not in r.headers["location"]
assert r.headers["location"].startswith("/login")
# ---------------------------------------------------------------------------
# /auth/login: the OAuth redirect_uri reflects the proxy prefix
# ---------------------------------------------------------------------------
class TestOAuthRedirectUriRespectsPrefix:
def test_redirect_uri_includes_prefix_in_authorize_url(
self, gated_app_proxied
):
"""The IDP returns the user to the redirect_uri we sent. If we
don't include the prefix, the IDP redirects to
``https://mission-control.tilos.com/auth/callback`` instead of
``https://mission-control.tilos.com/hermes/auth/callback`` the
former routes to the MC frontend, not the dashboard, so the
user gets 404."""
r = gated_app_proxied.get(
"/auth/login?provider=stub",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
assert r.status_code == 302
location = r.headers["location"]
# The stub IDP's redirect_url echoes the redirect_uri back. The
# real IDP would consume it and later use it to redirect the
# user, so the byte-exact value MUST include the prefix.
from urllib.parse import urlparse, parse_qs, unquote
# Stub returns ``{redirect_uri}?code=stub_code&state=...`` — so
# we read up to the first ``?``.
redirect_uri = location.split("?", 1)[0]
# Absolute https URL including prefix.
parsed = urlparse(redirect_uri)
assert parsed.scheme == "https"
assert parsed.netloc == "mission-control.tilos.com"
assert parsed.path == "/hermes/auth/callback", (
f"redirect_uri dropped prefix: {redirect_uri!r}"
)
def test_redirect_uri_no_prefix_when_direct_deploy(
self, gated_app_direct
):
r = gated_app_direct.get(
"/auth/login?provider=stub", follow_redirects=False
)
assert r.status_code == 302
redirect_uri = r.headers["location"].split("?", 1)[0]
from urllib.parse import urlparse
parsed = urlparse(redirect_uri)
assert parsed.netloc == "fly-app.fly.dev"
assert parsed.path == "/auth/callback"
# ---------------------------------------------------------------------------
# Cookies: Path attribute + __Host- / __Secure- prefix rules
# ---------------------------------------------------------------------------
class TestCookiePathRespectsPrefix:
"""Cookies must use ``Path=<prefix>`` when behind a proxy so they:
a) get sent back to the dashboard on subsequent requests (browser
only sends a cookie if the request path starts with the cookie's
Path attribute);
b) don't leak to other apps mounted alongside the dashboard
(e.g. ``mission-control.tilos.com/billing/...``).
When the cookie's Path can be ``/`` (no prefix, Fly-direct), we use
the ``__Host-`` cookie prefix for additional hardening it binds
the cookie to the exact host (no Domain attribute) and requires Secure.
"""
def test_pkce_cookie_uses_prefix_path(self, gated_app_proxied):
r = gated_app_proxied.get(
"/auth/login?provider=stub",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
cookies = r.headers.get_list("set-cookie")
pkce = next(c for c in cookies if "hermes_session_pkce" in c)
# Browser only sends cookie back if the request path is under
# the cookie's Path attribute, so we need /hermes here. Bare
# /-rooted cookies would still be sent but would also be sent
# to /billing/... etc.
assert "Path=/hermes" in pkce, (
f"PKCE cookie has wrong Path: {pkce!r}"
)
def test_pkce_cookie_uses_secure_prefix_when_proxied(
self, gated_app_proxied
):
"""Behind a proxy with Path != /, ``__Host-`` is disallowed
(the spec requires Path=/). Fall back to ``__Secure-``, which
carries the same Secure-required guarantee but allows any Path.
"""
r = gated_app_proxied.get(
"/auth/login?provider=stub",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
cookies = r.headers.get_list("set-cookie")
# The PKCE cookie name carries the __Secure- prefix.
pkce_candidates = [
c for c in cookies
if c.startswith("__Secure-hermes_session_pkce=")
]
assert pkce_candidates, (
f"PKCE cookie missing __Secure- prefix: {cookies!r}"
)
def test_pkce_cookie_uses_host_prefix_when_direct(
self, gated_app_direct
):
"""Fly-direct deploy: Path=/ is available, so we can use the
stricter ``__Host-`` prefix. This binds the cookie to the
exact origin (no Domain attribute) best practice for
single-host single-app deploys."""
r = gated_app_direct.get(
"/auth/login?provider=stub", follow_redirects=False
)
cookies = r.headers.get_list("set-cookie")
pkce_candidates = [
c for c in cookies
if c.startswith("__Host-hermes_session_pkce=")
]
assert pkce_candidates, (
f"PKCE cookie missing __Host- prefix on direct deploy: "
f"{cookies!r}"
)
# __Host- requires Path=/ and Secure (cookies spec); both must
# be present even if a regression flips one off.
pkce = pkce_candidates[0]
assert "Path=/" in pkce
assert "Secure" in pkce
def test_loopback_cookies_unprefixed(self):
"""Loopback HTTP dev: no Secure, no __Host- / __Secure-.
The bare cookie name is the right choice neither prefix is
spec-compatible without Secure."""
from fastapi import FastAPI
from fastapi.responses import Response
from hermes_cli.dashboard_auth.cookies import set_pkce_cookie
app = FastAPI()
@app.get("/set")
def _set():
r = Response("ok")
set_pkce_cookie(r, payload="x", use_https=False)
return r
client = TestClient(app)
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
# Bare cookie name, no prefix.
assert any(c.startswith("hermes_session_pkce=") for c in cookies), (
f"Loopback cookie should be bare-named: {cookies!r}"
)
# And no __Host- / __Secure- variant accidentally emitted.
assert not any(
c.startswith("__Host-") or c.startswith("__Secure-")
for c in cookies
)
def test_cookies_read_back_round_trip_through_prefix(
self, gated_app_proxied
):
"""The end-to-end property: after a successful OAuth round
trip via the proxy, the session-AT cookie carries the
__Secure- prefix AND Path=/hermes, so the next request under
the same prefix is authenticated.
Note on TestClient semantics: starlette's TestClient sees the
literal request path (``/auth/login``, ``/auth/callback``)
not the public path the proxy displays to the browser
(``/hermes/auth/login``, ``/hermes/auth/callback``). A cookie
set with ``Path=/hermes`` would therefore NOT be sent back on
the second request through TestClient even though it WOULD be
sent by a real browser hitting ``/hermes/auth/callback``. To
avoid baking that mismatch into the test, we inspect the
``Set-Cookie`` header on the callback's response WITHOUT
depending on the PKCE cookie round-tripping through
TestClient's jar — we drive /auth/callback with an explicit
Cookie header that carries the PKCE value from /auth/login.
"""
# /auth/login sets the PKCE cookie. Capture it from Set-Cookie.
r1 = gated_app_proxied.get(
"/auth/login?provider=stub",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
pkce_set = next(
c for c in r1.headers.get_list("set-cookie")
if "hermes_session_pkce" in c
)
# Parse "__Secure-hermes_session_pkce=...; HttpOnly; ...".
pkce_kv = pkce_set.split(";", 1)[0] # "__Secure-hermes_session_pkce=value"
state = r1.headers["location"].split("state=")[1]
# Round-trip the cookie by hand because TestClient's jar won't
# automatically send a Path=/hermes cookie to a /auth/callback
# request path.
r2 = gated_app_proxied.get(
f"/auth/callback?code=stub_code&state={state}",
headers={
"x-forwarded-prefix": "/hermes",
"cookie": pkce_kv,
},
follow_redirects=False,
)
assert r2.status_code == 302, r2.text
cookies = r2.headers.get_list("set-cookie")
at_cookies = [
c for c in cookies
if c.startswith("__Secure-hermes_session_at=")
]
assert at_cookies, (
f"session_at missing __Secure- prefix: {cookies!r}"
)
assert "Path=/hermes" in at_cookies[0]
assert "Secure" in at_cookies[0]
assert "HttpOnly" in at_cookies[0]