diff --git a/app/__init__.py b/app/__init__.py index 7dde884..1088065 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -869,6 +869,15 @@ def _user_to_dict(u): 'last_login': u.last_login.isoformat() if u.last_login else None, } + def _admin_create_user_target(): + data = request.get_json(silent=True) + if not isinstance(data, dict): + return 'invalid-payload' + username = data.get('username') + if not isinstance(username, str): + return 'invalid-payload' + return username.strip() + @app.route('/admin') @admin_required @login_required @@ -887,13 +896,22 @@ def admin_list_users(): @login_required @step_up_required( 'user.create', - lambda: str((request.get_json(silent=True) or {}).get('username') or ''), + _admin_create_user_target, ) def admin_create_user(): - data = request.get_json(silent=True) or {} - username = (data.get('username') or '').strip() - password = data.get('password') or '' - make_admin = bool(data.get('is_admin')) + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'Invalid user payload'}), 400 + username = data.get('username') + password = data.get('password') + make_admin = data.get('is_admin', False) + if ( + not isinstance(username, str) + or not isinstance(password, str) + or type(make_admin) is not bool + ): + return jsonify({'error': 'Invalid user payload'}), 400 + username = username.strip() user, error = register_user(username, password) if error: return jsonify({'error': error}), 400 @@ -1001,7 +1019,9 @@ def admin_reset_user_mfa(user_id): WebAuthnCredential, ) - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'Invalid request'}), 400 target = db.session.get(User, user_id) if target is None: return jsonify({'error': 'User not found'}), 404 @@ -1050,7 +1070,9 @@ def admin_get_settings(): @login_required @step_up_required('settings.update', 'global') def admin_set_settings(): - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'Invalid settings payload'}), 400 if 'registration_enabled' in data: if type(data['registration_enabled']) is not bool: return jsonify({ diff --git a/app/admin_backup.py b/app/admin_backup.py index 11d18f4..4169e3c 100644 --- a/app/admin_backup.py +++ b/app/admin_backup.py @@ -403,7 +403,9 @@ def cancel_backup_operation(operation_id): @login_required @step_up_required('backup.restore_prepare', lambda operation_id: operation_id) def prepare_restore(operation_id): - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'Invalid request'}), 400 if data.get('acknowledge_sensitive_restore') is not True: return jsonify({'error': 'Restore acknowledgement is required'}), 400 try: @@ -437,7 +439,9 @@ def prepare_restore(operation_id): def restore_uploaded_backup(operation_id): if _rate_limited('backup_restore', config.RATELIMIT_BACKUP_RESTORE): return jsonify({'error': 'Too many restore attempts'}), 429 - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'Invalid request'}), 400 if ( data.get('confirm_destructive_restore') is not True or data.get('confirmation_phrase') != 'RESTORE' diff --git a/app/audit_export.py b/app/audit_export.py index 0d2d41c..2046026 100644 --- a/app/audit_export.py +++ b/app/audit_export.py @@ -195,7 +195,9 @@ def generate(): @step_up_required("audit.retention", "global") def update_audit_retention(): _require_enabled() - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"error": "Invalid request"}), 400 value = data.get("backup_count") if type(value) is not int or not 1 <= value <= 90: return jsonify({ diff --git a/app/github_auth_routes.py b/app/github_auth_routes.py index 6bbde01..d4f7d09 100644 --- a/app/github_auth_routes.py +++ b/app/github_auth_routes.py @@ -119,7 +119,9 @@ def github_step_up_start(): from .auth_assurance import current_authentication_session from .step_up import account_step_up_intent, StepUpError - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'Invalid request'}), 400 token = data.get('intent') try: intent = account_step_up_intent(token, current_authentication_session()) diff --git a/app/ldap_routes.py b/app/ldap_routes.py index 4f8728f..9c910ac 100644 --- a/app/ldap_routes.py +++ b/app/ldap_routes.py @@ -43,6 +43,7 @@ _MAX_LDAP_FORM_BYTES = 4096 _MAX_LDAP_JSON_BYTES = 4096 _MAX_AUTO_PROVISIONED_USERNAME_LENGTH = 80 +_JSON_BODY_TOO_LARGE = object() def _bounded_json(): @@ -51,14 +52,15 @@ def _bounded_json(): request.content_length is not None and request.content_length > _MAX_LDAP_JSON_BYTES ): - return None + return _JSON_BODY_TOO_LARGE try: raw_data = request.get_data(cache=True) except RequestEntityTooLarge: - return None + return _JSON_BODY_TOO_LARGE if len(raw_data) > _MAX_LDAP_JSON_BYTES: - return None - return request.get_json(silent=True) or {} + return _JSON_BODY_TOO_LARGE + data = request.get_json(silent=True) + return data if isinstance(data, dict) else None def _request_body_too_large(): @@ -278,8 +280,10 @@ def ldap_login(): @step_up_required('ldap.link', lambda user_id: user_id) def link_ldap_identity(user_id): data = _bounded_json() - if data is None: + if data is _JSON_BODY_TOO_LARGE: return _request_body_too_large() + if data is None: + return jsonify({'error': 'Invalid request'}), 400 target = db.session.get(User, user_id) if target is None: return jsonify({'error': 'User not found'}), 404 @@ -289,7 +293,10 @@ def link_ldap_identity(user_id): }), 400 if data.get('confirm_username') != target.username: return jsonify({'error': 'Target confirmation does not match'}), 400 - directory_username = str(data.get('directory_username') or '').strip() + directory_username = data.get('directory_username') + if not isinstance(directory_username, str): + return jsonify({'error': 'Directory username is required'}), 400 + directory_username = directory_username.strip() if not directory_username or len(directory_username) > 256: return jsonify({'error': 'Directory username is required'}), 400 if target.ldap_identity is not None: @@ -430,8 +437,10 @@ def ldap_status(): ) def unlink_ldap_identity(user_id, identity_id): data = _bounded_json() - if data is None: + if data is _JSON_BODY_TOO_LARGE: return _request_body_too_large() + if data is None: + return jsonify({'error': 'Invalid request'}), 400 target = db.session.get(User, user_id) if target is None: return jsonify({'error': 'User not found'}), 404 @@ -440,7 +449,9 @@ def unlink_ldap_identity(user_id, identity_id): row = db.session.get(LDAPIdentity, identity_id) if row is None or row.user_id != target.id: return jsonify({'error': 'LDAP identity not found'}), 404 - new_password = data.get('new_password') or '' + new_password = data.get('new_password') + if not isinstance(new_password, str): + return jsonify({'error': 'New password is required'}), 400 if len(new_password) < config.MIN_PASSWORD_LENGTH: return jsonify({ 'error': ( diff --git a/app/oidc_routes.py b/app/oidc_routes.py index 20006cd..660809b 100644 --- a/app/oidc_routes.py +++ b/app/oidc_routes.py @@ -457,7 +457,9 @@ def oidc_callback(): @step_up_required('oidc.link', lambda user_id: user_id) def link_oidc_identity(user_id): _require_enabled() - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"error": "Invalid request"}), 400 target = db.session.get(User, user_id) if target is None: return jsonify({"error": "User not found"}), 404 @@ -467,7 +469,10 @@ def link_oidc_identity(user_id): }), 400 if data.get("confirm_username") != target.username: return jsonify({"error": "Target confirmation does not match"}), 400 - subject = str(data.get("subject") or "").strip() + subject = data.get("subject") + if not isinstance(subject, str): + return jsonify({"error": "OIDC subject is required"}), 400 + subject = subject.strip() if not subject or len(subject) > 512: return jsonify({"error": "OIDC subject is required"}), 400 row = OIDCIdentity( @@ -537,7 +542,9 @@ def list_oidc_identities(user_id): ) def unlink_oidc_identity(user_id, identity_id): _require_enabled() - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"error": "Invalid request"}), 400 target = db.session.get(User, user_id) if target is None: return jsonify({"error": "User not found"}), 404 diff --git a/app/socket_capacity.py b/app/socket_capacity.py index 6500e69..71c5c99 100644 --- a/app/socket_capacity.py +++ b/app/socket_capacity.py @@ -37,5 +37,11 @@ def release(self, socket_sid): self._by_user.pop(user_id, None) return user_id + def count_for_user(self, user_id): + """Return the number of process-local sockets owned by one user.""" + user_id = int(user_id) + with self._lock: + return len(self._by_user.get(user_id, ())) + socket_capacity = SocketCapacityRegistry() diff --git a/app/socket_events.py b/app/socket_events.py index 79f135e..b7a8dc8 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -398,7 +398,16 @@ def handle_disconnect(): socket_sid = request.sid _cancel_ssh_banner_prompts_for_socket(socket_sid) owner_id = socket_capacity.release(socket_sid) - user = get_user_from_socket(socket_sid) + try: + user = get_user_from_socket(socket_sid) + except Exception as error: + user = None + log_error( + 'Socket owner lookup failed on disconnect', + user_id=owner_id, + sid=socket_sid, + exception_type=type(error).__name__, + ) user_id = user.id if user else owner_id if user_id is not None: @@ -421,10 +430,35 @@ def handle_disconnect(): exception_type=type(error).__name__, ) - SocketSession.query.filter_by(socket_sid=socket_sid).delete() - db.session.commit() - - other_sessions = SocketSession.query.filter_by(user_id=user_id).count() + # The process-local capacity registry is authoritative for this + # single-worker runtime and remains available if persistent socket + # metadata cannot be updated during a database outage. + other_sessions = socket_capacity.count_for_user(user_id) + try: + SocketSession.query.filter_by(socket_sid=socket_sid).delete() + db.session.commit() + other_sessions = SocketSession.query.filter_by( + user_id=user_id + ).count() + except Exception as error: + try: + db.session.rollback() + except Exception as rollback_error: + log_error( + 'Socket metadata rollback failed on disconnect', + user_id=user_id, + sid=socket_sid, + exception_type=type(rollback_error).__name__, + ) + log_error( + 'Socket metadata cleanup failed on disconnect', + user_id=user_id, + sid=socket_sid, + exception_type=type(error).__name__, + ) + # A replacement socket may have connected after the fallback was + # sampled but before the metadata operation failed. + other_sessions = socket_capacity.count_for_user(user_id) if other_sessions == 0: try: diff --git a/static/js/app.js b/static/js/app.js index d4dc6df..d264b87 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2323,14 +2323,13 @@ // Scrollback lines setting const scrollbackInput = document.getElementById('scrollbackInput'); if (scrollbackInput) { - const savedScrollback = localStorage.getItem('terminalScrollback') || '500'; - scrollbackInput.value = savedScrollback; + scrollbackInput.value = String(TerminalManager.getScrollbackLines()); scrollbackInput.addEventListener('change', () => { let val = parseInt(scrollbackInput.value, 10); if (isNaN(val) || val < 50) val = 50; if (val > 10000) val = 10000; scrollbackInput.value = val; - localStorage.setItem('terminalScrollback', String(val)); + window.BrowserPreferences?.set('terminalScrollback', val); // Update all existing terminals Object.keys(TerminalManager.terminals).forEach(key => { TerminalManager.terminals[key].options.scrollback = val; diff --git a/static/js/command-set-manager.js b/static/js/command-set-manager.js index eba24ea..662009b 100644 --- a/static/js/command-set-manager.js +++ b/static/js/command-set-manager.js @@ -202,8 +202,8 @@ window.CommandSetManager = { const detail = document.createElement('span'); detail.textContent = this.t( 'commandSets.stepCount', - `${commandSet.steps.length} command step${commandSet.steps.length === 1 ? '' : 's'}`, - ); + 'Command steps: {count}', + ).replace('{count}', String(commandSet.steps.length)); preview.append(title, detail); }, diff --git a/static/js/i18n.js b/static/js/i18n.js index 7b868d1..92da6e3 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -157,8 +157,11 @@ const translations = { 'commandSets.edit': 'Edit command set', 'commandSets.duplicate': 'Duplicate', 'commandSets.steps': 'Steps', + 'commandSets.step': 'step', + 'commandSets.stepCount': 'Command steps: {count}', 'commandSets.addFreeText': 'Add free text', 'commandSets.freeText': 'Free text', + 'commandSets.emptyInline': 'Empty free-text step', 'commandSets.saveToLibrary': 'Save as library command', 'commandSets.useDefaultParameters': 'Use library parameters', 'commandSets.useSudo': 'Run commands with sudo', @@ -169,6 +172,7 @@ const translations = { 'commandSets.removeStep': 'Remove step', 'commandSets.noSteps': 'Add a library command or free-text step.', 'commandSets.empty': 'No command sets saved.', + 'commandSets.emptySearch': 'No command sets match this search.', 'commandSets.noSelectionHint': 'No commands will run after connecting.', 'commandSets.missingCommand': 'Missing library command', 'commandSets.missingSet': 'Missing command set', @@ -1131,6 +1135,7 @@ const translations = { 'common.delete': 'Delete', 'common.edit': 'Edit', + 'common.actions': 'Actions', 'common.cancel': 'Cancel', 'common.continue': 'Continue', 'common.save': 'Save', @@ -1185,6 +1190,7 @@ const translations = { 'commands.delete': 'Delete', 'commands.save': 'Save', 'commands.cancel': 'Cancel', + 'commands.noCommands': 'No commands found', 'commands.searchPlaceholder': 'Search commands, parameters, or descriptions...', 'commands.currentOs': 'Current OS', 'commands.detecting': 'Detecting...', @@ -1409,8 +1415,11 @@ const translations = { 'commandSets.edit': 'Sửa bộ lệnh', 'commandSets.duplicate': 'Nhân bản', 'commandSets.steps': 'bước', + 'commandSets.step': 'bước', + 'commandSets.stepCount': 'Số bước lệnh: {count}', 'commandSets.addFreeText': 'Thêm văn bản tự do', 'commandSets.freeText': 'Văn bản tự do', + 'commandSets.emptyInline': 'Bước văn bản tự do trống', 'commandSets.saveToLibrary': 'Lưu vào thư viện lệnh', 'commandSets.useDefaultParameters': 'Dùng tham số từ thư viện', 'commandSets.useSudo': 'Chạy lệnh bằng sudo', @@ -1421,6 +1430,7 @@ const translations = { 'commandSets.removeStep': 'Xóa bước', 'commandSets.noSteps': 'Thêm lệnh từ thư viện hoặc bước văn bản tự do.', 'commandSets.empty': 'Chưa lưu bộ lệnh nào.', + 'commandSets.emptySearch': 'Không có bộ lệnh nào khớp với tìm kiếm này.', 'commandSets.noSelectionHint': 'Không có lệnh nào chạy sau khi kết nối.', 'commandSets.missingCommand': 'Thiếu lệnh trong thư viện', 'commandSets.missingSet': 'Thiếu bộ lệnh', @@ -2383,6 +2393,7 @@ const translations = { 'common.delete': 'Xóa', 'common.edit': 'Chỉnh sửa', + 'common.actions': 'Thao tác', 'common.cancel': 'Hủy', 'common.continue': 'Tiếp tục', 'common.save': 'Lưu', @@ -2437,6 +2448,7 @@ const translations = { 'commands.delete': 'Xóa', 'commands.save': 'Lưu', 'commands.cancel': 'Hủy', + 'commands.noCommands': 'Không tìm thấy lệnh', 'commands.searchPlaceholder': 'Tìm kiếm lệnh, tham số hoặc mô tả...', 'commands.currentOs': 'OS hiện tại', 'commands.detecting': 'Đang phát hiện...', @@ -2660,8 +2672,11 @@ const translations = { 'commandSets.edit': 'Befehlssatz bearbeiten', 'commandSets.duplicate': 'Duplizieren', 'commandSets.steps': 'Schritte', + 'commandSets.step': 'Schritt', + 'commandSets.stepCount': 'Befehlsschritte: {count}', 'commandSets.addFreeText': 'Freitext hinzufügen', 'commandSets.freeText': 'Freitext', + 'commandSets.emptyInline': 'Leerer Freitextschritt', 'commandSets.saveToLibrary': 'Als Bibliotheksbefehl speichern', 'commandSets.useDefaultParameters': 'Parameter aus der Bibliothek verwenden', 'commandSets.useSudo': 'Befehle mit sudo ausführen', @@ -2672,6 +2687,7 @@ const translations = { 'commandSets.removeStep': 'Schritt entfernen', 'commandSets.noSteps': 'Füge einen Bibliotheksbefehl oder einen Freitext-Schritt hinzu.', 'commandSets.empty': 'Noch keine Befehlssätze gespeichert.', + 'commandSets.emptySearch': 'Keine Befehlssätze entsprechen dieser Suche.', 'commandSets.noSelectionHint': 'Nach dem Verbinden werden keine Befehle ausgeführt.', 'commandSets.missingCommand': 'Bibliotheksbefehl fehlt', 'commandSets.missingSet': 'Befehlssatz fehlt', @@ -3647,6 +3663,7 @@ const translations = { 'common.delete': 'Löschen', 'common.edit': 'Bearbeiten', + 'common.actions': 'Aktionen', 'common.cancel': 'Abbrechen', 'common.continue': 'Weiter', 'common.save': 'Speichern', @@ -3687,6 +3704,7 @@ const translations = { 'commands.delete': 'Löschen', 'commands.save': 'Speichern', 'commands.cancel': 'Abbrechen', + 'commands.noCommands': 'Keine Befehle gefunden', 'commands.searchPlaceholder': 'Befehle, Parameter oder Beschreibungen durchsuchen...', 'commands.currentOs': 'Aktuelles OS', 'commands.detecting': 'Erkennung läuft...', @@ -3910,8 +3928,11 @@ const translations = { 'commandSets.edit': 'Modifier l’ensemble', 'commandSets.duplicate': 'Dupliquer', 'commandSets.steps': 'étapes', + 'commandSets.step': 'étape', + 'commandSets.stepCount': 'Étapes de commande : {count}', 'commandSets.addFreeText': 'Ajouter du texte libre', 'commandSets.freeText': 'Texte libre', + 'commandSets.emptyInline': 'Étape de texte libre vide', 'commandSets.saveToLibrary': 'Enregistrer dans la bibliothèque', 'commandSets.useDefaultParameters': 'Utiliser les paramètres de la bibliothèque', 'commandSets.useSudo': 'Exécuter les commandes avec sudo', @@ -3922,6 +3943,7 @@ const translations = { 'commandSets.removeStep': 'Supprimer l’étape', 'commandSets.noSteps': 'Ajoutez une commande de la bibliothèque ou une étape en texte libre.', 'commandSets.empty': 'Aucun ensemble de commandes enregistré.', + 'commandSets.emptySearch': 'Aucun ensemble de commandes ne correspond à cette recherche.', 'commandSets.noSelectionHint': 'Aucune commande ne sera exécutée après la connexion.', 'commandSets.missingCommand': 'Commande de bibliothèque manquante', 'commandSets.missingSet': 'Ensemble de commandes manquant', @@ -4906,6 +4928,7 @@ const translations = { 'common.delete': 'Supprimer', 'common.edit': 'Modifier', + 'common.actions': 'Actions', 'common.cancel': 'Annuler', 'common.continue': 'Continuer', 'common.save': 'Enregistrer', @@ -4937,6 +4960,7 @@ const translations = { 'commands.delete': 'Supprimer', 'commands.save': 'Enregistrer', 'commands.cancel': 'Annuler', + 'commands.noCommands': 'Aucune commande trouvée', 'commands.searchPlaceholder': 'Rechercher des commandes, des paramètres ou des descriptions...', 'commands.currentOs': 'OS actuel', 'commands.detecting': 'Détection...', @@ -5160,8 +5184,11 @@ const translations = { 'commandSets.edit': 'Editar conjunto', 'commandSets.duplicate': 'Duplicar', 'commandSets.steps': 'pasos', + 'commandSets.step': 'paso', + 'commandSets.stepCount': 'Pasos de comando: {count}', 'commandSets.addFreeText': 'Añadir texto libre', 'commandSets.freeText': 'Texto libre', + 'commandSets.emptyInline': 'Paso de texto libre vacío', 'commandSets.saveToLibrary': 'Guardar en la biblioteca', 'commandSets.useDefaultParameters': 'Usar parámetros de la biblioteca', 'commandSets.useSudo': 'Ejecutar comandos con sudo', @@ -5172,6 +5199,7 @@ const translations = { 'commandSets.removeStep': 'Eliminar paso', 'commandSets.noSteps': 'Añade un comando de la biblioteca o un paso de texto libre.', 'commandSets.empty': 'No hay conjuntos de comandos guardados.', + 'commandSets.emptySearch': 'Ningún conjunto de comandos coincide con esta búsqueda.', 'commandSets.noSelectionHint': 'No se ejecutarán comandos después de conectar.', 'commandSets.missingCommand': 'Falta el comando de la biblioteca', 'commandSets.missingSet': 'Falta el conjunto de comandos', @@ -6156,6 +6184,7 @@ const translations = { 'common.delete': 'Eliminar', 'common.edit': 'Editar', + 'common.actions': 'Acciones', 'common.cancel': 'Cancelar', 'common.continue': 'Continuar', 'common.save': 'Guardar', @@ -6187,6 +6216,7 @@ const translations = { 'commands.delete': 'Eliminar', 'commands.save': 'Guardar', 'commands.cancel': 'Cancelar', + 'commands.noCommands': 'No se encontraron comandos', 'commands.searchPlaceholder': 'Buscar comandos, parámetros o descripciones...', 'commands.currentOs': 'OS actual', 'commands.detecting': 'Detectando...', @@ -6410,8 +6440,11 @@ const translations = { 'commandSets.edit': '编辑命令集', 'commandSets.duplicate': '复制', 'commandSets.steps': '步骤', + 'commandSets.step': '步骤', + 'commandSets.stepCount': '命令步骤:{count}', 'commandSets.addFreeText': '添加自由文本', 'commandSets.freeText': '自由文本', + 'commandSets.emptyInline': '空的自由文本步骤', 'commandSets.saveToLibrary': '保存为库命令', 'commandSets.useDefaultParameters': '使用命令库参数', 'commandSets.useSudo': '使用 sudo 运行命令', @@ -6422,6 +6455,7 @@ const translations = { 'commandSets.removeStep': '删除步骤', 'commandSets.noSteps': '添加库命令或自由文本步骤。', 'commandSets.empty': '尚未保存命令集。', + 'commandSets.emptySearch': '没有与此搜索匹配的命令集。', 'commandSets.noSelectionHint': '连接后不会运行任何命令。', 'commandSets.missingCommand': '缺少库命令', 'commandSets.missingSet': '缺少命令集', @@ -7397,6 +7431,7 @@ const translations = { 'common.delete': '删除', 'common.edit': '编辑', + 'common.actions': '操作', 'common.cancel': '取消', 'common.continue': '继续', 'common.save': '保存', @@ -7437,6 +7472,7 @@ const translations = { 'commands.delete': '删除', 'commands.save': '保存', 'commands.cancel': '取消', + 'commands.noCommands': '未找到命令', 'commands.searchPlaceholder': '搜索命令、参数或说明...', 'commands.currentOs': '当前系统', 'commands.detecting': '检测中...', @@ -7504,17 +7540,40 @@ const translations = { } }; +const BrowserPreferences = { + get(key, fallback = null) { + try { + const value = window.localStorage?.getItem(key); + return typeof value === 'string' && value ? value : fallback; + } catch { + return fallback; + } + }, + + set(key, value) { + try { + window.localStorage?.setItem(key, String(value)); + return true; + } catch { + return false; + } + } +}; + +const storedLanguage = BrowserPreferences.get('language', 'en'); + const i18n = { - currentLang: localStorage.getItem('language') || 'en', + currentLang: translations[storedLanguage] ? storedLanguage : 'en', t(key) { - return translations[this.currentLang][key] || translations['en'][key] || key; + const activeTranslations = translations[this.currentLang] || translations.en; + return activeTranslations[key] || translations.en[key] || key; }, setLanguage(lang) { if (translations[lang]) { this.currentLang = lang; - localStorage.setItem('language', lang); + BrowserPreferences.set('language', lang); this.updatePageText(); window.dispatchEvent(new CustomEvent('languageChanged', { detail: { lang } })); @@ -7583,6 +7642,7 @@ const i18n = { } }; +window.BrowserPreferences = BrowserPreferences; window.i18n = i18n; document.addEventListener('DOMContentLoaded', () => { diff --git a/static/js/settings-center.js b/static/js/settings-center.js index 7a888e3..5ff7654 100644 --- a/static/js/settings-center.js +++ b/static/js/settings-center.js @@ -207,13 +207,20 @@ function initScrollback() { const input = document.getElementById('scrollbackInput'); if (!input) { return; } - input.value = localStorage.getItem('terminalScrollback') || '500'; + const storedValue = window.BrowserPreferences?.get( + 'terminalScrollback', + '500', + ) ?? '500'; + let initialValue = Number.parseInt(storedValue, 10); + if (!Number.isFinite(initialValue)) { initialValue = 500; } + initialValue = Math.min(10000, Math.max(50, initialValue)); + input.value = String(initialValue); input.addEventListener('change', () => { let value = Number.parseInt(input.value, 10); if (!Number.isFinite(value) || value < 50) { value = 50; } if (value > 10000) { value = 10000; } input.value = String(value); - localStorage.setItem('terminalScrollback', String(value)); + window.BrowserPreferences?.set('terminalScrollback', value); setPreferenceStatus(t( 'settings.scrollbackSaved', 'Scrollback saved in this browser.' diff --git a/static/js/terminal-manager.js b/static/js/terminal-manager.js index f03314d..d017597 100644 --- a/static/js/terminal-manager.js +++ b/static/js/terminal-manager.js @@ -92,11 +92,21 @@ const TerminalManager = { return window.innerWidth < 768 || 'ontouchstart' in window; }, + getScrollbackLines() { + const rawValue = window.BrowserPreferences?.get( + 'terminalScrollback', + '500', + ) ?? '500'; + const parsed = Number.parseInt(rawValue, 10); + if (!Number.isFinite(parsed)) return 500; + return Math.min(10000, Math.max(50, parsed)); + }, + createTerminal(sessionId, terminalKey = null) { const key = terminalKey || sessionId; const monoFont = this.getMonoFont(); const theme = this.buildTheme(); - const scrollbackLines = parseInt(localStorage.getItem('terminalScrollback') || '500', 10); + const scrollbackLines = this.getScrollbackLines(); const terminal = new Terminal({ cursorBlink: true, fontSize: this.getResponsiveFontSize(), diff --git a/templates/admin.html b/templates/admin.html index c25c168..11f6b23 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -448,7 +448,7 @@

