From 8ea7ad582e99a2e1e3d458229fd5c41ffdf0ac16 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 21 Jun 2026 02:26:20 +0000 Subject: [PATCH] =?UTF-8?q?rebase=20#3581=20onto=20master=20(v0.51.546)=20?= =?UTF-8?q?for=20review+ship-gate=20=E2=80=94=20require=20current=20passwo?= =?UTF-8?q?rd=20before=20disabling=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/config.py | 2 + api/routes.py | 56 +++++ static/i18n.js | 162 +++++++++++++- static/index.html | 17 +- static/panels.js | 123 ++++++++++- static/style.css | 2 + static/ui.js | 7 +- tests/test_auth_password_hash_cache.py | 29 ++- tests/test_auth_settings_safety.py | 286 +++++++++++++++++++++++++ tests/test_sprint33.py | 119 ++++++++++ tests/test_sprint45.py | 9 +- 11 files changed, 788 insertions(+), 24 deletions(-) create mode 100644 tests/test_auth_settings_safety.py diff --git a/api/config.py b/api/config.py index 180c98adc..1528a6bc4 100644 --- a/api/config.py +++ b/api/config.py @@ -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})?$") diff --git a/api/routes.py b/api/routes.py index 6bffd4748..f90c14dcf 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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) diff --git a/static/i18n.js b/static/i18n.js index a867a92de..c6e0eab0e 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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: 'I’ve 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: 'J’ai examiné ce risque ; afficher un avertissement de navigation moins urgent.', + auth_ack_save_failed: 'Échec de la mise à jour de l’accusé 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 diff --git a/static/index.html b/static/index.html index 26dbbb1c3..72f44b464 100644 --- a/static/index.html +++ b/static/index.html @@ -167,7 +167,7 @@
- +