Files
hermes-agent/hermes_cli/dashboard_auth/login_page.py
T
Ben 034ad95fed fix(dashboard-auth): propagate next= through login page + PKCE cookie
The gate's _unauth_response set next=<path> on the /login redirect URL,
but nothing downstream read it: render_login_html ignored next=,
auth_login dropped it, and auth_callback read next= from its own query
string — which an IDP never sets on the callback URL (real IDPs only
echo back code+state). The _validate_post_login_target plumbing in the
callback was unreachable on the happy path, so users always landed on
"/" regardless of what they originally requested.

Worse: reading next= from the callback URL was a latent open-redirect
sink, since an attacker could craft /auth/callback?...&next=/admin and
have the server honour it post-auth.

Fix carries next= through the round trip on a server-controlled channel:

  1. login_page reads request.query_params['next'] and passes it (post-
     validation) to render_login_html.
  2. render_login_html threads next= URL-encoded into each provider
     button's href, with HTML-attribute escaping as defence in depth.
  3. auth_login accepts ?next= as a query param, re-validates, and
     appends it as a fourth segment (next=<urlquoted>) in the PKCE
     cookie payload alongside provider/state/verifier.
  4. auth_callback no longer accepts a next: str = "" query param. It
     parses next= out of the PKCE cookie and validates that with the
     same same-origin rules. Any attacker-supplied ?next= on the
     callback URL is silently ignored — server-only carrier.

Test coverage adds three classes:

  - TestAuthCallbackNext drives /login → /auth/login → IDP-bounce →
    /auth/callback end-to-end without smuggling next= onto the callback
    URL (which is what the previous tests did and why they didn't
    catch the bug). Includes test_attacker_callback_next_param_is_ignored
    to pin the security property that the URL value is never read.
  - TestRenderLoginHtmlNext covers the rendering function at the
    unit boundary so a regression that drops next_path is caught
    without spinning up the full app.
  - TestAuthLoginPkceCookieNext inspects the Set-Cookie header on
    /auth/login responses so a regression in cookie encoding is caught
    without driving the full round trip.

Mutation-tested: reverting auth_callback to read next= from the URL
trips 3 of 6 TestAuthCallbackNext tests (the safe-path and attacker-
hardening ones), confirming the suite discriminates between the cookie
read and the URL read.
2026-05-27 02:12:27 -07:00

123 lines
3.9 KiB
Python

"""Server-rendered /login page.
No React, no JavaScript dependency. Listed providers come from the
registry; clicking a provider sends a GET to
``/auth/login?provider=<name>``.
"""
from __future__ import annotations
import html
from hermes_cli.dashboard_auth import list_providers
# Inline minimal CSS. The dashboard's full skin lives in the React
# bundle, which we deliberately do NOT load here — the login page must
# not depend on the SPA build being present or on the injected session
# token.
_LOGIN_HTML_TEMPLATE = """\
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in — Hermes Agent</title>
<style>
:root {{
--bg: #0a0a0b;
--fg: #e5e5e7;
--accent: #f97316;
--border: #27272a;
}}
html, body {{
margin: 0; padding: 0; height: 100%;
background: var(--bg); color: var(--fg);
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}}
main {{
max-width: 28rem; margin: 10vh auto; padding: 2rem;
border: 1px solid var(--border); border-radius: 0.75rem;
background: rgba(255,255,255,0.02);
}}
h1 {{ margin: 0 0 0.5rem; font-size: 1.5rem; }}
p {{ margin: 0 0 1.5rem; opacity: 0.7; }}
.provider-list {{ display: grid; gap: 0.75rem; }}
.provider-btn {{
display: block; width: 100%; box-sizing: border-box;
padding: 0.875rem 1rem; text-align: center;
background: var(--accent); color: #0a0a0b;
font-weight: 600; font-size: 1rem;
border-radius: 0.5rem; text-decoration: none;
border: 0; cursor: pointer;
}}
.provider-btn:hover {{ filter: brightness(1.1); }}
footer {{
margin-top: 2rem; font-size: 0.875rem;
opacity: 0.5; text-align: center;
}}
</style>
</head>
<body>
<main>
<h1>Sign in to Hermes Agent</h1>
<p>Choose a sign-in method to continue.</p>
<div class="provider-list">
{provider_buttons}
</div>
<footer>This dashboard is bound to a non-loopback host.<br>
Sign-in is required for security.</footer>
</main>
</body>
</html>
"""
_EMPTY_HTML = """\
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Sign-in unavailable — Hermes Agent</title>
</head>
<body><main style="font-family: system-ui; max-width: 36rem; margin: 10vh auto; padding: 2rem;">
<h1>Sign-in unavailable</h1>
<p>This dashboard is bound to a non-loopback host but no authentication
providers are installed.</p>
<p>Install <code>plugins/dashboard-auth-nous</code> (default) or another
auth provider, or restart with <code>--insecure</code> to bypass the
auth gate (not recommended on untrusted networks).</p>
</main></body></html>
"""
def render_login_html(*, next_path: str = "") -> str:
"""Return the full HTML for ``GET /login``.
``next_path`` — when set, the post-login landing path the user
originally requested. Threaded into each provider button's ``href``
as a ``next=`` query parameter so the OAuth round trip carries it
end-to-end. The caller (``routes.login_page``) is responsible for
validating ``next_path`` against the same-origin rules before we
emit it; we still HTML-escape it as defence in depth.
"""
providers = list_providers()
if not providers:
return _EMPTY_HTML
if next_path:
# URL-encode then HTML-escape. The URL-encode step matches the
# gate's ``_safe_next_target`` output shape (also URL-encoded),
# so a value that round-tripped from /login?next=... back into
# the button href is byte-identical.
from urllib.parse import quote
next_qs = f"&next={html.escape(quote(next_path, safe=''), quote=True)}"
else:
next_qs = ""
buttons = []
for p in providers:
buttons.append(
f' <a class="provider-btn" '
f'href="/auth/login?provider={html.escape(p.name, quote=True)}{next_qs}">'
f'Sign in with {html.escape(p.display_name)}</a>'
)
return _LOGIN_HTML_TEMPLATE.format(provider_buttons="\n".join(buttons))