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.
This commit is contained in:
Ben
2026-05-27 02:12:27 -07:00
committed by Teknium
parent c3104195b8
commit 034ad95fed
3 changed files with 259 additions and 32 deletions
+21 -3
View File
@@ -88,17 +88,35 @@ auth gate (not recommended on untrusted networks).</p>
"""
def render_login_html() -> str:
"""Return the full HTML for ``GET /login``."""
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)}">'
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))
+35 -9
View File
@@ -71,8 +71,15 @@ def _client_ip(request: Request) -> str:
@router.get("/login", name="login_page")
async def login_page(request: Request) -> HTMLResponse:
# Read the ``next=`` query the gate's ``_unauth_response`` set on
# the redirect URL. Validate against the same same-origin rules the
# callback applies (defence in depth — the gate already filters,
# but /login is reachable directly too).
next_path = _validate_post_login_target(
request.query_params.get("next", "")
)
return HTMLResponse(
render_login_html(),
render_login_html(next_path=next_path),
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
@@ -105,7 +112,7 @@ async def api_auth_providers() -> Any:
@router.get("/auth/login", name="auth_login")
async def auth_login(request: Request, provider: str):
async def auth_login(request: Request, provider: str, next: str = ""):
p = get_provider(provider)
if p is None:
raise HTTPException(
@@ -140,6 +147,16 @@ async def auth_login(request: Request, provider: str):
pkce = ls.cookie_payload.get("hermes_session_pkce", "")
if "provider=" not in pkce:
pkce = f"provider={provider};{pkce}" if pkce else f"provider={provider}"
# Carry ``next=`` through the round trip in the PKCE cookie. Real
# IDPs only echo back ``code`` + ``state`` on the callback URL, so
# query-string transport would lose the value — the cookie is the
# only server-controlled channel that survives. Validate before we
# store it so an attacker who reaches /auth/login directly with
# ``next=//evil.example`` can't poison the cookie.
safe_next = _validate_post_login_target(next)
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))
return resp
@@ -151,7 +168,6 @@ async def auth_callback(
state: str = "",
error: str = "",
error_description: str = "",
next: str = "",
):
pkce_raw = read_pkce_cookie(request)
if not pkce_raw:
@@ -165,13 +181,21 @@ async def auth_callback(
detail="Missing PKCE state cookie",
)
# Parse ``provider=...;state=...;verifier=...``
# Parse ``provider=...;state=...;verifier=...;next=...`` — the
# ``next`` segment is optional (only present when /auth/login was
# given a next= query). All keys live in the same flat namespace;
# ``next`` carries a URL-encoded path so it never contains ``;``.
parts = dict(
seg.split("=", 1) for seg in pkce_raw.split(";") if "=" in seg
)
provider_name = parts.get("provider", "")
expected_state = parts.get("state", "")
verifier = parts.get("verifier", "")
# Read next= from the cookie ONLY. The IDP doesn't echo next= back
# on the callback URL (it only carries ``code`` + ``state``), so any
# next= query parameter on the callback URL is attacker-controlled
# and MUST be ignored.
next_from_cookie = parts.get("next", "")
p = get_provider(provider_name)
if p is None:
@@ -242,11 +266,13 @@ async def auth_callback(
)
expires_in = max(60, session.expires_at - int(time.time()))
# Honour the ``next=`` query param the gate's _unauth_response set in
# the redirect URL. Validated against the same same-origin rules as
# the gate's _safe_next_target — any absolute URL / protocol-relative
# path / loop back to /login is dropped in favour of ``/``.
landing = _validate_post_login_target(next) or "/"
# Honour the ``next=`` value the gate's _unauth_response set in the
# /login redirect URL and that /auth/login persisted into the PKCE
# cookie. We re-validate against the same-origin rules here — the
# cookie is server-set so this is defence in depth, but a regression
# that lets attacker-controlled bytes into the cookie would otherwise
# produce an open redirect.
landing = _validate_post_login_target(next_from_cookie) or "/"
resp = RedirectResponse(url=landing, status_code=302)
set_session_cookies(
resp,