rebase #3581 onto master (v0.51.546) for review+ship-gate — require current password before disabling auth

starship-s: sudo-mode re-auth on POST /api/settings before any change/clear/passwordless transition
of password auth, + cosmetic auth-disabled-acknowledged nav badge. Operator-hardening (rubric-named).
Security-boundary + visible UI -> Nathan visual sign-off. Co-authored-by: starship-s <starship-s@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-06-21 02:26:20 +00:00
parent fde3b0962a
commit 8ea7ad582e
11 changed files with 788 additions and 24 deletions
+2
View File
@@ -7168,6 +7168,7 @@ _SETTINGS_DEFAULTS = {
"auto_title_refresh_every": "0", # adaptive title refresh: 0=off, 5/10/20=every N exchanges
"busy_input_mode": "queue", # behavior when sending while agent is running: queue | interrupt | steer
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
"auth_disabled_acknowledged": False, # user acknowledged unauthenticated risk
}
_SETTINGS_LEGACY_DROP_KEYS = {
"assistant_language",
@@ -7368,6 +7369,7 @@ _SETTINGS_BOOL_KEYS = {
"session_endless_scroll",
"auto_scroll_follow",
"worklog_details_expanded_default",
"auth_disabled_acknowledged",
}
# Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr')
_SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$")
+56
View File
@@ -8594,6 +8594,7 @@ def handle_get(handler, parsed) -> bool:
"passkeys_enabled": bool(passkeys),
"passkeys_count": len(passkeys),
"passkey_feature_flag": passkey_flag,
"auth_disabled_acknowledged": bool(load_settings().get("auth_disabled_acknowledged")) if not auth_enabled else False,
})
if parsed.path in ("/manifest.json", "/manifest.webmanifest"):
@@ -8840,6 +8841,21 @@ def handle_get(handler, parsed) -> bool:
settings["password_env_var"] = bool(
os.getenv("HERMES_WEBUI_PASSWORD", "").strip()
)
# Auth-state fields for frontend safety badge / confirmation flows
from api.auth import get_password_hash, is_auth_enabled
settings["auth_enabled"] = is_auth_enabled()
settings["password_auth_enabled"] = get_password_hash() is not None
try:
from api.auth import _passkey_feature_flag_enabled as _pffe
from api.passkeys import registered_credentials as _rc
if _pffe():
settings["passkeys_enabled"] = bool(_rc())
settings["passwordless_enabled"] = bool(_rc()) and not settings["password_auth_enabled"]
else:
settings["passkeys_enabled"] = False
settings["passwordless_enabled"] = False
except Exception:
pass
# Inject the running version so the UI badge stays in sync with git tags
# without any manual release step.
try:
@@ -11346,9 +11362,11 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/settings":
from api.auth import (
create_session,
get_password_hash,
is_auth_enabled,
parse_cookie,
set_auth_cookie,
verify_password,
verify_session,
)
@@ -11356,6 +11374,7 @@ def handle_post(handler, parsed) -> bool:
body["bot_name"] = (str(body["bot_name"]) or "").strip() or "Hermes"
auth_enabled_before = is_auth_enabled()
password_auth_enabled_before = auth_enabled_before and get_password_hash() is not None
current_cookie = parse_cookie(handler)
logged_in_before = bool(current_cookie and verify_session(current_cookie))
requested_password = bool(
@@ -11367,6 +11386,8 @@ def handle_post(handler, parsed) -> bool:
if requested_passwordless:
body["_clear_password"] = True
current_password = body.pop("_current_password", None)
# #1560: HERMES_WEBUI_PASSWORD env var takes precedence in
# api.auth.get_password_hash(), so writing password_hash to settings.json
# has no effect on auth. Refuse loudly with 409 instead of silently
@@ -11395,6 +11416,22 @@ def handle_post(handler, parsed) -> bool:
403,
)
# Auth-disable safety: when password auth is currently enabled, require
# the current password to change, clear, or switch to passwordless.
if auth_enabled_before and password_auth_enabled_before and (requested_password or requested_clear_password):
if not isinstance(current_password, str) or not current_password:
return bad(
handler,
"Current password is required to change or disable authentication.",
403,
)
if not verify_password(current_password):
return bad(
handler,
"Current password is incorrect.",
403,
)
if requested_passwordless:
from api.auth import _passkey_feature_flag_enabled
from api.passkeys import registered_credentials
@@ -11408,6 +11445,13 @@ def handle_post(handler, parsed) -> bool:
clear_credentials()
# Handle auth_disabled_acknowledged setting
ack = body.pop("_auth_disabled_acknowledged", None)
if ack is not None and not is_auth_enabled():
body["auth_disabled_acknowledged"] = bool(ack)
elif is_auth_enabled() or requested_password:
body["auth_disabled_acknowledged"] = False
saved = save_settings(body)
saved.pop("password_hash", None) # never expose hash to client
@@ -11450,8 +11494,20 @@ def handle_post(handler, parsed) -> bool:
logged_in_after = True
saved["auth_enabled"] = auth_enabled_after
saved["password_auth_enabled"] = get_password_hash() is not None
saved["logged_in"] = logged_in_after
saved["auth_just_enabled"] = auth_just_enabled
try:
from api.auth import _passkey_feature_flag_enabled as _pffe
from api.passkeys import registered_credentials as _rc
if _pffe():
saved["passkeys_enabled"] = bool(_rc())
saved["passwordless_enabled"] = bool(_rc()) and not saved["password_auth_enabled"]
else:
saved["passkeys_enabled"] = False
saved["passwordless_enabled"] = False
except Exception:
pass
if not new_cookie:
return j(handler, saved)
+160 -2
View File
@@ -1433,6 +1433,18 @@ const LOCALES = {
disable_auth_confirm_message: 'Anyone will be able to access this instance.',
auth_disabled: 'Auth disabled — password protection removed',
disable_auth_failed: 'Failed to disable auth: ',
current_password_label: 'Current Password',
current_password_placeholder: 'Enter current password\u2026',
current_password_required: 'Current password is required to change or disable authentication.',
current_password_incorrect: 'Current password is incorrect.',
disable_auth_typed_confirm: 'Type DISABLE AUTH to confirm',
auth_status_password: 'Password Auth',
auth_status_passkey_only: 'Passkey Only',
auth_status_unauthenticated: 'Unauthenticated',
auth_warning_badge: 'Auth disabled',
auth_disabled_warning_message: 'This instance is accessible without authentication.',
auth_acknowledged_label: 'Ive reviewed this risk; show a less-urgent nav warning.',
auth_ack_save_failed: 'Failed to update acknowledgement: ',
bg_error_single: (title) => `"${title}" has encountered an error`,
bg_error_multi: (count) => `${count} sessions have encountered an error`,
// skill form
@@ -2953,6 +2965,18 @@ const LOCALES = {
disable_auth_confirm_message: 'Chiunque potrà accedere a questa istanza.',
auth_disabled: 'Auth disabilitata — protezione password rimossa',
disable_auth_failed: 'Disabilitazione auth fallita: ',
current_password_label: 'Password attuale',
current_password_placeholder: 'Inserisci password attuale…',
current_password_required: 'La password attuale è necessaria per modificare o disabilitare l\'autenticazione.',
current_password_incorrect: 'Password attuale non corretta.',
disable_auth_typed_confirm: 'Digita DISABLE AUTH per confermare',
auth_status_password: 'Autenticazione password',
auth_status_passkey_only: 'Solo passkey',
auth_status_unauthenticated: 'Non autenticato',
auth_warning_badge: 'Auth disabilitata',
auth_disabled_warning_message: 'Questa istanza è accessibile senza autenticazione.',
auth_acknowledged_label: 'Ho esaminato questo rischio; mostra un avviso di navigazione meno urgente.',
auth_ack_save_failed: 'Aggiornamento del riconoscimento non riuscito: ',
bg_error_single: (title) => `"${title}" ha incontrato un errore`,
bg_error_multi: (count) => `${count} sessioni hanno incontrato un errore`,
// skill form
@@ -4481,6 +4505,18 @@ const LOCALES = {
disable_auth_confirm_message: '誰でもこのインスタンスにアクセスできるようになります。',
auth_disabled: '認証無効化 — パスワード保護を解除しました',
disable_auth_failed: '認証の無効化に失敗: ',
current_password_label: '現在のパスワード',
current_password_placeholder: '現在のパスワードを入力…',
current_password_required: '認証の変更または無効化には現在のパスワードが必要です。',
current_password_incorrect: '現在のパスワードが正しくありません。',
disable_auth_typed_confirm: '確認のため DISABLE AUTH と入力',
auth_status_password: 'パスワード認証',
auth_status_passkey_only: 'パスキーのみ',
auth_status_unauthenticated: '未認証',
auth_warning_badge: '認証無効',
auth_disabled_warning_message: 'このインスタンスは認証なしでアクセスできます。',
auth_acknowledged_label: 'このリスクを確認しました。ナビゲーション警告を控えめに表示します。',
auth_ack_save_failed: '確認状態の更新に失敗しました: ',
bg_error_single: (title) => `"${title}" でエラーが発生しました`,
bg_error_multi: (count) => `${count} 件のセッションでエラーが発生しました`,
// skill form
@@ -5704,6 +5740,18 @@ const LOCALES = {
disable_auth_confirm_message: 'Любой сможет получить доступ к этому экземпляру.',
auth_disabled: 'Авторизация отключена — защита паролем снята',
disable_auth_failed: 'Не удалось отключить авторизацию: ',
current_password_label: 'Текущий пароль',
current_password_placeholder: 'Введите текущий пароль…',
current_password_required: 'Для изменения или отключения авторизации требуется текущий пароль.',
current_password_incorrect: 'Текущий пароль неверный.',
disable_auth_typed_confirm: 'Введите DISABLE AUTH для подтверждения',
auth_status_password: 'Парольная авторизация',
auth_status_passkey_only: 'Только ключи доступа',
auth_status_unauthenticated: 'Без авторизации',
auth_warning_badge: 'Авторизация отключена',
auth_disabled_warning_message: 'Этот экземпляр доступен без авторизации.',
auth_acknowledged_label: 'Я ознакомился(ась) с этим риском; показывать менее срочное предупреждение в навигации.',
auth_ack_save_failed: 'Не удалось обновить подтверждение: ',
bg_error_single: (title) => `В "${title}" возникла ошибка`,
bg_error_multi: (count) => `${count} сеансов столкнулись с ошибкой`,
skill_deleted: 'Навык удалён',
@@ -7164,6 +7212,18 @@ const LOCALES = {
disable_auth_confirm_message: 'Anyone will be able to access this instance.',
auth_disabled: 'Autenticación desactivada — protección por contraseña eliminada',
disable_auth_failed: 'Failed to disable auth: ',
current_password_label: 'Contraseña actual',
current_password_placeholder: 'Ingrese contraseña actual…',
current_password_required: 'Se requiere la contraseña actual para cambiar o desactivar la autenticación.',
current_password_incorrect: 'La contraseña actual es incorrecta.',
disable_auth_typed_confirm: 'Escriba DISABLE AUTH para confirmar',
auth_status_password: 'Autenticación por contraseña',
auth_status_passkey_only: 'Solo clave de acceso',
auth_status_unauthenticated: 'Sin autenticación',
auth_warning_badge: 'Auth desactivada',
auth_disabled_warning_message: 'Esta instancia es accesible sin autenticación.',
auth_acknowledged_label: 'He revisado este riesgo; muestra un aviso de navegación menos urgente.',
auth_ack_save_failed: 'Error al actualizar el reconocimiento: ',
bg_error_single: (title) => `"${title}" has encountered an error`,
bg_error_multi: (count) => `${count} sessions have encountered an error`,
// skill form
@@ -8845,6 +8905,18 @@ const LOCALES = {
disable_auth_confirm_message: 'Zugriff ohne Authentifizierung?',
auth_disabled: 'Passwortschutz deaktiviert.',
disable_auth_failed: 'Deaktivieren fehlgeschlagen.',
current_password_label: 'Aktuelles Passwort',
current_password_placeholder: 'Aktuelles Passwort eingeben…',
current_password_required: 'Das aktuelle Passwort ist erforderlich, um die Authentifizierung zu ändern oder zu deaktivieren.',
current_password_incorrect: 'Das aktuelle Passwort ist falsch.',
disable_auth_typed_confirm: 'Geben Sie DISABLE AUTH ein, um zu bestätigen',
auth_status_password: 'Passwort-Authentifizierung',
auth_status_passkey_only: 'Nur Passkey',
auth_status_unauthenticated: 'Nicht authentifiziert',
auth_warning_badge: 'Auth deaktiviert',
auth_disabled_warning_message: 'Diese Instanz ist ohne Authentifizierung zugänglich.',
auth_acknowledged_label: 'Ich habe dieses Risiko geprüft; Navigationswarnung weniger dringend anzeigen.',
auth_ack_save_failed: 'Bestätigung konnte nicht aktualisiert werden: ',
bg_error_single: 'Hinterfrage {count} fehlgeschlagen.',
bg_error_multi: '{count} Hinterfragen fehlgeschlagen.',
media_audio_label: 'Audio',
@@ -10081,6 +10153,18 @@ const LOCALES = {
disable_auth_confirm_message: '任何人都可以访问此实例。',
auth_disabled: '认证已停用,密码保护已移除',
disable_auth_failed: '停用认证失败:',
current_password_label: '当前密码',
current_password_placeholder: '输入当前密码…',
current_password_required: '更改或禁用认证需要当前密码。',
current_password_incorrect: '当前密码不正确。',
disable_auth_typed_confirm: '输入 DISABLE AUTH 以确认',
auth_status_password: '密码认证',
auth_status_passkey_only: '仅通行密钥',
auth_status_unauthenticated: '未认证',
auth_warning_badge: '认证已禁用',
auth_disabled_warning_message: '此实例无需认证即可访问。',
auth_acknowledged_label: '我已查看此风险;显示较低优先级的导航警告。',
auth_ack_save_failed: '更新确认状态失败:',
bg_error_single: (title) => `${title}”出现错误`,
bg_error_multi: (count) => `${count} 个会话出现错误`,
skill_deleted: '技能已删除',
@@ -11850,6 +11934,18 @@ const LOCALES = {
disable_auth_confirm_message: '任何人都可以存取此執行個體。',
auth_disabled: '驗證已停用 — 密碼保護已關閉',
disable_auth_failed: '停用驗證失敗:',
current_password_label: '目前密碼',
current_password_placeholder: '輸入目前密碼…',
current_password_required: '變更或停用驗證需要目前密碼。',
current_password_incorrect: '目前密碼不正確。',
disable_auth_typed_confirm: '輸入 DISABLE AUTH 以確認',
auth_status_password: '密碼驗證',
auth_status_passkey_only: '僅限通行金鑰',
auth_status_unauthenticated: '未驗證',
auth_warning_badge: '驗證已停用',
auth_disabled_warning_message: '此執行個體無需驗證即可存取。',
auth_acknowledged_label: '我已檢視此風險;顯示較低急迫性的導覽警告。',
auth_ack_save_failed: '更新確認狀態失敗:',
bg_error_single: (title) => `${title}」發生錯誤`,
bg_error_multi: (count) => `${count} 個對話發生錯誤`,
// skill form
@@ -13290,8 +13386,22 @@ const LOCALES = {
subagent_children: 'Subagent sessions', // TODO: translate
// login-flow keys (issue #1442)
sign_out_failed: 'Falha ao sair: ',
auth_disabled: 'Autenticação desativada — proteção por senha removida',
disable_auth_confirm_title: 'Desativar proteção por senha',
disable_auth_confirm_message: 'Qualquer pessoa poderá acessar esta instância.',
auth_disabled: 'Autenticação desativada — proteção por senha removida',
disable_auth_failed: 'Falha ao desativar autenticação: ',
current_password_label: 'Senha atual',
current_password_placeholder: 'Digite a senha atual…',
current_password_required: 'A senha atual é necessária para alterar ou desativar a autenticação.',
current_password_incorrect: 'A senha atual está incorreta.',
disable_auth_typed_confirm: 'Digite DISABLE AUTH para confirmar',
auth_status_password: 'Autenticação por senha',
auth_status_passkey_only: 'Somente passkey',
auth_status_unauthenticated: 'Sem autenticação',
auth_warning_badge: 'Auth desativada',
auth_disabled_warning_message: 'Esta instância está acessível sem autenticação.',
auth_acknowledged_label: 'Revisei este risco; mostre um aviso de navegação menos urgente.',
auth_ack_save_failed: 'Falha ao atualizar o reconhecimento: ',
outline_title: 'Esboço',
outline_empty: 'Ainda não há perguntas.',
outline_loading: 'Carregando…',
@@ -14617,6 +14727,18 @@ const LOCALES = {
disable_auth_confirm_message: 'Anyone will be able to access this instance.',
auth_disabled: '인증 비활성화 — 비밀번호 보호가 제거되었습니다',
disable_auth_failed: 'Failed to disable auth: ',
current_password_label: '현재 비밀번호',
current_password_placeholder: '현재 비밀번호 입력…',
current_password_required: '인증을 변경하거나 비활성화하려면 현재 비밀번호가 필요합니다.',
current_password_incorrect: '현재 비밀번호가 올바르지 않습니다.',
disable_auth_typed_confirm: '확인하려면 DISABLE AUTH를 입력하세요',
auth_status_password: '비밀번호 인증',
auth_status_passkey_only: '패스키 전용',
auth_status_unauthenticated: '미인증',
auth_warning_badge: '인증 비활성화됨',
auth_disabled_warning_message: '이 인스턴스는 인증 없이 접근할 수 있습니다.',
auth_acknowledged_label: '이 위험을 확인했습니다. 덜 긴급한 탐색 경고를 표시합니다.',
auth_ack_save_failed: '승인 상태를 업데이트하지 못했습니다: ',
bg_error_single: (title) => `"${title}" has encountered an error`,
bg_error_multi: (count) => `${count} sessions have encountered an error`,
// skill form
@@ -16079,7 +16201,19 @@ const LOCALES = {
disable_auth_confirm_title: 'Désactiver la protection par mot de passe',
disable_auth_confirm_message: 'Tout le monde pourra accéder à cette instance.',
auth_disabled: 'Authentification désactivée  protection par mot de passe supprimée',
disable_auth_failed: 'Échec de la désactivation de l\'authentification :',
disable_auth_failed: 'Échec de la désactivation de l\'authentification :',
current_password_label: 'Mot de passe actuel',
current_password_placeholder: 'Entrez le mot de passe actuel…',
current_password_required: 'Le mot de passe actuel est requis pour modifier ou désactiver l\'authentification.',
current_password_incorrect: 'Le mot de passe actuel est incorrect.',
disable_auth_typed_confirm: 'Tapez DISABLE AUTH pour confirmer',
auth_status_password: 'Authentification par mot de passe',
auth_status_passkey_only: 'Passkey uniquement',
auth_status_unauthenticated: 'Non authentifié',
auth_warning_badge: 'Auth désactivée',
auth_disabled_warning_message: 'Cette instance est accessible sans authentification.',
auth_acknowledged_label: 'Jai examiné ce risque ; afficher un avertissement de navigation moins urgent.',
auth_ack_save_failed: 'Échec de la mise à jour de laccusé de réception : ',
skill_name: 'Nom',
skill_category: 'Catégorie',
skill_category_placeholder: 'Facultatif, par ex. développeurs',
@@ -17603,6 +17737,18 @@ const LOCALES = {
disable_auth_confirm_message: 'Bu örneğe herkes erişebilecek.',
auth_disabled: 'Kimlik doğrulama devre dışı bırakıldı — şifre koruması kaldırıldı',
disable_auth_failed: 'Kimlik doğrulama devre dışı bırakılamadı:',
current_password_label: 'Mevcut şifre',
current_password_placeholder: 'Mevcut şifreyi girin…',
current_password_required: 'Kimlik doğrulamasını değiştirmek veya devre dışı bırakmak için mevcut şifre gereklidir.',
current_password_incorrect: 'Mevcut şifre yanlış.',
disable_auth_typed_confirm: 'Onaylamak için DISABLE AUTH yazın',
auth_status_password: 'Şifre kimlik doğrulaması',
auth_status_passkey_only: 'Yalnızca passkey',
auth_status_unauthenticated: 'Kimlik doğrulaması yok',
auth_warning_badge: 'Kimlik doğrulaması devre dışı',
auth_disabled_warning_message: 'Bu örneğe kimlik doğrulaması olmadan erişilebilir.',
auth_acknowledged_label: 'Bu riski gözden geçirdim; daha az acil bir gezinme uyarısı göster.',
auth_ack_save_failed: 'Onay durumu güncellenemedi: ',
bg_error_single: (title) => `"${title}" bir hatayla karşılaştı`,
bg_error_multi: (count) => `${count} oturum bir hatayla karşılaştı`,
// skill form
@@ -19209,6 +19355,18 @@ const LOCALES = {
disable_auth_confirm_message: 'Każdy będzie mógł uzyskać dostęp do tej instancji.',
auth_disabled: 'Uwierzytelnianie wyłączone — usunięto ochronę hasłem',
disable_auth_failed: 'Wyłączenie uwierzytelniania nie powiodło się: ',
current_password_label: 'Obecne hasło',
current_password_placeholder: 'Wpisz obecne hasło…',
current_password_required: 'Obecne hasło jest wymagane do zmiany lub wyłączenia uwierzytelniania.',
current_password_incorrect: 'Obecne hasło jest nieprawidłowe.',
disable_auth_typed_confirm: 'Wpisz DISABLE AUTH, aby potwierdzić',
auth_status_password: 'Uwierzytelnianie hasłem',
auth_status_passkey_only: 'Tylko passkey',
auth_status_unauthenticated: 'Bez uwierzytelniania',
auth_warning_badge: 'Uwierzytelnianie wyłączone',
auth_disabled_warning_message: 'Ta instancja jest dostępna bez uwierzytelniania.',
auth_acknowledged_label: 'Znam to ryzyko; pokaż mniej pilne ostrzeżenie w nawigacji.',
auth_ack_save_failed: 'Nie udało się zaktualizować potwierdzenia: ',
bg_error_single: (title) => `W „${title}” wystąpił błąd`,
bg_error_multi: (count) => `W ${count} sesjach wystąpił błąd`,
// skill form
+15 -2
View File
@@ -167,7 +167,7 @@
<button class="rail-btn nav-tab dashboard-link has-tooltip" id="dashboardRailBtn" data-dashboard-link style="display:none" onclick="openHermesDashboard(event)" data-tooltip="Hermes Dashboard" data-i18n-title="tab_dashboard" aria-label="Hermes Dashboard"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg><span class="dashboard-external-badge" aria-hidden="true"></span></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="logs" onclick="switchPanel('logs',{fromRailClick:true})" data-tooltip="Logs" data-i18n-title="tab_logs" aria-label="Logs"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h8"/><path d="M8 9h2"/></svg></button>
<div class="rail-spacer"></div>
<button class="rail-btn nav-tab has-tooltip" data-panel="settings" onclick="switchPanel('settings',{fromRailClick:true})" data-tooltip="Settings" data-i18n-title="tab_settings" aria-label="Settings"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="settings" onclick="switchPanel('settings',{fromRailClick:true})" data-tooltip="Settings" data-i18n-title="tab_settings" aria-label="Settings"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg><span class="auth-warning-badge" id="authWarningBadgeDesktop" style="display:none;position:absolute;top:4px;right:4px;width:8px;height:8px;border-radius:50%;background:#e05"></span></button>
</nav>
<aside class="sidebar">
@@ -184,7 +184,7 @@
<button class="nav-tab dashboard-link has-tooltip has-tooltip--bottom" id="dashboardMobileBtn" data-dashboard-link data-label="Dashboard" style="display:none" onclick="openHermesDashboard(event)" data-tooltip="Hermes Dashboard" data-i18n-title="tab_dashboard"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg><span class="dashboard-external-badge" aria-hidden="true"></span></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="logs" data-label="Logs" onclick="switchPanel('logs',{fromRailClick:true})" data-tooltip="Logs" data-i18n-title="tab_logs"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h8"/><path d="M8 9h2"/></svg></button>
<!-- Settings button mirrored here for mobile (rail is desktop-only via @media >=768px). Keep in sync with rail entry. -->
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="settings" onclick="switchPanel('settings',{fromRailClick:true})" data-tooltip="Settings" data-i18n-title="tab_settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="settings" onclick="switchPanel('settings',{fromRailClick:true})" data-tooltip="Settings" data-i18n-title="tab_settings" style="position:relative"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg><span class="auth-warning-badge" id="authWarningBadgeMobile" style="display:none;position:absolute;top:4px;right:4px;width:8px;height:8px;border-radius:50%;background:#e05"></span></button>
</div>
<!-- Flash-prevention: apply hidden tab visibility from localStorage synchronously
before any paint. Mirrors the theme/skin/font-size inline scripts in <head>.
@@ -1381,9 +1381,22 @@
<div class="settings-field">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_password">Enter a new password to set or change it. Leave blank to keep current setting.</div>
<div id="settingsAuthStatus" style="margin-bottom:8px;display:none"></div>
<input type="password" id="settingsPassword" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
<div id="settingsCurrentPasswordBlock" style="display:none;margin-top:6px">
<label for="settingsCurrentPassword" style="font-size:11px;display:block;margin-bottom:4px" data-i18n="current_password_label">Current Password</label>
<input type="password" id="settingsCurrentPassword" placeholder="Enter current password…" data-i18n-placeholder="current_password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<div id="settingsPasswordEnvLock" data-i18n="password_env_var_locked" style="display:none;margin-top:6px;padding:8px 10px;font-size:11px;color:var(--muted);background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;line-height:1.45">The HERMES_WEBUI_PASSWORD environment variable is currently set and takes precedence. Unset it and restart the server to manage the password from here.</div>
</div>
<div id="settingsAuthDisabledWarning" style="display:none;margin-top:8px;padding:10px 12px;border-radius:8px;border:1px solid rgba(220,80,80,.35);background:rgba(220,80,80,.08)">
<div style="font-size:12px;font-weight:700;color:rgba(220,80,80,.95);margin-bottom:6px" data-i18n="auth_status_unauthenticated">Unauthenticated</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px;line-height:1.45" data-i18n="auth_disabled_warning_message">This instance is accessible without authentication.</div>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text);cursor:pointer">
<input type="checkbox" id="settingsAuthDisabledAck" onchange="_setAuthDisabledAck(this.checked)">
<span data-i18n="auth_acknowledged_label">Ive reviewed this risk; show a less-urgent nav warning.</span>
</label>
</div>
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none" data-i18n="disable_auth">Disable Auth</button>
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
<div class="settings-field" id="shutdownServerBlock" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
+117 -6
View File
@@ -7467,8 +7467,13 @@ async function loadSettingsPanel(){
// Show auth buttons only when auth is active
try{
const authStatus=await api('/api/auth/status');
_settingsPasswordAuthEnabled=!!authStatus.password_auth_enabled;
_setSettingsAuthButtonsVisible(!!authStatus.auth_enabled);
_syncPasswordlessButton(authStatus);
_renderSettingsAuthStatus(authStatus);
_updateCurrentPasswordVisibility();
_updateAuthWarningBadge(authStatus);
_updateAuthDisabledWarning(authStatus);
}catch(e){}
loadPasskeys();
// #1560: env-var-locked password also disables the Disable Auth button —
@@ -8293,6 +8298,7 @@ async function _refreshProviderModels(providerId, btn){
}
let _settingsPasswordEnvLocked=false;
let _settingsPasswordAuthEnabled=false;
function _setSettingsAuthButtonsVisible(active){
const signOutBtn=$('btnSignOut');
if(signOutBtn) signOutBtn.style.display=active?'':'none';
@@ -8309,6 +8315,63 @@ function _syncPasswordlessButton(authStatus){
btn.disabled=!can;
}
function _renderSettingsAuthStatus(authStatus){
const el=$('settingsAuthStatus');
if(!el) return;
if(!authStatus) { el.style.display='none'; return; }
el.style.display='block';
let label='',cls='detail-badge ok';
if(authStatus.auth_enabled && authStatus.password_auth_enabled){
label=t('auth_status_password'); cls='detail-badge ok';
}else if(authStatus.auth_enabled && !authStatus.password_auth_enabled){
label=t('auth_status_passkey_only'); cls='detail-badge warn';
}else{
label=t('auth_status_unauthenticated'); cls='detail-badge err';
}
el.innerHTML='<span class="'+cls+'" style="font-size:11px">'+label+'</span>';
}
function _updateCurrentPasswordVisibility(){
const block=$('settingsCurrentPasswordBlock');
if(!block) return;
block.style.display=_settingsPasswordAuthEnabled?'block':'none';
}
function _updateAuthWarningBadge(authStatus){
const badges=['authWarningBadgeDesktop','authWarningBadgeMobile'];
const authDisabled=!authStatus||!authStatus.auth_enabled;
const acknowledged=!!(authStatus&&authStatus.auth_disabled_acknowledged);
badges.forEach(function(id){
const el=$(id);
if(!el) return;
if(!authDisabled){ el.style.display='none'; return; }
el.style.display='block';
el.style.background=acknowledged?'#e8a030':'#e05';
});
}
function _updateAuthDisabledWarning(authStatus){
const el=$('settingsAuthDisabledWarning');
if(!el) return;
const authDisabled=!authStatus||!authStatus.auth_enabled;
if(!authDisabled){ el.style.display='none'; return; }
el.style.display='block';
const cb=$('settingsAuthDisabledAck');
if(cb) cb.checked=!!(authStatus&&authStatus.auth_disabled_acknowledged);
}
async function _setAuthDisabledAck(checked){
try{
await api('/api/settings',{method:'POST',body:JSON.stringify({_auth_disabled_acknowledged:!!checked})});
try{
const authStatus=await api('/api/auth/status');
_updateAuthWarningBadge(authStatus);
}catch(e){}
}catch(e){
showToast(t('auth_ack_save_failed')+e.message);
}
}
function _b64uToBytes(s){
s=String(s||'').replace(/-/g,'+').replace(/_/g,'/');
while(s.length%4) s+='=';
@@ -8957,8 +9020,17 @@ async function saveSettings(andClose){
body.bot_name=botName||'Hermes';
// Password: only act if the field has content; blank = leave auth unchanged
if(pw && pw.trim()){
const currentPwField=$('settingsCurrentPassword');
const currentPw=(currentPwField||{}).value||'';
if(_settingsPasswordAuthEnabled && !currentPw.trim()){
if(currentPwField) currentPwField.focus();
showToast(t('current_password_required'));
return;
}
const payload={...body,_set_password:pw.trim()};
if(_settingsPasswordAuthEnabled) payload._current_password=currentPw;
try{
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(payload)});
if(modelChanged && model){
try{
await api('/api/default-model',{method:'POST',body:JSON.stringify({model})});
@@ -8969,6 +9041,16 @@ async function saveSettings(andClose){
}
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showQuotaChip,showConversationOutline,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated'));
const cpField=$('settingsCurrentPassword'); if(cpField) cpField.value='';
const pwField=$('settingsPassword'); if(pwField) pwField.value='';
_settingsPasswordAuthEnabled=!!saved.password_auth_enabled;
_updateCurrentPasswordVisibility();
try{
const authStatus=await api('/api/auth/status');
_renderSettingsAuthStatus(authStatus);
_updateAuthWarningBadge(authStatus);
_updateAuthDisabledWarning(authStatus);
}catch(e){}
_settingsDirty=false;
_resetSettingsPanelState();
if(!andClose) _pendingSettingsTargetPanel = null;
@@ -9009,28 +9091,57 @@ async function signOut(){
async function goPasswordless(){
const ok=await showConfirmDialog({title:'Go passwordless?',message:'This removes the password and keeps passkey sign-in enabled. Keep at least one passkey registered or you could lose access.',confirmLabel:'Go passwordless',danger:false,focusCancel:true});
if(!ok) return;
const currentPw=($('settingsCurrentPassword')||{}).value;
const payload={_passwordless:true};
if(_settingsPasswordAuthEnabled && currentPw) payload._current_password=currentPw;
try{
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify({_passwordless:true})});
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(payload)});
showToast('Password removed. Passkey sign-in remains enabled.');
_setSettingsAuthButtonsVisible(!!saved.auth_enabled);
_syncPasswordlessButton({auth_enabled:saved.auth_enabled,password_auth_enabled:false,passkeys_count:1});
const pwField=$('settingsPassword'); if(pwField) pwField.value='';
const cpField=$('settingsCurrentPassword'); if(cpField) cpField.value='';
_settingsPasswordAuthEnabled=false;
_updateCurrentPasswordVisibility();
try{
const authStatus=await api('/api/auth/status');
_renderSettingsAuthStatus(authStatus);
_updateAuthWarningBadge(authStatus);
}catch(e){}
}catch(e){showToast('Failed to go passwordless: '+e.message);}
}
async function disableAuth(){
const _disAuth=await showConfirmDialog({title:t('disable_auth_confirm_title'),message:t('disable_auth_confirm_message'),confirmLabel:t('disable'),danger:true,focusCancel:true});
if(!_disAuth) return;
const currentPwField=$('settingsCurrentPassword');
const currentPw=(currentPwField||{}).value||'';
if(_settingsPasswordAuthEnabled && !currentPw.trim()){
if(currentPwField) currentPwField.focus();
showToast(t('current_password_required'));
return;
}
const confirmText='DISABLE AUTH';
const userInput=await showPromptDialog({title:t('disable_auth_confirm_title'),message:t('disable_auth_confirm_message')+' '+t('disable_auth_typed_confirm'),placeholder:confirmText,confirmLabel:t('disable_auth'),danger:true});
if(!userInput || userInput.trim()!==confirmText) return;
const payload={_clear_password:true};
if(_settingsPasswordAuthEnabled) payload._current_password=currentPw;
try{
await api('/api/settings',{method:'POST',body:JSON.stringify({_clear_password:true})});
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(payload)});
showToast(t('auth_disabled'));
// Hide auth controls since auth is now off
const disableBtn=$('btnDisableAuth');
if(disableBtn) disableBtn.style.display='none';
const signOutBtn=$('btnSignOut');
if(signOutBtn) signOutBtn.style.display='none';
_syncPasswordlessButton({auth_enabled:false,password_auth_enabled:false,passkeys_count:0});
_settingsPasswordAuthEnabled=false;
_updateCurrentPasswordVisibility();
const cpField=$('settingsCurrentPassword'); if(cpField) cpField.value='';
loadPasskeys();
try{
const authStatus=await api('/api/auth/status');
_renderSettingsAuthStatus(authStatus);
_updateAuthWarningBadge(authStatus);
_updateAuthDisabledWarning(authStatus);
}catch(e){}
}catch(e){
showToast(t('disable_auth_failed')+e.message);
}
+2
View File
@@ -5267,6 +5267,8 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
.detail-alert{border:2px solid rgba(245,158,11,.45);background:rgba(245,158,11,.1);border-radius:8px;padding:12px 14px;margin-bottom:16px;color:var(--text);}
.detail-alert-title{font-size:12px;font-weight:700;color:var(--error,#e05);text-transform:uppercase;letter-spacing:0;margin-bottom:6px;}
.detail-alert p{margin:0 0 8px;font-size:13px;line-height:1.45;color:var(--text);}
.auth-warning-badge{display:none;position:absolute;top:4px;right:4px;width:8px;height:8px;border-radius:50%;background:#e05;pointer-events:none;}
.nav-tab{position:relative;}
.detail-alert p:last-child{margin-bottom:0;}
.detail-alert-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;}
+5 -2
View File
@@ -5888,8 +5888,11 @@ function showPromptDialog(opts={}){
input.autocomplete='off';input.spellcheck=false;
}
if(cancelBtn) cancelBtn.textContent=opts.cancelLabel||t('cancel');
if(confirmBtn){confirmBtn.textContent=opts.confirmLabel||t('create');confirmBtn.classList.remove('danger');}
if(dialog) dialog.setAttribute('role','dialog');
if(confirmBtn){
confirmBtn.textContent=opts.confirmLabel||t('create');
confirmBtn.classList.toggle('danger',!!opts.danger);
}
if(dialog) dialog.setAttribute('role',opts.danger?'alertdialog':'dialog');
if(overlay){overlay.style.display='flex';overlay.setAttribute('aria-hidden','false');}
return new Promise(resolve=>{
APP_DIALOG.resolve=resolve;
+19 -10
View File
@@ -16,6 +16,7 @@ the cached result.
"""
import importlib
import os
import shutil
import sys
import tempfile
import threading
@@ -57,6 +58,10 @@ class TestPasswordHashCache(unittest.TestCase):
# doesn't cascade across test boundaries
os.environ.pop('HERMES_WEBUI_PASSWORD', None)
def tearDown(self):
os.environ.pop('HERMES_WEBUI_PASSWORD', None)
auth._invalidate_password_hash_cache()
def _set_env_pw(self, pw: str) -> None:
os.environ['HERMES_WEBUI_PASSWORD'] = pw
@@ -166,6 +171,10 @@ class TestPasswordHashCacheConcurrency(unittest.TestCase):
auth._AUTH_HASH_CACHE = None
os.environ.pop('HERMES_WEBUI_PASSWORD', None)
def tearDown(self):
os.environ.pop('HERMES_WEBUI_PASSWORD', None)
auth._invalidate_password_hash_cache()
def _set_env_pw(self, pw: str) -> None:
os.environ['HERMES_WEBUI_PASSWORD'] = pw
@@ -251,18 +260,18 @@ class TestPasswordCacheInvalidation(unittest.TestCase):
auth._AUTH_HASH_COMPUTED = False
auth._AUTH_HASH_CACHE = None
os.environ.pop('HERMES_WEBUI_PASSWORD', None)
# Start with a clean settings.json so write tests are isolated
self._sf = config.SETTINGS_FILE
self._backup = None
if self._sf.exists():
self._backup = self._sf.read_text(encoding='utf-8')
self._sf.unlink()
# Start with a clean temporary settings.json so write tests are isolated.
# The full pytest shard may already have the test server subprocess
# running. Mutating the shared TEST_STATE_DIR/settings.json here can make
# that server observe/cache a password and then return 401 to unrelated
# later HTTP tests. Keep this class fully in-process instead.
self._orig_settings_file = config.SETTINGS_FILE
self._tmpdir = Path(tempfile.mkdtemp())
config.SETTINGS_FILE = self._tmpdir / 'settings.json'
def tearDown(self):
if self._backup is not None:
self._sf.write_text(self._backup, encoding='utf-8')
elif self._sf.exists():
self._sf.unlink()
config.SETTINGS_FILE = self._orig_settings_file
shutil.rmtree(self._tmpdir, ignore_errors=True)
auth._invalidate_password_hash_cache()
os.environ.pop('HERMES_WEBUI_PASSWORD', None)
+286
View File
@@ -0,0 +1,286 @@
"""Auth-disable safety tests.
Verify that POST /api/settings requires _current_password when password auth
is already enabled and the user wants to change, clear, or go passwordless.
"""
import io
import json
import os
from urllib.parse import urlparse
import pytest
@pytest.fixture(autouse=True)
def _isolate_auth_settings_state(tmp_path, monkeypatch):
"""Keep these unit tests from mutating the shared pytest server state.
The full CI shard may already have the test server running. If these tests
write password_hash into the shared test settings.json, the server process can
observe/cache it and unrelated later API tests start returning 401. Point the
in-process config module at a per-test settings file instead.
"""
import api.config as cfg
from api.auth import _invalidate_password_hash_cache
monkeypatch.setattr(cfg, "SETTINGS_FILE", tmp_path / "settings.json")
_invalidate_password_hash_cache()
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
yield
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
_invalidate_password_hash_cache()
class _FakeHandler:
def __init__(self, body_bytes: bytes = b"", cookie: str = ""):
self.status = None
self.sent_headers = []
self.body = bytearray()
self.wfile = self
self.rfile = io.BytesIO(body_bytes)
self.headers = {
"Content-Length": str(len(body_bytes)),
}
if cookie:
self.headers["Cookie"] = cookie
self.request = None
# First-password setup is gated to local/private clients when auth is
# disabled. These in-process settings tests exercise the local bootstrap
# path, so model a loopback request explicitly.
self.client_address = ("127.0.0.1", 0)
def send_response(self, status):
self.status = status
def send_header(self, name, value):
self.sent_headers.append((name, value))
def end_headers(self):
pass
def write(self, data):
self.body.extend(data)
def header(self, name):
for key, value in self.sent_headers:
if key.lower() == name.lower():
return value
return None
def json_body(self):
return json.loads(bytes(self.body).decode("utf-8"))
def _post_settings(body_dict, cookie=""):
from api.routes import handle_post
raw = json.dumps(body_dict).encode("utf-8")
handler = _FakeHandler(body_bytes=raw, cookie=cookie)
parsed = urlparse("http://example.com/api/settings")
handle_post(handler, parsed)
return handler
def _get_settings():
from api.routes import handle_get
handler = _FakeHandler()
parsed = urlparse("http://example.com/api/settings")
handle_get(handler, parsed)
return handler.json_body(), handler.status
def _set_password_raw(pw):
from api.config import save_settings
from api.auth import _invalidate_password_hash_cache
save_settings({"_set_password": pw})
_invalidate_password_hash_cache()
def _clear_password_raw():
from api.config import save_settings
from api.auth import _invalidate_password_hash_cache
save_settings({"_clear_password": True})
_invalidate_password_hash_cache()
class TestChangePasswordRequiresCurrentPassword:
def test_cannot_change_password_without_current_password(self):
_set_password_raw("oldpassword")
handler = _post_settings({"_set_password": "newpassword"})
assert handler.status == 403
payload = handler.json_body()
assert "current password" in payload.get("error", "").lower()
def test_cannot_change_password_with_wrong_current_password(self):
_set_password_raw("oldpassword")
handler = _post_settings({"_set_password": "newpassword", "_current_password": "wrongpw"})
assert handler.status == 403
payload = handler.json_body()
assert "incorrect" in payload.get("error", "").lower()
def test_can_change_password_with_correct_current_password(self):
_set_password_raw("oldpassword")
handler = _post_settings({"_set_password": "newpassword", "_current_password": "oldpassword"})
assert handler.status == 200
payload = handler.json_body()
assert payload.get("auth_enabled") is True
_clear_password_raw()
class TestClearPasswordRequiresCurrentPassword:
def test_cannot_clear_password_without_current_password(self):
_set_password_raw("oldpassword")
handler = _post_settings({"_clear_password": True})
assert handler.status == 403
payload = handler.json_body()
assert "current password" in payload.get("error", "").lower()
def test_cannot_clear_password_with_wrong_current_password(self):
_set_password_raw("oldpassword")
handler = _post_settings({"_clear_password": True, "_current_password": "wrongpw"})
assert handler.status == 403
def test_cannot_clear_password_with_non_string_current_password(self):
_set_password_raw("oldpassword")
handler = _post_settings({"_clear_password": True, "_current_password": 123})
assert handler.status == 403
payload = handler.json_body()
assert "current password" in payload.get("error", "").lower()
def test_can_clear_password_with_correct_current_password(self):
_set_password_raw("oldpassword")
handler = _post_settings({"_clear_password": True, "_current_password": "oldpassword"})
assert handler.status == 200
payload = handler.json_body()
assert payload.get("auth_enabled") is False
class TestFirstTimePasswordNoCurrentRequired:
def test_first_time_password_works_without_current_password(self):
_clear_password_raw()
from api.auth import _invalidate_password_hash_cache
_invalidate_password_hash_cache()
handler = _post_settings({"_set_password": "firstpw"})
assert handler.status == 200
payload = handler.json_body()
assert payload.get("auth_enabled") is True
_clear_password_raw()
_invalidate_password_hash_cache()
def test_first_time_password_uses_auth_enabled_state_not_stale_hash(self, monkeypatch):
"""Disabled-auth bootstrap must not be blocked by stale password-hash state."""
monkeypatch.setattr("api.auth.is_auth_enabled", lambda: False)
monkeypatch.setattr("api.auth.get_password_hash", lambda: "stale-hash")
handler = _post_settings({"_set_password": "firstpw"})
assert handler.status == 200
payload = handler.json_body()
assert payload.get("auth_enabled") is False
class TestEnvVarPasswordLockStillRejects:
def test_env_var_lock_rejects_password_change(self):
os.environ["HERMES_WEBUI_PASSWORD"] = "envpw"
handler = _post_settings({"_set_password": "newpw", "_current_password": "envpw"})
assert handler.status == 409
def test_env_var_lock_rejects_password_clear(self):
os.environ["HERMES_WEBUI_PASSWORD"] = "envpw"
handler = _post_settings({"_clear_password": True, "_current_password": "envpw"})
assert handler.status == 409
class TestCurrentPasswordNotPersisted:
def test_current_password_not_in_settings_response(self):
_set_password_raw("oldpassword")
handler = _post_settings({"_set_password": "newpassword", "_current_password": "oldpassword"})
assert handler.status == 200
payload = handler.json_body()
assert "_current_password" not in payload
assert "current_password" not in payload
_clear_password_raw()
def test_current_password_not_in_get_settings(self):
payload, status = _get_settings()
assert status == 200
assert "_current_password" not in payload
assert "current_password" not in payload
class TestSettingsExposesAuthStateFields:
def test_get_settings_has_auth_enabled(self):
payload, status = _get_settings()
assert status == 200
assert "auth_enabled" in payload
assert "password_auth_enabled" in payload
def test_get_settings_never_exposes_password_hash(self):
payload, status = _get_settings()
assert status == 200
assert "password_hash" not in payload
def test_post_settings_includes_auth_state_fields(self):
_clear_password_raw()
from api.auth import _invalidate_password_hash_cache
_invalidate_password_hash_cache()
handler = _post_settings({"send_key": "enter"})
assert handler.status == 200
payload = handler.json_body()
assert "auth_enabled" in payload
assert "password_auth_enabled" in payload
class TestAuthDisabledAcknowledged:
def test_acknowledged_can_be_set_when_auth_disabled(self):
_clear_password_raw()
from api.auth import _invalidate_password_hash_cache
_invalidate_password_hash_cache()
handler = _post_settings({"_auth_disabled_acknowledged": True})
assert handler.status == 200
payload = handler.json_body()
assert payload.get("auth_disabled_acknowledged") is True
def test_acknowledged_resets_when_auth_enabled(self):
_set_password_raw("testpw")
handler = _post_settings({"_auth_disabled_acknowledged": True, "_current_password": "testpw"})
assert handler.status == 200
payload = handler.json_body()
assert payload.get("auth_disabled_acknowledged") is False
_clear_password_raw()
from api.auth import _invalidate_password_hash_cache
_invalidate_password_hash_cache()
def test_acknowledged_resets_when_setting_first_password(self):
_clear_password_raw()
handler = _post_settings({"_auth_disabled_acknowledged": True})
assert handler.status == 200
assert handler.json_body().get("auth_disabled_acknowledged") is True
handler = _post_settings({"_set_password": "firstpw"})
assert handler.status == 200
payload = handler.json_body()
assert payload.get("auth_enabled") is True
assert payload.get("auth_disabled_acknowledged") is False
_clear_password_raw()
from api.auth import _invalidate_password_hash_cache
_invalidate_password_hash_cache()
def test_auth_status_exposes_acknowledgement_only_while_disabled(self):
_clear_password_raw()
handler = _post_settings({"_auth_disabled_acknowledged": True})
assert handler.status == 200
from api.routes import handle_get
status_handler = _FakeHandler()
handle_get(status_handler, urlparse("http://example.com/api/auth/status"))
status_payload = status_handler.json_body()
assert status_payload.get("auth_enabled") is False
assert status_payload.get("auth_disabled_acknowledged") is True
_set_password_raw("testpw")
status_handler = _FakeHandler()
handle_get(status_handler, urlparse("http://example.com/api/auth/status"))
status_payload = status_handler.json_body()
assert status_payload.get("auth_enabled") is True
assert status_payload.get("auth_disabled_acknowledged") is False
_clear_password_raw()
from api.auth import _invalidate_password_hash_cache
_invalidate_password_hash_cache()
+119
View File
@@ -47,6 +47,125 @@ def test_ui_js_exposes_shared_dialog_helpers():
assert "document.addEventListener('keydown'" in src
def test_prompt_dialog_honors_custom_label_and_danger_state():
src = read("static/ui.js")
assert "confirmBtn.textContent=opts.confirmLabel||t('create')" in src
assert "confirmBtn.classList.toggle('danger',!!opts.danger)" in src
assert "opts.danger?'alertdialog':'dialog'" in src
def test_disable_auth_prompt_uses_destructive_label_not_create():
src = read("static/panels.js")
match = re.search(r"async function disableAuth\(\)\{(.*?)\n\}", src, re.DOTALL)
assert match, "disableAuth() not found"
body = match.group(1)
assert "const confirmText='DISABLE AUTH'" in body
assert "currentPwField=$('settingsCurrentPassword')" in body
assert "showToast(t('current_password_required'))" in body
assert body.index("showToast(t('current_password_required'))") < body.index("showPromptDialog")
assert "showPromptDialog" in body
assert "confirmLabel:t('disable_auth')" in body
assert "danger:true" in body
assert "t('create')" not in body
def test_auth_disabled_warning_uses_status_payload_without_extra_settings_fetch():
src = read("static/panels.js")
match = re.search(r"function _updateAuthDisabledWarning\(authStatus\)\{(.*?)\n\}", src, re.DOTALL)
assert match, "_updateAuthDisabledWarning(authStatus) not found"
body = match.group(1)
assert "authStatus&&authStatus.auth_disabled_acknowledged" in body
assert "api('/api/settings')" not in body
def test_acknowledgement_save_failure_uses_i18n_toast():
src = read("static/panels.js")
match = re.search(r"async function _setAuthDisabledAck\(checked\)\{(.*?)\n\}", src, re.DOTALL)
assert match, "_setAuthDisabledAck(checked) not found"
body = match.group(1)
assert "showToast(t('auth_ack_save_failed')+e.message)" in body
assert "Failed to update acknowledgement" not in body
def test_save_settings_password_change_preflights_current_password_before_api():
src = read("static/panels.js")
match = re.search(r"async function saveSettings\([^)]*\)\{(.*?)\n\}", src, re.DOTALL)
assert match, "saveSettings() not found"
body = match.group(1)
assert "currentPwField=$('settingsCurrentPassword')" in body
assert "showToast(t('current_password_required'))" in body
assert body.index("showToast(t('current_password_required'))") < body.index("api('/api/settings'")
def test_disable_auth_typed_confirm_locales_show_literal_phrase():
src = read("static/i18n.js")
values = re.findall(r"disable_auth_typed_confirm:\s*'([^']*)'", src)
assert values, "disable_auth_typed_confirm keys missing"
assert len(values) == len(_i18n_locale_blocks(src)), (
"Every locale must define disable_auth_typed_confirm exactly once"
)
missing_literal = [value for value in values if "DISABLE AUTH" not in value]
assert not missing_literal, (
"Disable-auth prompt must display the exact phrase accepted by "
f"disableAuth(), but these translations do not: {missing_literal}"
)
AUTH_SAFETY_LOCALE_KEYS = (
"current_password_label",
"current_password_placeholder",
"current_password_required",
"current_password_incorrect",
"disable_auth_typed_confirm",
"auth_status_password",
"auth_status_passkey_only",
"auth_status_unauthenticated",
"auth_warning_badge",
"auth_disabled_warning_message",
"auth_acknowledged_label",
"auth_ack_save_failed",
)
def _i18n_locale_blocks(src):
heads = list(re.finditer(r"^ (?:(?:'([^']+)')|([A-Za-z][A-Za-z0-9_]*)):\s*\{", src, re.M))
blocks = {}
for i, head in enumerate(heads):
locale = head.group(1) or head.group(2)
end = heads[i + 1].start() if i + 1 < len(heads) else src.find("\n};", head.end())
assert end != -1, f"could not find end of locale block {locale}"
blocks[locale] = src[head.end():end]
return blocks
def test_auth_safety_keys_exist_once_per_locale():
src = read("static/i18n.js")
blocks = _i18n_locale_blocks(src)
assert "pt" in blocks
assert "zh-Hant" in blocks
for locale, block in blocks.items():
missing = [key for key in AUTH_SAFETY_LOCALE_KEYS if f"{key}:" not in block]
duplicated = [
key for key in AUTH_SAFETY_LOCALE_KEYS
if len(re.findall(rf"\b{re.escape(key)}\s*:", block)) != 1
]
assert not missing, f"{locale} missing auth-safety locale keys: {missing}"
assert not duplicated, f"{locale} has duplicated auth-safety locale keys: {duplicated}"
def test_auth_safety_pt_and_zh_hant_strings_stay_in_correct_locale_blocks():
src = read("static/i18n.js")
blocks = _i18n_locale_blocks(src)
zh_hant = blocks["zh-Hant"]
pt = blocks["pt"]
assert "目前密碼" in zh_hant
assert "輸入 DISABLE AUTH" in zh_hant
assert "Senha atual" not in zh_hant
assert "Digite DISABLE AUTH" not in zh_hant
assert "Senha atual" in pt
assert "Digite DISABLE AUTH" in pt
def test_no_native_confirm_calls_remain_in_static_js():
for path in (REPO / "static").glob("*.js"):
src = path.read_text(encoding="utf-8")
+7 -2
View File
@@ -101,10 +101,15 @@ def test_first_password_enablement_returns_cookie_and_keeps_browser_logged_in():
_get_settings_file().write_text(_json.dumps(clean, indent=2), encoding="utf-8")
except Exception:
pass
# Then: tell the server to clear auth via API (must use the session cookie)
# Then: tell the server to clear auth via API (must use the session cookie
# and prove possession of the current password under auth-disable safety).
try:
_headers = {"Cookie": cookie_header} if cookie_header else {}
post("/api/settings", {"_clear_password": True}, headers=_headers)
post(
"/api/settings",
{"_clear_password": True, "_current_password": "sprint45-secret"},
headers=_headers,
)
except Exception:
pass
_restore_settings_file(original_settings)