fix(dashboard-auth): drop /api/* paths from OAuth next= round trip (#36244)

When an unauthenticated SPA fetch hit a gated /api/* endpoint (e.g.
GET /api/analytics/models?days=30 fired from ModelsPage on mount or
after a session expiry), the gated middleware stamped the request's
own path into next= on the 401 envelope's login_url. The SPA's global
401 handler in web/src/lib/api.ts full-page-navigated to that URL,
the PKCE cookie carried the encoded /api/* value through the OAuth
round trip to Portal, and /auth/callback's _validate_post_login_target
accepted it as same-origin and redirected the user to the raw JSON
endpoint instead of the dashboard.

Symptom Ben reported: after the OAuth screen he kept landing on
$DOMAIN/api/analytics/models?days=30 (raw JSON) rather than /models.
The bug was deterministic per page — whichever /api/* call ModelsPage,
AnalyticsPage, or SessionsPage fired first owned the redirect race.

Fix: both validators now reject /api/* targets in addition to the
existing /login, /auth/, /api/auth/ exclusions:

  - _safe_next_target in middleware.py drops the value before it ever
    enters login_url, so the SPA's 401 handler navigates to a bare
    /login (which the SPA itself can return-from via its own
    sessionStorage["hermes.lastLocation"] fallback that was already
    saving the actual browser location).
  - _validate_post_login_target in routes.py drops it as second-line
    defence at the callback boundary, so a legacy cookie, a regressed
    middleware, or an attacker-crafted /auth/login?next=/api/... value
    can't smuggle the redirect through. Either layer alone is enough;
    pairing them means a regression in one is caught by the other.

The match is anchored: ``decoded == "/api"`` or
``decoded.startswith("/api/")``. SPA route lookalikes like /apidocs
or /api-keys remain valid landing targets — tests pin that.

Test additions in test_dashboard_auth_401_reauth.py:

  - TestApi401Envelope: rewrote test_login_url_carries_next_for_deep_
    api_path (which asserted the pre-fix behaviour) as
    test_login_url_drops_next_for_deep_api_path, plus added the
    specific analytics-models repro case from Ben's report.
  - TestNextSameOriginValidation: rejects-api-paths + does-not-reject-
    api-prefix-lookalikes (covers /apidocs, /api-keys).
  - TestAuthCallbackNext: end-to-end test_callback_with_api_next_
    lands_at_root drives /auth/login?next=/api/... through to the
    callback and asserts the user lands at "/", not the API URL.
  - TestValidatePostLoginTarget: new class covering the callback-side
    validator directly, including the URL-encoded ``%2Fapi%2F...``
    form the PKCE cookie actually carries.

Mutation-tested: reverting both validators causes exactly the 5 new
or rewritten /api/*-related assertions to fail (each fix layer is
independently tested), while the 31 other assertions in the file
remain green. Full tests/hermes_cli/ suite (288 files, 5,938 tests)
passes with the fix applied.
This commit is contained in:
Ben Barclay
2026-06-01 15:10:20 +10:00
committed by GitHub
parent 7fbe9b79ab
commit e1eba6f8cc
3 changed files with 169 additions and 4 deletions

View File

@ -150,6 +150,16 @@ def _safe_next_target(request: Request) -> str:
for p in ("/login", "/auth/", "/api/auth/")
):
return ""
# Reject ALL ``/api/*`` paths. The 401-envelope code path fires for
# any unauthenticated SPA fetch (e.g. ``GET /api/analytics/models``
# from ModelsPage), and the SPA's global 401 handler full-page
# navigates to ``login_url``. After the OAuth round trip the user
# would land on the API URL and see raw JSON instead of the
# dashboard. SPA routes survive (they don't start with ``/api/``);
# the SPA's own ``sessionStorage["hermes.lastLocation"]`` fallback
# in ``web/src/lib/api.ts`` covers the deep-link case.
if path == "/api" or path.startswith("/api/"):
return ""
# Preserve query string if present (e.g. /sessions?page=2).
query = request.url.query
target = f"{path}?{query}" if query else path

View File

@ -365,6 +365,15 @@ def _validate_post_login_target(raw: str) -> str:
for p in ("/login", "/auth/", "/api/auth/")
):
return ""
# Reject any ``/api/*`` target. The gate's ``_safe_next_target``
# already filters these out before they reach the cookie, but a
# malicious or stale ``next=`` value that re-enters via the
# callback URL must not be honoured: a successful redirect to an
# API endpoint renders raw JSON in the browser address bar — never
# a useful post-login destination, and indistinguishable from an
# attacker trying to weaponise the redirect.
if decoded == "/api" or decoded.startswith("/api/"):
return ""
return decoded

View File

@ -163,12 +163,33 @@ class TestApi401Envelope:
for c in set_cookies
)
def test_login_url_carries_next_for_deep_api_path(self, gated_app):
def test_login_url_drops_next_for_deep_api_path(self, gated_app):
"""Bug fix: ``/api/*`` paths must NOT round-trip into ``next=``.
Before the fix, an unauthenticated SPA fetch like ``GET
/api/analytics/models?days=30`` from ModelsPage round-tripped
through the OAuth dance and landed the user on the raw JSON
endpoint instead of the dashboard. The gate now drops API paths
from ``next=`` entirely; the SPA's own ``hermes.lastLocation``
fallback in ``web/src/lib/api.ts`` covers the deep-link case.
"""
r = gated_app.get("/api/sessions?page=2")
body = r.json()
# next= is URL-encoded.
assert "next=" in body["login_url"]
assert quote("/api/sessions?page=2", safe="") in body["login_url"]
# ``login_url`` is the bare ``/login`` (no ``next=``) — the
# post-callback landing falls back to "/" rather than the API
# URL.
assert body["login_url"] == "/login"
assert "next=" not in body["login_url"]
def test_login_url_drops_next_for_analytics_path(self, gated_app):
"""Specific repro for the ``/api/analytics/models?days=30``
case Ben reported: page on /models, session expires, SPA fires
getModelsAnalytics(), 401 envelope carries ``next=``, user ends
up staring at JSON post-callback."""
r = gated_app.get("/api/analytics/models?days=30")
body = r.json()
assert body["login_url"] == "/login"
assert "next=" not in body["login_url"]
class TestHtmlRedirectNext:
@ -253,6 +274,46 @@ class TestNextSameOriginValidation:
assert _safe_next_target(FakeRequest("/auth/login")) == ""
assert _safe_next_target(FakeRequest("/api/auth/me")) == ""
def test_safe_next_validator_rejects_api_paths(self):
"""``/api/*`` paths must not round-trip through ``next=``.
Any API URL is a JSON endpoint; landing the browser there after
OAuth shows raw JSON instead of the dashboard. This is the bug
fix that closes the analytics-page redirect mishap.
"""
from hermes_cli.dashboard_auth.middleware import _safe_next_target
class FakeRequest:
def __init__(self, path, query=""):
self.url = type("URL", (), {"path": path, "query": query})()
assert _safe_next_target(FakeRequest("/api/analytics/models")) == ""
assert (
_safe_next_target(FakeRequest("/api/analytics/models", "days=30"))
== ""
)
assert _safe_next_target(FakeRequest("/api/sessions")) == ""
assert _safe_next_target(FakeRequest("/api/config")) == ""
assert _safe_next_target(FakeRequest("/api/status")) == ""
# Exact ``/api`` (no trailing slash) also rejected — the dashboard
# has no such SPA route, but pinning the boundary keeps the rule
# crisp.
assert _safe_next_target(FakeRequest("/api")) == ""
def test_safe_next_validator_does_not_reject_api_prefix_lookalikes(self):
"""Negative guard: ``/api-docs`` or ``/apis`` aren't ``/api/*``
and must remain valid landing targets."""
from hermes_cli.dashboard_auth.middleware import _safe_next_target
class FakeRequest:
def __init__(self, path):
self.url = type("URL", (), {"path": path, "query": ""})()
# ``/apidocs`` or ``/api-keys`` lookalike SPA routes — we must
# only match the ``/api/`` prefix or exact ``/api``.
assert _safe_next_target(FakeRequest("/apidocs")) == "%2Fapidocs"
assert _safe_next_target(FakeRequest("/api-keys")) == "%2Fapi-keys"
# ---------------------------------------------------------------------------
# /auth/callback honours next= and validates it
@ -395,6 +456,91 @@ class TestAuthCallbackNext:
# NOT /internal-admin.
assert r.headers["location"] == "/"
def test_callback_with_api_next_lands_at_root(self, gated_app):
"""End-to-end repro of the analytics-redirect bug.
Drive ``/auth/login?next=/api/analytics/models?days=30`` —
exactly what the pre-fix gate would have stamped after a
ModelsPage 401. The validator at /auth/login MUST now drop
``/api/*`` so the PKCE cookie never carries the API path, AND
the callback's ``_validate_post_login_target`` MUST drop it as
second-line defence. Either layer alone is enough; both means
a regression in one is caught by the other.
Discrimination: under the pre-fix code, both validators
accepted ``/api/*`` and the callback redirected to the raw
JSON endpoint. With the fix, the callback redirects to "/".
"""
api_next = "/api/analytics/models?days=30"
r_to_idp = gated_app.get(
f"/auth/login?provider=stub&next={quote(api_next, safe='')}",
follow_redirects=False,
)
state = r_to_idp.headers["location"].split("state=")[1]
r = gated_app.get(
f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False,
)
assert r.status_code == 302
# Landing falls back to "/" — NOT the API URL.
assert r.headers["location"] == "/"
# ---------------------------------------------------------------------------
# Unit-level coverage: _validate_post_login_target on the callback boundary
# ---------------------------------------------------------------------------
class TestValidatePostLoginTarget:
"""Cover ``_validate_post_login_target`` directly — it's the second
half of the next= validator pair (the callback boundary). The gate
side has matching coverage in ``TestNextSameOriginValidation``.
"""
def test_accepts_same_origin_paths(self):
from hermes_cli.dashboard_auth.routes import _validate_post_login_target
assert _validate_post_login_target("/sessions") == "/sessions"
# URL-encoded form (as the cookie carries it) round-trips through
# the validator's unquote step.
assert (
_validate_post_login_target("%2Fsessions%3Fpage%3D2")
== "/sessions?page=2"
)
def test_rejects_protocol_relative(self):
from hermes_cli.dashboard_auth.routes import _validate_post_login_target
assert _validate_post_login_target("//evil.com") == ""
assert _validate_post_login_target("%2F%2Fevil.com") == ""
def test_rejects_login_loop(self):
from hermes_cli.dashboard_auth.routes import _validate_post_login_target
assert _validate_post_login_target("/login") == ""
assert _validate_post_login_target("/auth/login") == ""
assert _validate_post_login_target("/api/auth/me") == ""
def test_rejects_api_paths(self):
"""Bug fix: any ``/api/*`` target is dropped at the callback
boundary. Pin both the exact match and the trailing-slash forms
plus a few realistic SPA-API endpoints."""
from hermes_cli.dashboard_auth.routes import _validate_post_login_target
assert _validate_post_login_target("/api") == ""
assert _validate_post_login_target("/api/analytics/models") == ""
assert _validate_post_login_target("/api/analytics/models?days=30") == ""
assert _validate_post_login_target("/api/sessions") == ""
assert _validate_post_login_target("/api/config") == ""
# URL-encoded form — what the cookie actually carries.
assert (
_validate_post_login_target(
"%2Fapi%2Fanalytics%2Fmodels%3Fdays%3D30"
) == ""
)
def test_does_not_reject_api_prefix_lookalikes(self):
from hermes_cli.dashboard_auth.routes import _validate_post_login_target
# SPA route lookalikes — must NOT be dropped.
assert _validate_post_login_target("/apidocs") == "/apidocs"
assert _validate_post_login_target("/api-keys") == "/api-keys"
# ---------------------------------------------------------------------------
# Unit-level coverage: render_login_html threads next= into provider buttons