Final destru {% if not embedded %}
- + diff --git a/templates/change_password.html b/templates/change_password.html index eb58d71..bd607e1 100644 --- a/templates/change_password.html +++ b/templates/change_password.html @@ -119,7 +119,7 @@

Change Password

- + diff --git a/templates/index.html b/templates/index.html index 59c9566..d30864b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1418,7 +1418,7 @@

File Preview

window.WEBSSH_TRANSFER_LIMITS = Object.freeze({{ transfer_limits | tojson }}); window.WEBSSH_SSH_INPUT_LIMITS = Object.freeze({{ ssh_input_limits | tojson }}); - + @@ -1436,7 +1436,7 @@

File Preview

- + @@ -1447,7 +1447,7 @@

File Preview

- + @@ -1456,7 +1456,7 @@

File Preview

- + + diff --git a/templates/register.html b/templates/register.html index cef2256..470b0c8 100644 --- a/templates/register.html +++ b/templates/register.html @@ -136,7 +136,7 @@

Create a local WebSSH account

- + diff --git a/templates/security.html b/templates/security.html index fa0b446..35ae440 100644 --- a/templates/security.html +++ b/templates/security.html @@ -412,8 +412,8 @@

Co
- - + + {% if admin_panel_enabled and is_admin and not recovery_mode %} diff --git a/tests/js/i18n-storage.test.js b/tests/js/i18n-storage.test.js new file mode 100644 index 0000000..bfc4b35 --- /dev/null +++ b/tests/js/i18n-storage.test.js @@ -0,0 +1,50 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +const source = fs.readFileSync('static/js/i18n.js', 'utf8'); + +function loadI18n(localStorage) { + const document = { + documentElement: {}, + title: '', + addEventListener() {}, + querySelector() { return null; }, + querySelectorAll() { return []; }, + }; + const window = { + localStorage, + dispatchEvent() {}, + }; + const context = vm.createContext({ + CustomEvent: class CustomEvent {}, + document, + window, + }); + vm.runInContext(source, context); + return window; +} + +test('unsupported stored languages fall back to English', () => { + const window = loadI18n({ + getItem() { return 'unsupported'; }, + setItem() {}, + }); + + assert.equal(window.i18n.getLanguage(), 'en'); + assert.equal(window.i18n.t('common.actions'), 'Actions'); +}); + +test('blocked browser storage does not prevent translations from loading', () => { + const window = loadI18n({ + getItem() { throw new Error('storage denied'); }, + setItem() { throw new Error('storage denied'); }, + }); + + assert.equal(window.i18n.getLanguage(), 'en'); + assert.equal(window.i18n.setLanguage('de'), true); + assert.equal(window.i18n.getLanguage(), 'de'); + assert.equal(window.i18n.t('common.actions'), 'Aktionen'); + assert.equal(window.BrowserPreferences.set('example', 'value'), false); +}); diff --git a/tests/js/terminal-manager-layout.test.js b/tests/js/terminal-manager-layout.test.js index f079ab2..1ad4700 100644 --- a/tests/js/terminal-manager-layout.test.js +++ b/tests/js/terminal-manager-layout.test.js @@ -13,16 +13,24 @@ global.navigator = {}; require('../../static/js/terminal-manager.js'); const TerminalManager = global.window.TerminalManager; -test('new terminals use 500 scrollback lines when no preference is stored', () => { +test('terminal scrollback preferences are bounded and malformed values fall back', () => { const source = fs.readFileSync( path.join(__dirname, '../../static/js/terminal-manager.js'), 'utf8', ); - assert.match( - source, - /localStorage\.getItem\('terminalScrollback'\) \|\| '500'/, - ); + assert.equal(TerminalManager.getScrollbackLines(), 500); + for (const [stored, expected] of [ + ['not-a-number', 500], + ['10', 50], + ['700', 700], + ['20000', 10000], + ]) { + global.window.BrowserPreferences = {get: () => stored}; + assert.equal(TerminalManager.getScrollbackLines(), expected); + } + delete global.window.BrowserPreferences; + assert.match(source, /const scrollbackLines = this\.getScrollbackLines\(\)/); }); test('virtual keyboard detection follows visual viewport occlusion, not browser resize history', () => { diff --git a/tests/test_admin_backup.py b/tests/test_admin_backup.py index c1db5e8..62afe5b 100644 --- a/tests/test_admin_backup.py +++ b/tests/test_admin_backup.py @@ -127,6 +127,29 @@ def test_backup_endpoints_require_admin(app, client, isolated_operations): assert client.post('/admin/api/backups/upload', data=b'PK').status_code == 403 +def test_restore_mutations_reject_non_object_json( + app, client, isolated_operations +): + del isolated_operations + _create_user(app, 'restore_json_admin', admin=True) + _login(client, 'restore_json_admin') + operation_id = 'missing-operation' + + prepared = client.post( + f'/admin/api/backups/{operation_id}/restore/prepare', + json=['unexpected'], + headers=_step_up(client, 'backup.restore_prepare', operation_id), + ) + restored = client.post( + f'/admin/api/backups/{operation_id}/restore', + json=['unexpected'], + headers=_step_up(client, 'backup.restore', operation_id), + ) + + assert prepared.status_code == 400 + assert restored.status_code == 400 + + @pytest.mark.parametrize( 'endpoint', ('/admin/api/backups', '/admin/api/backups/upload'), diff --git a/tests/test_admin_routes.py b/tests/test_admin_routes.py index 5fcb237..5bc6712 100644 --- a/tests/test_admin_routes.py +++ b/tests/test_admin_routes.py @@ -126,6 +126,69 @@ def _mutation_step_up(client, path, data, target_id): return None +def test_admin_mutations_reject_non_object_json(app, client): + target_id = _prepare_role(app, client, 'admin') + requests = ( + ( + 'post', + '/admin/api/users', + password_step_up_headers( + client, 'user.create', 'invalid-payload' + )[0], + ), + ( + 'post', + '/admin/api/settings', + password_step_up_headers(client, 'settings.update', 'global')[0], + ), + ( + 'delete', + f'/admin/api/users/{target_id}/mfa', + password_step_up_headers( + client, 'user.mfa_reset', target_id + )[0], + ), + ) + + responses = [ + getattr(client, method)(path, json=['unexpected'], headers=headers) + for method, path, headers in requests + ] + + assert [response.status_code for response in responses] == [400, 400, 400] + + +@pytest.mark.parametrize( + 'payload', + ( + {'username': ['invalid'], 'password': 'password123'}, + {'username': 'new-user', 'password': ['invalid']}, + { + 'username': 'new-user', + 'password': 'password123', + 'is_admin': 'false', + }, + ), +) +def test_admin_create_user_rejects_malformed_fields(app, client, payload): + _prepare_role(app, client, 'admin') + username = payload.get('username') + target = ( + username.strip() + if isinstance(username, str) + else 'invalid-payload' + ) + + response = client.post( + '/admin/api/users', + json=payload, + headers=password_step_up_headers(client, 'user.create', target)[0], + ) + + assert response.status_code == 400 + assert response.get_json()['error'] == 'Invalid user payload' + + @pytest.mark.parametrize('role', ('anonymous', 'normal', 'locked', 'admin')) @pytest.mark.parametrize(('method', 'path', 'data'), ADMIN_REQUESTS) def test_every_admin_route_is_hidden_when_panel_is_disabled( diff --git a/tests/test_audit_export.py b/tests/test_audit_export.py index 001b87e..64f94b1 100644 --- a/tests/test_audit_export.py +++ b/tests/test_audit_export.py @@ -210,6 +210,31 @@ def test_retention_rejects_out_of_range_without_changing_handlers( assert calls == [] +def test_retention_rejects_non_object_json(app, client, monkeypatch): + from app import audit_export + + _create_user(app, "malformed_retention_admin", is_admin=True) + _login(client, "malformed_retention_admin") + calls = [] + monkeypatch.setattr( + audit_export, + "set_audit_backup_count", + lambda value: calls.append(value), + ) + + response = client.post( + "/admin/api/audit/retention", + json=[14], + headers=password_step_up_headers( + client, "audit.retention", "global" + )[0], + ) + + assert response.status_code == 400 + assert response.get_json() == {"error": "Invalid request"} + assert calls == [] + + def test_retention_endpoint_applies_selected_backup_count( app, client, monkeypatch ): diff --git a/tests/test_command_set_ui.py b/tests/test_command_set_ui.py index 830075b..72705a9 100644 --- a/tests/test_command_set_ui.py +++ b/tests/test_command_set_ui.py @@ -143,6 +143,12 @@ def test_connection_command_manager_uses_current_cache_version(): assert "filename='js/connection-command-manager.js') }}?v=2" in template +def test_command_set_manager_uses_current_cache_version(): + template = read('templates/index.html') + + assert "filename='js/command-set-manager.js') }}?v=8" in template + + def test_connection_and_profile_payloads_send_only_selected_set_id(): source = read('static/js/app.js') diff --git a/tests/test_github_auth_routes.py b/tests/test_github_auth_routes.py index 45b6ba4..3929198 100644 --- a/tests/test_github_auth_routes.py +++ b/tests/test_github_auth_routes.py @@ -73,6 +73,20 @@ def test_routes_are_hidden_until_admin_configuration_is_active(app, client): assert response.status_code == 404 +def test_github_step_up_start_rejects_non_object_json(app, client): + admin_id = _create_user(app, 'github_json_admin', is_admin=True) + _configure(app, admin_id) + _login(client, 'github_json_admin') + + response = client.post( + '/api/account/step-up/github/start', + json=['unexpected'], + ) + + assert response.status_code == 400 + assert response.get_json() == {'error': 'Invalid request'} + + def test_linked_identity_login_uses_numeric_id_and_callback_is_single_use( app, client, monkeypatch, ): diff --git a/tests/test_i18n_parity.py b/tests/test_i18n_parity.py index a42a119..a2576e5 100644 --- a/tests/test_i18n_parity.py +++ b/tests/test_i18n_parity.py @@ -242,24 +242,10 @@ def test_english_command_set_copy_explains_execution_boundaries(): def test_all_popup_translation_references_exist_in_every_locale(): i18n_source = Path('static/js/i18n.js').read_text(encoding='utf-8') - source_paths = sorted(Path('templates').glob('*.html')) + [ - Path('static/js/admin.js'), - Path('static/js/app.js'), - Path('static/js/auth.js'), - Path('static/js/binary-transfer-client.js'), - Path('static/js/command-library.js'), - Path('static/js/drag-drop-manager.js'), - Path('static/js/file-transfer.js'), - Path('static/js/security-ui.js'), - Path('static/js/session-diagnostics.js'), - Path('static/js/settings-center.js'), - Path('static/js/ssh-error-ui.js'), - Path('static/js/session-command-launcher.js'), - Path('static/js/smb-source-dialog.js'), - Path('static/js/sftp-file-manager.js'), - Path('static/js/webauthn.js'), - Path('static/js/webssh2-shell.js'), - ] + source_paths = ( + sorted(Path('templates').glob('*.html')) + + sorted(Path('static/js').glob('*.js')) + ) referenced_keys = set() for source_path in source_paths: source = source_path.read_text(encoding='utf-8') diff --git a/tests/test_key_management_ui.py b/tests/test_key_management_ui.py index c8c0437..0052b40 100644 --- a/tests/test_key_management_ui.py +++ b/tests/test_key_management_ui.py @@ -102,8 +102,8 @@ def test_key_replacement_event_updates_ui_and_asset_version(): assert "socket.on('key_replaced'" in APP assert 'ProfileManager.upsertKeySummary(data.key)' in APP assert "filename='js/profile-manager.js') }}?v=19" in TEMPLATE - assert "filename='js/i18n.js') }}?v=45" in TEMPLATE - assert "filename='js/app.js') }}?v=28" in TEMPLATE + assert "filename='js/i18n.js') }}?v=46" in TEMPLATE + assert "filename='js/app.js') }}?v=29" in TEMPLATE assert "filename='css/style.css') }}?v=24" in TEMPLATE diff --git a/tests/test_ldap_auth.py b/tests/test_ldap_auth.py index 9585113..9eb7e41 100644 --- a/tests/test_ldap_auth.py +++ b/tests/test_ldap_auth.py @@ -135,6 +135,39 @@ def _enable_ldap_blueprint(app, monkeypatch, directory): app.register_blueprint(ldap_routes.ldap_blueprint) +def test_ldap_identity_mutations_reject_non_object_json( + app, client, monkeypatch +): + directory = _FakeDirectory(_DirectoryIdentity( + provider="default", + subject="unused-id", + distinguished_name="uid=unused,dc=example,dc=com", + )) + _enable_ldap_blueprint(app, monkeypatch, directory) + _create_user(app, "ldap_json_admin", is_admin=True) + target_id = _create_user(app, "ldap_json_target") + login = client.post( + "/login", + data={"username": "ldap_json_admin", "password": "password123"}, + ) + assert login.status_code == 302 + + linked = client.post( + f"/admin/api/users/{target_id}/ldap-link", + json=["unexpected"], + headers=_step_up(client, "ldap.link", target_id), + ) + unlinked = client.delete( + f"/admin/api/users/{target_id}/ldap-identities/999", + json=["unexpected"], + headers=_step_up(client, "ldap.unlink", f"{target_id}:999"), + ) + + assert linked.status_code == 400 + assert unlinked.status_code == 400 + assert directory.lookups == [] + + def test_enabled_ldap_login_defaults_to_named_directory_source( app, client, diff --git a/tests/test_oidc_routes.py b/tests/test_oidc_routes.py index d13f3fa..506bb0b 100644 --- a/tests/test_oidc_routes.py +++ b/tests/test_oidc_routes.py @@ -119,6 +119,32 @@ def test_admin_link_requires_password_confirmation_and_stable_subject( assert identity.subject == "stable-subject" +def test_oidc_identity_mutations_reject_non_object_json( + app, client, monkeypatch +): + import config + + _create_user(app, "oidc_json_admin", is_admin=True) + target_id = _create_user(app, "oidc_json_target") + _login(client, "oidc_json_admin") + monkeypatch.setattr(config, "OIDC_ENABLED", True) + monkeypatch.setattr(config, "OIDC_ISSUER", "https://issuer.example") + + linked = client.post( + f"/admin/api/users/{target_id}/oidc-link", + json=["unexpected"], + headers=_step_up(client, "oidc.link", target_id), + ) + unlinked = client.delete( + f"/admin/api/users/{target_id}/oidc-identities/999", + json=["unexpected"], + headers=_step_up(client, "oidc.unlink", f"{target_id}:999"), + ) + + assert linked.status_code == 400 + assert unlinked.status_code == 400 + + def test_admin_can_list_and_unlink_the_exact_oidc_identity( app, client, monkeypatch ): diff --git a/tests/test_profile_launcher_ui.py b/tests/test_profile_launcher_ui.py index 9ece87c..a442290 100644 --- a/tests/test_profile_launcher_ui.py +++ b/tests/test_profile_launcher_ui.py @@ -34,7 +34,7 @@ def test_merged_profile_frontend_assets_have_distinct_cache_versions(): expected_versions = { "filename='css/style.css'": '?v=24', "filename='css/sftp-file-manager.css'": '?v=22', - "filename='js/i18n.js'": '?v=45', + "filename='js/i18n.js'": '?v=46', "filename='js/command-workspace.js'": '?v=4', "filename='js/command-palette-utils.js'": '?v=1', "filename='js/profile-launcher-utils.js'": '?v=5', @@ -42,16 +42,16 @@ def test_merged_profile_frontend_assets_have_distinct_cache_versions(): "filename='js/profile-manager.js'": '?v=19', "filename='js/session-workspace.js'": '?v=9', "filename='js/session-manager.js'": '?v=12', - "filename='js/terminal-manager.js'": '?v=12', + "filename='js/terminal-manager.js'": '?v=13', "filename='js/mobile-app-shell.js'": '?v=7', "filename='js/smb-source-dialog.js'": '?v=5', "filename='js/sftp-file-manager.js'": '?v=35', "filename='js/jump-host-manager.js'": '?v=4', "filename='js/command-library.js'": '?v=5', - "filename='js/command-set-manager.js'": '?v=7', + "filename='js/command-set-manager.js'": '?v=8', "filename='js/session-command-launcher.js'": '?v=5', "filename='js/connection-history.js'": '?v=2', - "filename='js/app.js'": '?v=28', + "filename='js/app.js'": '?v=29', } for asset, version in expected_versions.items(): asset_start = template.index(asset) diff --git a/tests/test_socket_session_lifecycle.py b/tests/test_socket_session_lifecycle.py index 8ff7f32..2a2c395 100644 --- a/tests/test_socket_session_lifecycle.py +++ b/tests/test_socket_session_lifecycle.py @@ -353,6 +353,123 @@ def test_last_socket_disconnect_cancels_user_transfers(app, monkeypatch): assert manager._records == {} +def test_last_socket_disconnect_cleans_pools_when_metadata_commit_fails( + app, monkeypatch +): + from app import connection_pool, smb_pool, socket_events + from app.models import db + from app.socket_capacity import socket_capacity + + socket_client, user_id = _authenticated_socket( + app, 'disconnect_metadata_failure_user' + ) + calls = [] + original_rollback = db.session.rollback + + def fail_commit(): + raise RuntimeError('database unavailable') + + def track_rollback(): + calls.append(('rollback',)) + original_rollback() + + monkeypatch.setattr( + socket_events, + 'get_user_from_socket', + lambda _socket_sid: (_ for _ in ()).throw( + RuntimeError('database unavailable') + ), + ) + monkeypatch.setattr(db.session, 'commit', fail_commit) + monkeypatch.setattr(db.session, 'rollback', track_rollback) + monkeypatch.setattr( + socket_events.transfer_manager, + 'cancel_all_for_user', + lambda owner_id: calls.append(('transfers', owner_id)), + ) + monkeypatch.setattr( + connection_pool.temp_connection_pool, + 'close_all_user_connections', + lambda owner_id: calls.append(('ssh', owner_id)) or 0, + ) + monkeypatch.setattr( + smb_pool.smb_connection_pool, + 'close_all_user_sources', + lambda owner_id: calls.append(('smb', owner_id)) or 0, + ) + + socket_client.disconnect() + + assert ('rollback',) in calls + assert ('transfers', user_id) in calls + assert ('ssh', str(user_id)) in calls + assert ('smb', str(user_id)) in calls + assert socket_capacity.count_for_user(user_id) == 0 + + +def test_metadata_failure_rechecks_replacement_socket_before_pool_cleanup( + app, monkeypatch +): + from app import connection_pool, smb_pool, socket_events + from app.models import db + from app.socket_capacity import socket_capacity + + socket_client, user_id = _authenticated_socket( + app, 'disconnect_reconnect_user' + ) + replacement_sid = 'replacement-after-fallback-sample' + cleanup_calls = [] + original_count_for_user = socket_capacity.count_for_user + count_calls = 0 + + def reconnect_after_first_count(owner_id): + nonlocal count_calls + count_calls += 1 + current_count = original_count_for_user(owner_id) + if count_calls == 1: + assert socket_capacity.reserve( + owner_id, + replacement_sid, + max_total=100, + max_per_user=100, + ) + return current_count + + monkeypatch.setattr( + db.session, + 'commit', + lambda: (_ for _ in ()).throw(RuntimeError('database unavailable')), + ) + monkeypatch.setattr( + socket_capacity, + 'count_for_user', + reconnect_after_first_count, + ) + monkeypatch.setattr( + socket_events.transfer_manager, + 'cancel_all_for_user', + lambda owner_id: cleanup_calls.append(('transfers', owner_id)), + ) + monkeypatch.setattr( + connection_pool.temp_connection_pool, + 'close_all_user_connections', + lambda owner_id: cleanup_calls.append(('ssh', owner_id)) or 0, + ) + monkeypatch.setattr( + smb_pool.smb_connection_pool, + 'close_all_user_sources', + lambda owner_id: cleanup_calls.append(('smb', owner_id)) or 0, + ) + + try: + socket_client.disconnect() + finally: + socket_capacity.release(replacement_sid) + + assert count_calls == 2 + assert cleanup_calls == [] + + def test_disconnect_cancels_only_transfers_prepared_by_that_socket(app, monkeypatch): from app import socket_events, transfer_routes from app.transfer_manager import TransferManager diff --git a/tests/test_webssh2_shell.py b/tests/test_webssh2_shell.py index 8f4275d..a39d5c9 100644 --- a/tests/test_webssh2_shell.py +++ b/tests/test_webssh2_shell.py @@ -138,7 +138,7 @@ def test_every_user_facing_page_uses_current_shared_asset_versions(app, client): assert response.status_code == 200 assert b'css/style.css?v=24' in response.data assert b'css/webssh-2.css?v=32' in response.data - assert b'js/i18n.js?v=45' in response.data + assert b'js/i18n.js?v=46' in response.data client.post("/logout") for path in ("/login", "/register"): @@ -146,7 +146,7 @@ def test_every_user_facing_page_uses_current_shared_asset_versions(app, client): assert response.status_code == 200 assert b'css/style.css?v=24' in response.data assert b'css/webssh-2.css?v=32' in response.data - assert b'js/i18n.js?v=45' in response.data + assert b'js/i18n.js?v=46' in response.data def test_compact_workspace_controls_keep_accessible_names_and_close_command_input(