Files
hermes-webui/static/login.js
T
nesquena-hermes e09e819f08 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.
2026-07-05 00:12:19 +00:00

199 lines
7.6 KiB
JavaScript

/* Login page — external script, no inline handlers.
* Loaded by the /login route. Reads data attributes from the form for
* i18n strings so the server does not need to inject JS literals.
*/
document.addEventListener('DOMContentLoaded', function () {
var form = document.getElementById('login-form');
var input = document.getElementById('pw');
var passkeyBtn = document.getElementById('passkey-login');
if (!form || !input) return;
var invalidPw = form.getAttribute('data-invalid-pw') || 'Invalid password';
var connFailed = form.getAttribute('data-conn-failed') || 'Connection failed';
function showErr(msg) {
var err = document.getElementById('err');
if (err) { err.textContent = msg; err.style.display = 'block'; }
}
function hideErr() {
var err = document.getElementById('err');
if (err) { err.style.display = 'none'; }
}
// Return the ?next= redirect path if present and safe, otherwise './'
// Guards against open-redirect: rejects protocol-relative (//evil.com),
// absolute URLs, backslash variants, and control characters.
function _safeNextPath() {
try {
var raw = new URL(window.location.href).searchParams.get('next');
if (!raw) return './';
if (raw.charAt(0) !== '/') return './'; // must be path-absolute
if (raw.charAt(1) === '/' || raw.charAt(1) === '\\') return './'; // reject // and \\
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;
} catch (_) { return './'; }
}
async function doLogin(e) {
e.preventDefault();
var pw = input.value;
hideErr();
try {
var res = await fetch('api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
credentials: 'include',
});
var data = {};
try { data = await res.json(); } catch (_) {}
if (res.ok && data.ok) {
window.location.href = _safeNextPath();
} else {
showErr(data.error || invalidPw);
}
} catch (ex) {
showErr(connFailed);
}
}
form.addEventListener('submit', doLogin);
function b64uToBytes(s) {
s = String(s || '').replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '=';
var bin = atob(s);
var out = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function bytesToB64u(buf) {
var bytes = new Uint8Array(buf);
var bin = '';
for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
async function doPasskeyLogin() {
if (!window.PublicKeyCredential || !navigator.credentials) return;
hideErr();
try {
passkeyBtn.disabled = true;
var optRes = await fetch('api/auth/passkey/options', { method: 'POST', body: '{}', credentials: 'include' });
var optData = await optRes.json();
if (!optRes.ok || !optData.publicKey) throw new Error(optData.error || 'Passkey unavailable');
var pk = optData.publicKey;
pk.challenge = b64uToBytes(pk.challenge);
if (Array.isArray(pk.allowCredentials)) {
pk.allowCredentials = pk.allowCredentials.map(function (c) { return Object.assign({}, c, { id: b64uToBytes(c.id) }); });
}
var cred = await navigator.credentials.get({ publicKey: pk });
if (!cred) throw new Error('Passkey sign-in cancelled');
var payload = {
id: cred.id,
rawId: bytesToB64u(cred.rawId),
type: cred.type,
response: {
authenticatorData: bytesToB64u(cred.response.authenticatorData),
clientDataJSON: bytesToB64u(cred.response.clientDataJSON),
signature: bytesToB64u(cred.response.signature),
userHandle: cred.response.userHandle ? bytesToB64u(cred.response.userHandle) : null,
},
};
var res = await fetch('api/auth/passkey/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), credentials: 'include',
});
var data = {};
try { data = await res.json(); } catch (_) {}
if (res.ok && data.ok) window.location.href = _safeNextPath();
else showErr(data.error || invalidPw);
} catch (ex) {
showErr(ex && ex.message ? ex.message : connFailed);
} finally {
passkeyBtn.disabled = false;
}
}
if (passkeyBtn && window.PublicKeyCredential && navigator.credentials) {
fetch('api/auth/status', { credentials: 'include' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (s) { if (s && s.passkeys_enabled) passkeyBtn.style.display = 'block'; })
.catch(function () {});
passkeyBtn.addEventListener('click', doPasskeyLogin);
}
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
doLogin(e);
}
});
// On page load, probe the server so we can distinguish "can't reach server"
// (Tailscale off, wrong network) from "session expired / need to log in".
// Uses /health — public for WebUI auth, but deployment access proxies may
// require same-origin cookies before the request reaches WebUI.
// If unreachable, retries every 3 s and auto-reloads once the server is back.
(function checkConnectivity() {
var retryTimer = null;
function setFormDisabled(disabled) {
if (input) input.disabled = disabled;
var btn = form.querySelector('button');
if (btn) btn.disabled = disabled;
}
function probe() {
fetch('health', { method: 'GET', credentials: 'same-origin' })
.then(function (r) {
if (r.ok) {
// Server is reachable — if we were in retry mode, reload so the
// page reflects the correct auth state (expired session, etc.).
if (retryTimer !== null) {
clearTimeout(retryTimer);
retryTimer = null;
window.location.reload();
}
} else {
showErr(connFailed + ' (server error ' + r.status + ')');
}
})
.catch(function () {
showErr('Cannot reach server — check your VPN / Tailscale connection.');
setFormDisabled(true);
// Keep retrying so the page auto-recovers once the network is back.
if (retryTimer === null) {
retryTimer = setInterval(probe, 3000);
}
});
}
probe();
})();
});