fix(#5578): stop login next= param from nesting into itself and exploding the URL

An expired-auth 401 on the login page fed the login redirect its own address:
each of the three redirect guards (_safeNextPath in login.js,
_safe_login_redirect_path in routes.py, the api() 401 redirect in workspace.js,
_safe_login_inner_next in auth.py) validated against open-redirect but none
rejected a `next` pointing back at the login page, so every auth bounce wrapped +
re-encoded the previous login URL one level deeper — growing the URL exponentially
until the browser's length limit broke the tab. All guards now collapse/reject a
`next` whose decoded leading PATH resolves to the login route (bounded
percent-decoding up to 8 levels, fail-closed at the cap), while preserving the
existing open-redirect protections AND legitimate non-login paths that merely
carry their own `next=` query key. Length cap as belt-and-suspenders.

Self-built (nesquena-hermes); nesquena independent review APPROVED. Gate (Codex)
found + fixed an over-broad nested-next reject (regressed next-carrying paths) and
a deep-encode decode off-by-one; both root-fixed, login guards verified clean.
This commit is contained in:
nesquena-hermes committed 2026-07-05 00:12:19 +00:00
1 parent 1f68c8cfa5
commit e09e819f08
7 files changed
+293 -3

No files matched your search

+69
View File
@@ -685,6 +685,46 @@ def parse_cookie(handler) -> str | None:
return morsel.value if morsel else None return morsel.value if morsel else None
def _safe_login_inner_next(query: str | None) -> str:
"""#5578: extract a SAFE, non-login inner redirect from a login page's query.
When an expired-auth bounce lands back on the login page (which already
carries its own `next` in the query), we want to preserve a legitimate inner
destination X across the redirect to the real login route — but only if X is
itself safe (path-absolute, not protocol-relative/backslash, no control
chars) AND not login-shaped / not itself carrying a nested next param.
Anything else collapses to '' (no inner redirect), which kills the
self-referential chain. Mirrors _safe_login_redirect_path().
"""
import urllib.parse as _u
raw = _u.parse_qs(query or "").get("next", [""])[0]
path = str(raw or "").strip()
if not path or path[0] != "/" or path[1:2] in {"/", "\\"}:
return ""
if re.search(r"[\x00-\x1f\x7f\s]", path) or len(path) > 2048:
return ""
# Collapse only login-route chains — decode a few levels so a nested
# `/session/login%3Fnext%3D...` (encoded `?`) is still recognized by its
# leading PATH — but preserve a legitimate non-login inner path that merely
# carries its own `next=` query key (e.g. `/admin?next=/real/path`).
_probe = path
for _ in range(8):
_p = _probe.split("?", 1)[0].split("#", 1)[0].split("&", 1)[0].rstrip("/")
if _p == "/login" or _p.endswith("/login"):
return ""
_decoded = _u.unquote(_probe)
if _decoded == _probe:
break
_probe = _decoded
else:
# Still decoding at the cap (pathologically deep encoding) → fail closed.
_p = _probe.split("?", 1)[0].split("#", 1)[0].split("&", 1)[0].rstrip("/")
if _p == "/login" or _p.endswith("/login"):
return ""
return ""
return path
def check_auth(handler, parsed) -> bool: def check_auth(handler, parsed) -> bool:
"""Check if request is authorized. Returns True if OK. """Check if request is authorized. Returns True if OK.
If not authorized, sends 401 (API) or 302 redirect (page) and returns False.""" If not authorized, sends 401 (API) or 302 redirect (page) and returns False."""
@@ -727,6 +767,35 @@ def check_auth(handler, parsed) -> bool:
# the full original URL (the browser auto-decodes once). # the full original URL (the browser auto-decodes once).
# (Opus pre-release advisor finding for v0.50.258.) # (Opus pre-release advisor finding for v0.50.258.)
import urllib.parse as _urlparse import urllib.parse as _urlparse
# #5578: if the page being redirected is ALREADY login-shaped, do NOT
# wrap its full `path?query` into a fresh `next=` — that query already
# carries a `next=`, so quoting the whole thing nests the login URL into
# itself and re-encodes it on every expired-auth bounce, exploding the
# URL until the tab breaks. This guard runs in check_auth() (BEFORE
# route handling), the actual source of the server-side loop.
#
# The login page is served ONLY at the public `/login` route (see
# PUBLIC_PATHS + the routes.py `/login` handler); the app's client route
# `/session/login` is NOT public, so a bare relative `login` from
# `/session/login` resolves to `/session/login` again and re-triggers
# check_auth() — an infinite redirect. Resolve to the real login route
# with `../login`, which lands on `/login` from a `/session/*` scope and
# on `<mount>/login` under a subpath mount (verified via urljoin). Carry
# through only a validated, non-login inner `next` so a legitimate
# post-login destination still survives a bounce that happened to land
# on the login page.
_login_path = (parsed.path or '/').rstrip('/')
if _login_path == '/login' or _login_path.endswith('/login'):
# /login itself is public → check_auth never redirects it; this only
# fires for the non-public client login route (e.g. /session/login).
_target = '../login' if '/' in _login_path.lstrip('/') else 'login'
_inner = _safe_login_inner_next(parsed.query)
if _inner:
_target += '?next=' + _urlparse.quote(_inner, safe='/')
handler.send_header('Location', _target)
handler.send_header('Content-Length', '0')
handler.end_headers()
return False
_path_with_query = parsed.path or '/' _path_with_query = parsed.path or '/'
if parsed.query: if parsed.query:
_path_with_query += '?' + parsed.query _path_with_query += '?' + parsed.query
+32 -1
View File
@@ -31,7 +31,7 @@ import socket as _socket
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
from contextlib import closing from contextlib import closing
from urllib.parse import parse_qs, quote, urljoin, urlsplit from urllib.parse import parse_qs, quote, unquote, urljoin, urlsplit
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener
from api.agent_sessions import ( from api.agent_sessions import (
@@ -9238,6 +9238,37 @@ def _safe_login_redirect_path(raw_path: str | None) -> str:
return "/" return "/"
if re.search(r"[\x00-\x1f\x7f\s]", path): if re.search(r"[\x00-\x1f\x7f\s]", path):
return "/" return "/"
# #5578: reject a `next` that points back at the login page, so an
# expired-auth bounce on the login page can't feed the redirect its own
# address and grow the URL exponentially. Length cap is belt-and-suspenders:
# a legitimate app path is never this long.
if len(path) > 2048:
return "/"
# Detect a login-route target even through nested percent-encoding: a nested
# login-redirect chain looks like `/session/login%3Fnext%3D...`, where the
# `?` separating the path from the query is itself encoded, so a plain
# split("?") wouldn't isolate the real path. Fully decode (bounded) and check
# the leading PATH of EVERY decode level, including the final fully-decoded
# form. Only collapse login-route chains — a legitimate non-login path that
# merely carries its own `next=` query key (e.g. `/admin?action=foo&next=/x`)
# must still round-trip (regression guarded by test_v050258_opus_followups.py).
_probe = path
for _ in range(8):
_path_only = _probe.split("?", 1)[0].split("#", 1)[0].split("&", 1)[0].rstrip("/")
if _path_only.endswith("/login") or _path_only == "/login":
return "/"
_decoded = unquote(_probe)
if _decoded == _probe:
break
_probe = _decoded
else:
# Loop exhausted the cap while STILL decoding (pathologically deep
# encoding): check the final decoded form too, then fail closed — an
# 8-level-deep encoded value is never a legitimate redirect.
_path_only = _probe.split("?", 1)[0].split("#", 1)[0].split("&", 1)[0].rstrip("/")
if _path_only.endswith("/login") or _path_only == "/login":
return "/"
return "/"
return path return path
+4
View File
@@ -3071,6 +3071,10 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
return true; return true;
}; };
const redirectToLogin=(nextUrl)=>{ const redirectToLogin=(nextUrl)=>{
// #5578: never nest the login URL into its own next= — if already on a
// login-shaped page, reload 'login' bare (the page keeps its inner next).
const _p=(window.location.pathname||'').replace(/\/+$/,'');
if(/(?:^|\/)login$/.test(_p)){window.location.href='login';return;}
window.location.href='login?next='+encodeURIComponent(nextUrl); window.location.href='login?next='+encodeURIComponent(nextUrl);
}; };
return { return {
+21
View File
@@ -32,6 +32,27 @@ document.addEventListener('DOMContentLoaded', function () {
if (raw.charAt(0) !== '/') return './'; // must be path-absolute if (raw.charAt(0) !== '/') return './'; // must be path-absolute
if (raw.charAt(1) === '/' || raw.charAt(1) === '\\') return './'; // reject // and \\ if (raw.charAt(1) === '/' || raw.charAt(1) === '\\') return './'; // reject // and \\
if (/[\x00-\x1f\x7f\s]/.test(raw)) return './'; // reject control chars / whitespace if (/[\x00-\x1f\x7f\s]/.test(raw)) return './'; // reject control chars / whitespace
// #5578: never redirect back to the login page — that self-referential
// chain is what grows the URL exponentially on repeated expired-auth
// bounces. Detect the login route even through nested percent-encoding
// (a nested chain looks like `/session/login%3Fnext%3D...`, where the `?`
// is encoded so a plain split('?') wouldn't isolate the path). Decode a
// few levels and check the leading PATH. Only collapse login-route chains
// — a legitimate non-login path that merely carries its own `next=` query
// key must still round-trip.
if (raw.length > 2048) return './';
var probe = raw;
var stabilized = false;
for (var i = 0; i < 8; i++) {
var pathOnly = probe.split('?')[0].split('#')[0].split('&')[0].replace(/\/+$/, '');
if (pathOnly === '/login' || /\/login$/.test(pathOnly)) return './';
var decoded;
try { decoded = decodeURIComponent(probe); } catch (_) { stabilized = true; break; }
if (decoded === probe) { stabilized = true; break; }
probe = decoded;
}
// If still decoding at the cap (pathologically deep encoding), fail closed.
if (!stabilized) return './';
return raw; return raw;
} catch (_) { return './'; } } catch (_) { return './'; }
} }
+2 -1
View File
@@ -201,7 +201,8 @@ else initOfflineMonitor();
// Redirect to login when the server responds with 401 (auth session expired). // Redirect to login when the server responds with 401 (auth session expired).
// Handles iOS PWA standalone mode and keeps subpath mounts like /hermes/ from // Handles iOS PWA standalone mode and keeps subpath mounts like /hermes/ from
// escaping to the personal site root /login. // escaping to the personal site root /login.
function _redirectIfUnauth(res){if(res&&res.status===401){window.location.href='login?next='+encodeURIComponent(window.location.pathname+window.location.search);return true;}return false;} // #5578: on a login-shaped page, reload 'login' WITHOUT a next (avoid self-nesting).
function _redirectIfUnauth(res){if(res&&res.status===401){var _p=(window.location.pathname||'').replace(/\/+$/,'');if(/(?:^|\/)login$/.test(_p)){window.location.href='login';}else{window.location.href='login?next='+encodeURIComponent(window.location.pathname+window.location.search);}return true;}return false;}
function _getSessionQueue(sid, create=false){ function _getSessionQueue(sid, create=false){
if(!sid) return []; if(!sid) return [];
if(!SESSION_QUEUES[sid]&&create) SESSION_QUEUES[sid]=[]; if(!SESSION_QUEUES[sid]&&create) SESSION_QUEUES[sid]=[];
+15 -1
View File
@@ -46,7 +46,21 @@ async function api(path,opts={}){
// re-authenticate. This is especially important for iOS PWA (standalone mode) // re-authenticate. This is especially important for iOS PWA (standalone mode)
// and for subpath mounts like /hermes/, where /login escapes to the site root. // and for subpath mounts like /hermes/, where /login escapes to the site root.
if(res.status===401){ if(res.status===401){
if(redirect401) window.location.href='login?next='+encodeURIComponent(window.location.pathname+window.location.search); // #5578: if we're ALREADY on the login page, appending
// window.location.pathname+search (which contains ?next=…) into a
// fresh next= wraps the login URL into itself and re-encodes it —
// exponential URL growth on each expired-auth bounce until the tab
// breaks. On the login page, just reload login WITHOUT a next (the
// page preserves its own inner next); elsewhere, capture the path.
if(redirect401){
// Already on the login page? Reload login WITHOUT a next.
const _p=(window.location.pathname||'').replace(/\/+$/,'');
if(/(?:^|\/)login$/.test(_p)){
window.location.href='login';
}else{
window.location.href='login?next='+encodeURIComponent(window.location.pathname+window.location.search);
}
}
// Callers can opt out of navigation and handle the unauthenticated state themselves. // Callers can opt out of navigation and handle the unauthenticated state themselves.
return; return;
} }
+150
View File
@@ -0,0 +1,150 @@
"""Regression test for #5578 — login `next=` self-nesting URL explosion.
Bug: an expired-auth 401 while already on `/session/login?next=…` fed the login
redirect its own URL. Each of the three guards (workspace.js api() 401 handler,
login.js `_safeNextPath`, routes.py `_safe_login_redirect_path`) validated against
open-redirect but NONE rejected a `next` pointing back at the login page, so the
nested `/session/login?next=/session/login%3Fnext%3D…` chain re-percent-encoded
and grew exponentially on every bounce until the tab broke (~12k chars).
Fix: all three guards now reject a `next` that targets the login page or already
carries a nested `next=`, plus a length cap — while preserving legitimate
`next=/some/real/path` redirects and the existing open-redirect protections.
"""
from pathlib import Path
from api.routes import _safe_login_redirect_path as guard
ROOT = Path(__file__).resolve().parents[1]
LOGIN_JS = (ROOT / "static" / "login.js").read_text(encoding="utf-8")
WORKSPACE_JS = (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
# ── server guard: the self-nesting cases the bug exploited ──────────────────
class TestServerGuardRejectsNesting:
def test_rejects_login_self_reference(self):
assert guard("/login") == "/"
assert guard("/session/login") == "/"
assert guard("/session/login/") == "/"
assert guard("/hermes/session/login") == "/" # subpath mount
def test_rejects_nested_next_chain(self):
nested = "/session/login?next=/session/login%3Fnext%3D/session/login"
assert guard(nested) == "/"
def test_rejects_encoded_login_chain_at_any_depth(self):
# A nested LOGIN chain must collapse even when the `?` separating path
# from query is percent-encoded at any depth (that is the exponential
# explosion signature). Detection is by the decoded leading PATH.
assert guard("/session/login%3Fnext%3D/y") == "/"
assert guard("/session/login%253Fnext%253D/y") == "/"
assert guard("/hermes/session/login%3Fnext%3D/y") == "/"
def test_rejects_deeply_encoded_login_chain(self):
# #5579 gate: a 6+-level percent-encoded login chain must still collapse —
# the decode loop checks the leading path at EVERY level (incl. the final
# decoded form), so a login route hidden behind deep encoding is caught.
# /session/login with the '?' encoded 6 times: %25252525253F
assert guard("/session/login%25252525253Fnext%25252525253D/y") == "/"
def test_preserves_non_login_path_carrying_its_own_next_key(self):
# A legitimate NON-login destination that merely carries its own `next=`
# query key must still round-trip — only login-route chains collapse.
# (Regression guarded by test_v050258_opus_followups.py; #5579 gate.)
assert guard("/x?next=/y") == "/x?next=/y"
assert guard("/admin?action=foo&next=/real/path") == "/admin?action=foo&next=/real/path"
def test_rejects_overlong_next(self):
assert guard("/" + "a" * 3000) == "/"
def test_the_exact_12k_explosion_collapses(self):
# Reconstruct the reported exponential chain; the guard must collapse it.
enc = "%3Fnext%3D"
blown = "/session/login" + (enc + "/session/login") * 40
assert len(blown) > 500
assert guard(blown) == "/"
class TestServerGuardPreservesLegitimateRedirects:
def test_preserves_real_session_path(self):
assert guard("/session/abc123") == "/session/abc123"
def test_preserves_root_and_plain_paths(self):
assert guard("/") == "/"
assert guard("/workspace") == "/workspace"
assert guard("/session/xyz?tab=files") == "/session/xyz?tab=files"
def test_still_rejects_open_redirect_classics(self):
# The pre-existing protections must remain intact.
assert guard("//evil.example") == "/"
assert guard("/\\evil") == "/"
assert guard("https://evil.example") == "/"
assert guard("/x\x00y") == "/"
assert guard("") == "/"
assert guard(None) == "/"
# ── client guards: static wiring (both JS sites carry the self-ref guard) ───
class TestClientGuardsWired:
def test_login_js_rejects_login_self_and_nested_next(self):
# _safeNextPath must carry the login-route + length guards and detect the
# login route through nested percent-encoding via bounded decodeURIComponent.
assert "/login$/.test(pathOnly)" in LOGIN_JS or "/login$/" in LOGIN_JS
assert "decodeURIComponent" in LOGIN_JS
assert "2048" in LOGIN_JS
def test_workspace_js_skips_next_on_login_page(self):
# The 401 handler must NOT append the whole login URL as next when it's
# already on the login page (the recursion source).
assert "login$/.test(_p)" in WORKSPACE_JS
assert "window.location.href='login';" in WORKSPACE_JS
# And the non-login path still captures the real destination.
assert "'login?next='+encodeURIComponent" in WORKSPACE_JS
def test_all_client_401_helpers_guard_login_page(self):
# #5578 Codex round-2: workspace.js was fixed but two more client 401
# redirect helpers (ui.js _redirectIfUnauth, boot.js redirectToLogin)
# also nested the login URL. All three must carry the on-login guard.
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
assert "login$/.test(_p)" in UI_JS, "ui.js _redirectIfUnauth must guard the login page"
assert "login$/.test(_p)" in BOOT_JS, "boot.js redirectToLogin must guard the login page"
class TestServerCheckAuthGuard:
def test_check_auth_does_not_nest_login_redirect(self):
# #5578 Codex round-2 (BRICK): check_auth() runs BEFORE route handling,
# so the server-side page-redirect loop never reached
# _safe_login_redirect_path(). check_auth() must itself refuse to wrap a
# login-shaped path into a fresh next=.
AUTH_PY = (ROOT / "api" / "auth.py").read_text(encoding="utf-8")
assert "endswith('/login')" in AUTH_PY or "_login_path.endswith('/login')" in AUTH_PY, (
"check_auth() must detect a login-shaped path and skip the next= wrap"
)
def test_check_auth_resolves_to_real_public_login_route(self):
# #5578 Codex round-3 (BRICK): a bare relative 'login' from /session/login
# resolves BACK to /session/login (not public, not the /login route) and
# loops forever. Must use '../login' so it lands on the real public
# /login route (and <mount>/login under a subpath mount).
AUTH_PY = (ROOT / "api" / "auth.py").read_text(encoding="utf-8")
assert "'../login'" in AUTH_PY, (
"check_auth() must redirect a session-scoped login path to ../login "
"(the real public /login), not a bare relative login that loops"
)
def test_inner_next_helper_drops_login_keeps_safe_nonlogin(self):
# The preserved inner next must be safe + non-login. A nested LOGIN chain
# (even with an encoded `?`) collapses; a legit non-login path that
# carries its own `next=` key round-trips (#5579 gate — no over-collapse).
import api.auth as auth
assert auth._safe_login_inner_next("next=/session/login") == ""
assert auth._safe_login_inner_next("next=/session/login%3Fnext%3D/y") == ""
assert auth._safe_login_inner_next("next=//evil") == ""
assert auth._safe_login_inner_next("next=/session/abc123") == "/session/abc123"
# parse_qs decodes the value: /x%3Fnext%3D/y -> /x?next=/y, a legit
# non-login path with its own next key -> preserved (round-trips).
assert auth._safe_login_inner_next("next=/x%3Fnext%3D/y") == "/x?next=/y"
assert auth._safe_login_inner_next("") == ""