fix(dashboard-auth): drop /api/* paths from OAuth next= round trip#36244
Merged
Conversation
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.
Contributor
🔎 Lint report:
|
JoeKowal
pushed a commit
to JoeKowal/hermes-agent
that referenced
this pull request
Jun 4, 2026
…ousResearch#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.
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Symptom
Reported by Ben: open the dashboard with an expired/missing session while on
/models(or any analytics-style page), get bounced through Nous Portal's OAuth screen, and land on$DOMAIN/api/analytics/models?days=30— raw JSON in the address bar instead of the dashboard.Reproduces deterministically: whichever
/api/*call a page fires first on mount owns the redirect race, so the bad landing URL varies by page but the bug fires every time.Root cause
Single bug at two boundaries:
Gate side (
_safe_next_targetinhermes_cli/dashboard_auth/middleware.py): the gated middleware computesnext=from the request's own path. When an unauthenticated SPA fetch hits a gated endpoint, the path going intonext=is the API URL the SPA was calling.Callback side (
_validate_post_login_targetinhermes_cli/dashboard_auth/routes.py): only filters out/login,/auth/, and/api/auth/. Any other/api/*value is accepted as same-origin.End-to-end flow:
The SPA's own
sessionStorage["hermes.lastLocation"]fallback inapi.tsalready saves the actual browser location, so dropping the server-stampednext=on/api/*requests doesn't lose any UX — the SPA recovers the deep link itself.Fix
Both validators now reject
/api/*targets in addition to the existing/login,/auth/,/api/auth/exclusions. The match is anchored (decoded == "/api"ordecoded.startswith("/api/")) so SPA route lookalikes like/apidocsor/api-keysremain valid landing targets.Why both validators get the same fix: either layer alone is sufficient; pairing them means a regression in one is caught by the other. The callback-side validator is the load-bearing one (it's the last line of defence against a legacy cookie / regressed middleware / attacker-crafted
/auth/login?next=/api/...), and the gate-side fix means the SPA's 401 handler navigates to a bare/login(clean, no useless redirect parameter) in the common case.Tests
Added to
tests/hermes_cli/test_dashboard_auth_401_reauth.py:TestApi401Envelope: rewrotetest_login_url_carries_next_for_deep_api_path(which had locked in the pre-fix behaviour) intotest_login_url_drops_next_for_deep_api_path, plus addedtest_login_url_drops_next_for_analytics_pathas the specific repro for Ben's report.TestNextSameOriginValidation:test_safe_next_validator_rejects_api_paths(covers/api,/api/analytics/models,/api/sessions,/api/config,/api/status) +test_safe_next_validator_does_not_reject_api_prefix_lookalikes(covers/apidocs,/api-keys).TestAuthCallbackNext: end-to-endtest_callback_with_api_next_lands_at_rootdrives/auth/login?next=/api/analytics/models?days=30through 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.Verification
bash scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_401_reauth.py→ 36/36 pass.bash scripts/run_tests.sh tests/hermes_cli/→ 288 files, 5,938 tests, 100% pass./api/*assertions fail and the 31 other tests remain green. Each fix layer is independently tested.Files
hermes_cli/dashboard_auth/middleware.py_safe_next_targetdrops/api/*hermes_cli/dashboard_auth/routes.py_validate_post_login_targetdrops/api/*tests/hermes_cli/test_dashboard_auth_401_reauth.pyNo SPA changes —
sessionStorage["hermes.lastLocation"]was already saving the right value; this PR just stops the server from emitting a useless (and harmful) override.